Skip to content

Iteration-count reporting, interface-width docs, and a lattice-pinning guard - #14

Merged
pastewka merged 3 commits into
mainfrom
fix/iteration-count-and-pinning-test
Sep 18, 2026
Merged

pastewka merged 3 commits into
mainfrom
fix/iteration-count-and-pinning-test

Conversation

@pastewka

Copy link
Copy Markdown
Contributor

Three independent fixes that came out of investigating why the J-FFT
preconditioner appeared to underperform in muTopOpt runs. (The preconditioner
turned out to be fine — muGrid's and muFFTTO's J-FFT agree to 8 digits on the
same problem; the slow convergence is a property of sharp SIMP density fields.)

1. DOC: interface-width default and README snippet

--eta's help and the comment at its default both said two grid spacings;
the code has used one since 820e95c (MAINT: eta = grid spacing).

The README's API example called
PhaseFieldRegularization(homog, eta=1.0, well_weight=1e-3). There is no
well_weight argument — the snippet raises TypeError. It also pinned
eta=1.0, an interface as wide as the whole unit cell, which collapses the
design to constant density; dropped so the example takes the one-grid-spacing
default. Verified the snippet now runs end to end.

2. BUG: accepted iterates vs raw optimizer steps

The per-iteration lines, the histories and the dumped frames are indexed by
the number of accepted iterates. The final iters= line, the
nb_iterations attribute and the final frame's label used info["nit"] — the
raw step count, which for the trust region also counts rejected trial steps.

Consequences: nb_iterations disagreed with the length of the histories it is
meant to index, the final frame carried a label from a different counter than
every other frame, and the duplicate-frame check compared the two counters
against each other — so whenever the last iterate had already been dumped it
was written twice, under a wrong label.

Now:

done: ...  iters=25 (30 trust-region steps incl. rejected)  ...
:nb_iterations = 25 ;  :nb_optimizer_steps = 30 ;  :frame_iterations = 0, 10, 20, 25 ;

nb_iterations equals the history length, frame_iterations is on one counter
throughout, and the raw count is kept as a new nb_optimizer_steps attribute.
simulate_conduction.py had the same conflation and gets the same fix.

This surfaced as --bfgs-maxiter 150 and 400 both reporting CONVERGENCE
with iters equal to the budget and an identical objective. The root cause of
that was a separate bug in NuMPI's trust-region driver, fixed in
muSpectre/NuMPI#94; with both in place the same run reports
iters=94 (117 trust-region steps) for either budget.

3. TST: lattice-pinning guard

New test/test_interface_pinning.py (42 cases, 0.1 s, no optimizer). Since
eta defaults to a single grid spacing, it is worth knowing how close that is
to the Peierls regime where a diffuse interface can no longer move between
lattice sites. Relative barrier dE/E over one pixel of translation:

eta/h 0.50 0.75 1.00 1.50 2.00
dE/E 1.7e-2 3.7e-4 6.1e-6 1.0e-9 1.3e-13

At eta = h the critical depinning force is ~2e-4 in energy per unit volume,
against a curvature force sigma/R of 0.3–20 for any feature that fits in a
unit cell, and a default --bfgs-gtol of 2.5 in the same (mesh-invariant
volume-fraction-derivative) units — 3 to 4 orders of margin, including at the
end of a run where it is tightest. Confirmed end to end: a half-pixel spectral
shift of the initial density moves the converged 64² objective by 2.2e-7
relative, the same as the integer-shift control.

eta = h/2 is asserted to be pinned. That positive control is the half that
matters — without it the upper bounds would pass vacuously if the energy ever
stopped depending on interface position. Bounds were cross-checked against a
fully relaxed landscape (each sub-pixel position re-minimized at fixed volume
and fixed first moment): relaxed and analytic agree to 2% at eta >= h, which
is what licenses the cheap analytic profile in the test.

Mutation-tested rather than trusted: tightening the eta = h bound to 1e-7
fails 6 cases; making the energy shift-independent fails 18, the positive
control among them.

Checks

  • pytest — 160 passed (118 before this PR + 42 new)
  • README API snippet runs
  • output-file counters verified against a real run

🤖 Generated with Claude Code

pastewka and others added 3 commits September 18, 2026 10:10
The --eta help and the comment at its default both said "two grid spacings";
the code has used one grid spacing since 820e95c (MAINT: eta = grid spacing).

The README's API example passed well_weight= to PhaseFieldRegularization,
which has no such argument (TypeError; the keyword is weight). It also pinned
eta=1.0 -- an interface as wide as the whole unit cell, which collapses the
design to constant density -- so drop it and take the one-grid-spacing default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The per-iteration lines, the objective/volume/CG histories and the dumped
frames are all indexed by the number of *accepted* iterates, but the final
`iters=` line, the `nb_iterations` attribute and the final frame's label used
`info["nit"]` -- the optimizer's raw step count, which for the trust region
also includes rejected trial steps. So `nb_iterations` disagreed with the
length of the histories it is supposed to index, and the final frame carried a
label from a different counter than every other frame.

Mixing the two also made the final-frame duplicate check miss: it compared an
accepted-count label against the raw count, so whenever the last iterate had
already been dumped it was written a second time under a wrong label.

Report the accepted count (so the final line agrees with the last
per-iteration line), name the raw count in parentheses when the two differ,
and record it separately as `nb_optimizer_steps`. Same fix in the conduction
driver, which had the same conflation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Modica-Mortola energy of a flat interface is translation invariant in the
continuum; on a grid it is not, and once the interface is only a couple of
nodes wide the sub-pixel translation ripple becomes a barrier that stops
features moving at all. The interface width defaults to one grid spacing, so
it is worth knowing where that sits on the curve.

Measured relative barrier dE/E over one pixel of translation:

    eta/h    0.50      0.75      1.00      1.50      2.00
    dE/E     1.7e-2    3.7e-4    6.1e-6    1.0e-9    1.3e-13

At eta = h the critical depinning force is ~2e-4 in energy per unit volume,
against a curvature force sigma/R of 0.3-20 for any feature that fits in a
unit cell and a default --bfgs-gtol of 2.5 in the same units -- 3 to 4 orders
of margin, including at the end of a run where it is tightest. Confirmed
end-to-end: a half-pixel spectral shift of the initial density moves the
converged 64^2 objective by 2.2e-7 relative, the same as the integer-shift
control.

The eta = h/2 case is asserted to be pinned. That positive control is the
half that matters: without it the upper bounds would pass vacuously if the
energy ever stopped depending on the interface position at all.

Bounds are the worst case over element x quadrature with ~10x headroom, and
were cross-checked against a fully relaxed landscape (each sub-pixel position
re-minimized at fixed volume and fixed first moment); relaxed and analytic
agree to 2% at eta >= h, which is what licenses the cheap analytic profile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pastewka
pastewka merged commit b21145e into main Sep 18, 2026
8 checks passed
@pastewka
pastewka deleted the fix/iteration-count-and-pinning-test branch September 18, 2026 09:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant