Skip to content

Release v0.4.8: develop -> main - #42

Merged
simonevadi merged 61 commits into
mainfrom
develop
Sep 3, 2026
Merged

Release v0.4.8: develop -> main#42
simonevadi merged 61 commits into
mainfrom
develop

Conversation

@simonevadi

Copy link
Copy Markdown
Contributor

Release promotion

  • Promote release changes from develop to main.
  • Version: v0.4.8

simonevadi and others added 30 commits July 15, 2026 09:47
Roughness was global per-grating; a single RoughnessSpec.sigma_nm was
applied to every interface. This adds per-layer roughness, where each
layer carries the roughness of its top interface, falling back to the
grating-level sigma when unset (backward compatible).

- LayerSpec gains roughness_sigma_nm; SingleLayerStack/MultilayerStack/
  CustomStack expose per-layer/per-material roughness arguments.
- BaseStack.interface_roughness_sigmas_bottom_up() / has_per_layer_roughness()
  build a per-interface sigma array used by both roughness kinds.
- gratings._roughness_interface_offset() perturbs each interface by its
  own sigma; the underresolved warning uses the max per-interface sigma.
- Per-layer debye-waller sigmas combine in quadrature into an effective
  scalar; a bare RoughnessSpec is unchanged.
- Fixed-angle roughness example uses the per-layer system (uniform sigma
  across layers); tutorial + CHANGELOG updated; new test_stacks.py plus
  per-layer/quadrature tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEdvY6MWmJDeEm22pwbTRA
Add per-interface roughness fields to the grating form and a run-time
roughness-kind dropdown, wiring the existing per-layer roughness core
through the Flask app.

- stacks: add BaseStack.substrate_roughness_sigma_nm so the substrate
  boundary (interface 0) can be set explicitly; wire it into
  interface_roughness_sigmas_bottom_up() / has_per_layer_roughness() and
  the build_* helpers.
- persistence: serialize/deserialize substrate, layer/material, and top
  cap roughness sigmas; single-layer gratings now carry a coating stack
  so the sigmas round-trip. Bump SCHEMA_VERSION to 2; old specs without
  roughness keys still load (default None).
- grating form: roughness sigma input for substrate, single layer,
  material A, material B, and top cap; parsed via _optional_float.
- run form: None / Debye-Waller / Random interface dropdown. Attached to
  the grating in _execute_run_job via _attach_roughness(), covering every
  workflow and resume (run resolution clones preserve .roughness).
- tests: substrate-override stacks tests; persistence round-trip,
  backward-compat load, and _attach_roughness web tests; schema v2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEdvY6MWmJDeEm22pwbTRA
Add a geometry-only example that builds one laminar grating with an
extreme random-interface roughness (sigma = 100 nm) on the internal
Cr/C interface and exports a close-up image of the distorted profile.
The internal interface is used (rather than the top surface) so the full
excursion is visible instead of clipping at the top of the z-grid.

Also restrict the fixed_angle_roughness close-up export to the
geometry-distorting random-interface runs; Debye-Waller close-ups (which
do not change the geometry) are skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEdvY6MWmJDeEm22pwbTRA
Set substrate_roughness_sigma_nm to the same per-layer sigma so the
substrate/coating boundary (interface 0) is roughened too, instead of
falling back to the flat default. Previously only each layer's top
interface carried roughness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEdvY6MWmJDeEm22pwbTRA
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEdvY6MWmJDeEm22pwbTRA
Replace the per-sample white-noise interface with a periodic Gaussian
random field with a Gaussian autocorrelation, characterised by a lateral
correlation length. White noise put power at every spatial frequency up
to the grid Nyquist, giving unphysical per-pixel spikes; real metrology
shows a clear characteristic lateral scale.

- RoughnessSpec.correlation_length_nm: autocorrelation length in nm.
  None -> period/10 (visible per-period structure at any pitch);
  0.0 -> legacy white noise; >0 -> explicit length.
- gratings: synthesise the offset from a Gaussian power spectrum via
  rFFT on the periodic domain, then normalise to sigma (per-layer sigma
  semantics unchanged). Deterministic per seed+interface.
- Tests: correlated field is far smoother than white noise, default
  correlation length tracks the period, 0.0 reproduces white noise, plus
  validation. Docs + changelog updated, incl. the ~10 um metrology
  reference and the note that long correlations suit debye-waller.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEdvY6MWmJDeEm22pwbTRA
…amples

Library: RoughnessSpec gains num_supercells so random-interface roughness can
span multiple grating periods as one continuous correlated field; the RCWA
solver's fundamental period scales accordingly and diffraction orders are
reported in physical (possibly fractional) units end to end.

Examples: split the fixed-angle roughness example into a roughness-kind
comparison and a roughness-correlation-length comparison, each with their own
results directory, whole-grating (not close-up) geometry plots, and a bash
runner. The correlation-length script now also sweeps num_supercells=5 and
overlays it on the same comparison plot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The parallel BatchSimulationRunner path serializes case results through
JSON records; deserialization cast the orders array back with dtype=int,
truncating fractional supercell orders (e.g. -1.2, -1.4, -1.6, -1.8 all
collapsed onto -1). The all-orders CSV then contained several near-zero
rows labeled order -1 per energy alongside the real one, which made the
comparison plot zigzag to zero between energy points for supercell runs.

Deserialize orders as float, apply the integer-preserving CSV order
formatting to the theta-search sweep writer too, and align the empty
placeholder arrays. Adds a round-trip regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
roughness_kind_comparison.py now saves, for each run, a diffraction-order-vs-
intensity plot at the energy closest to the middle of the sweep, showing the
full order spectrum (specular plus any diffuse satellite orders from
supercell roughness).

Across both roughness example scripts, all figure outputs (grating previews,
order-spectrum plots, comparison plots) now live in plots_roughness_* folders
instead of being mixed into results_roughness_*, which now hold only CSV
data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both roughness_kind_comparison.py and roughness_correlation.py accept
--family {baseline,debye-waller,random-interface} to run only that family
instead of the full sweep. The final comparison plot now scans the results
directory for whatever CSVs already exist on disk (rather than only this
invocation's runs), so running families one at a time still builds up a
complete comparison once all of them have been run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rent config

Library: RoughnessSpec gains num_realizations (default 8 for random-interface,
forced to 1 for debye-waller) to average incoherent disorder realizations per
simulated point, and seed now accepts None to draw real entropy once at
construction time. run_simulation transparently averages efficiencies across
realizations while keeping orders/angles (which don't depend on the specific
roughness draw) from a single solve.

Examples: both roughness scripts stop hardcoding a fixed seed (None draws
real, non-reproducible randomness) and expose ROUGHNESS_NUM_REALIZATIONS.
The roughness-kind comparison plot (inline and standalone) now only includes
CSVs matching the script's current run configuration instead of globbing
every matching file in the results directory, so stale sigma/supercell
sweeps left over from earlier configs no longer leak into the plot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- _refresh_interactive_figure only calls plt.show() once per figure instead
  of on every redraw, so the macosx backend stops re-raising the window and
  the live-plot window can be minimized/backgrounded during a run.
- Force the spawn multiprocessing start method on macOS (fork is unsafe once
  BLAS/Numba/Cocoa native state is initialized) and size auto worker counts
  from each solve's peak RSS high-water mark, not just steady-state RSS.
- Re-run the roughness-kind comparison example with supercells=10 and let
  its comparison plot always try supercell counts 1/3/5/10 regardless of
  the script's current sweep config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add per-layer roughness support and fix macOS live-plot/worker-memory bugs
Failed trials use a large sentinel loss, which stretched the log-scale
y-axis and hid the real convergence trend. Cap the top at 10x the
first trial's loss whenever an outlier exceeds that ceiling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fits one parameter set against several measured curves recorded at
different grazing angles, each keeping its own energy grid. Simulated
efficiencies are reassembled by CaseExecutionResult.index because the
parallel batch runner yields in completion order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Persists the Ax client snapshot alongside optimizer run state and an
append-only trial log, so an interrupted run can continue and total_trials
can be raised to extend a finished one. A problem fingerprint refuses to
resume into a changed search space.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The best-fit plot now reuses the winning trial's simulated curve, and
plots are rewritten only when the best improves. Penalized trials log
the failing case or exception instead of failing silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Covers out-of-order result reassembly, joint loss reductions, resuming
with an extended total_trials, and the fingerprint and corruption guards.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AngleMeasurementSpec.from_mapping used a truthiness check that raised on
numpy arrays, which callers naturally pass for prepared energy grids and
efficiencies.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A non-resumed run opened trial_records.jsonl in append mode, so records
from an earlier run at the same output directory accumulated and a later
resume double-counted them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A non-resumed run rewrote created on every checkpoint write, so it
tracked the last write instead of when the run began.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The 1D solver lived in one flat module, which left no place for a second
solver to reuse its numerics. Split it into a package:

- solvers/common.py: shared types, res0/res1, the Fourier machinery, the
  layer field operators (including the Li/FFF rules for TM), the
  interface-response cascade, and the efficiency extraction
- solvers/rcwa.py: the modal layer block and res2, unchanged numerically

The tail of _solve_te_stack and _solve_tm_stack was identical apart from
the layer operator and the semi-infinite admittances, so it is now one
shared solve_stack_from_layer_blocks() parameterized by a per-layer block
builder. A second solver only has to supply that builder.

grax.rcwa_1d stays as a re-export shim so existing imports keep working.
Because a shim cannot be monkeypatched through, the cascade-parity test
now patches grax.solvers.common, where the name is actually resolved;
patching the alias silently left the optimized cascade in place and made
that test compare a run against itself.

Verified bit-identical all-order efficiencies and angles across eight
laminar/blazed/multilayer/sinusoidal cases in both polarizations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second, independent electromagnetic engine. Instead of eigen-decomposing
a z-invariant layer operator, it integrates the coupled first-order
system d/dz [F; G] = [[0, A], [B, 0]] [F; G] with fourth-order
Runge-Kutta.

A and B come from the shared layer_field_operators(), so the differential
method inherits the Li/fast-Fourier-factorization inverse rule for TM
rather than re-deriving it, and integrates exactly the same truncated
system RCWA solves. Agreement with RCWA is therefore a property of the
construction, not a coincidence: measured max|dR| is ~1e-10 at the
default step and drops to ~1e-14 as step_phase shrinks.

Step and sub-block sizes are set in units of optical phase, not
nanometers, so one setting behaves consistently across photon energies,
grazing angles, and truncation orders. The per-layer bound on |q| comes
from the operator's row norm, which is tight for the low-contrast
permittivities of X-ray optics.

Stability: a transfer matrix is only ever formed across a sub-block whose
optical thickness is capped by block_phase, and sub-blocks are combined
with the existing interface-response cascade. That cascade is an R-matrix
propagation, so deep gratings and strongly evanescent orders never build
up a growing exponential.

Two permittivity sampling modes:

- "textures" (default) integrates the same z-sliced permittivity RCWA
  uses, which is what makes the two solvers directly comparable
- "continuous" re-expands the permittivity from the true profile at each
  sub-block, dropping the staircase approximation entirely. Its result is
  independent of z_resolution and already closer to the z-converged limit
  than RCWA at z_resolution = 0.1 nm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follows the existing backend= / default_polarization= pattern:

- run_simulation and RCWASimulation take solver= and neviere_options=
- BatchSimulationRunner takes default_solver / default_neviere_options,
  and cases can override both per case
- SingleSimulationResult and CaseExecutionResult record which solver
  produced them, round-tripped through checkpoints so a resumed sweep
  keeps that provenance

Every default is "rcwa", so existing callers, saved checkpoints, examples
and the web UI behave exactly as before. The multilayer theta-search
workflow keeps its own payload and is unchanged.

The one-line-per-solve INFO log now names the solver, which is why the
logging test's expected string moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Unit tests cover solver parity against RCWA on laminar, blazed,
sinusoidal and multilayer gratings in both polarizations, and check that
the residual behaves like fourth-order truncation error: halving
step_phase cuts it by more than eight.

Two anchors do not lean on RCWA at all:

- a zero-depth grating must return the analytic Fresnel reflectivity of a
  plain vacuum/substrate interface in order zero and nothing anywhere
  else, which pins the absolute normalization in TE and TM
- a lossless dielectric grating must put its propagating reflected and
  transmitted orders at exactly one

The smoke tests use the published RETICOLO exemple1_1D values already
referenced elsewhere in the suite: a deep, high-contrast lamellar grating
at normal incidence, which is a far harder convergence case than the
shallow X-ray gratings this project normally runs. The differential
method reaches both the TE and the TM reference value.

That geometry also exposed a limit worth recording: the modal solver
forms q/sinh(q d) across a whole layer and overflows above roughly seven
wavelengths of depth, while the differential method stays finite and
energy-conserving at 167 wavelengths. The deep test asserts the modal
solver still fails there, so the claim gets revisited rather than
silently kept if that limit is ever lifted.

Also covers continuous z-sampling: it is now bit-identical across
z_resolution_nm, because the integration depth comes from the grating
geometry instead of the sliced profile's row count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
simonevadi and others added 29 commits August 15, 2026 11:14
- docs/developer/neviere-theory.md: the coupled system, why the TM
  operators use the inverse rule, why the propagation is an R-matrix
  cascade rather than a transfer-matrix product, and how the phase-based
  step settings work. Written as a delta against the RCWA theory page
  rather than repeating it.
- docs/tutorials/choosing-a-solver.md: when to reach for each solver, the
  batch-runner and per-case forms, and what continuous z-sampling buys.
- docs/validation/solver-comparison.md: how to reproduce the side-by-side
  runs and where the two solvers would be expected to diverge.
- examples/simulation/neviere_solver: one grating through both solvers,
  showing that tightening step_phase drives the residual towards zero and
  that continuous sampling matches a much finer staircase than the one it
  was run against. Registered with the smoke suite.
- References for Nevière/Vincent/Petit 1974, Nevière 1994, Nevière &
  Popov 2003, and Popov & Nevière 2000 on the TM equations.

The docs build copies the solver-comparison figures only when present, so
a docs build does not require running the sweeps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Roughness is applied around the solve rather than inside it, but the
supercell path also changes the period, the order grid and the
realization averaging, so it is worth asserting the differential method
threads through all of it unchanged rather than assuming it does.

Covers solver-level Debye-Waller, grating-level Debye-Waller, and
random-interface supercell roughness with realization averaging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comparison sweeps write checkpoints_neviere/ and checkpoints_rerun/
alongside the tracked checkpoints/ baseline. Those are scratch for a
rerun, not artifacts worth committing, and a partial one from an
interrupted sweep is actively misleading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every validation case run through both solvers on the same revision, with
per-order deviation tables and side-by-side figures.

                              points   max |dE|   max/peak
  laminar 400 l/mm               601    5.6e-11    3.1e-10
  blazed 600 l/mm                195    4.2e-12    1.7e-11
  laminar 150 l/mm               496    3.9e-12    1.2e-11
  blazed 2400 l/mm multilayer    173    8.7e-11    1.3e-10

All p polarization at production resolution; the multilayer case is
strided to 173 of its 1727 energy-angle pairs, spanning 500 to 6000 eV.
The differences are floating-point noise: in the difference panels they
form a structureless band rather than tracking the spectral features, and
they shrink as the fourth power of step_phase.

The brief anticipated a few percent. It is far better than that because
the two solvers are independent propagators for one shared model, not
independent models, which is worth being explicit about: the comparison
is a sharp test of the propagation and a weak test of everything upstream
of it. The analytic Fresnel and energy-conservation tests, the published
RETICOLO values, and the external-code pages cover the shared parts.

Each *_rerun.csv is a current-code RCWA baseline written alongside the
checked-in artifacts rather than over them, because those artifacts no
longer reproduce (2-4%, and 22% in the 12-28 eV tail of the 150 l/mm
case). That drift predates this work and applies equally to both current
solvers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fills in the per-order deviation table for all four cases, the drift
between the checked-in artifacts and current code, and a section on why
the two solvers agree as closely as they do and what that comparison
therefore does and does not test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each case folder now has a dedicated entry point sitting next to its RCWA
sweep, and its comparison script overlays the differential-method curve
alongside the external codes.

The entry points delegate to the existing sweep script with
--solver neviere rather than restating the setup. Duplicating a grating
definition across two files is how the two runs silently stop being
comparable: one gets a depth or resolution change and the other does not,
and the resulting plot looks like a solver disagreement. Extra arguments
are forwarded, so --stride and --quick still work.

The comparison scripts read the RCWA curve from a same-revision
*_rerun.csv when one exists, falling back to the checked-in artifact.
That matters here: the committed CSVs predate several solver changes, so
pairing a fresh differential-method run against them would render that
2-4% drift as though it were a difference between the two methods. Each
script now prints which file every curve came from.

The two solvers agree to ~1e-11, so the second curve would completely
hide the first. The differential-method curve is dashed over the solid
RCWA one, otherwise the plot reads as if a curve failed to load.

A missing solver run is skipped with a note rather than raising, so the
comparison scripts still work before the differential-method sweep has
been run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every validation folder now has the same three files:

  grating_definition.py   grating, materials, sweep grid, truncation
  run_rcwa.py             runs it with the modal solver
  run_neviere.py          runs it with the differential method

Both runners import the definition and nothing else shared, so they
cannot drift apart in geometry, energy grid or Fourier truncation. That
is the failure this structure is built against: two runners that each
carry their own copy of the setup eventually disagree on a depth or a
resolution, and the comparison plot then shows a solver disagreement
that is really a mismatched sweep.

Replaces the previous single sweep script with a --solver flag, and the
thin *_neviere.py entry points over it.

Each comparison script is now standalone as well, with no imports from
outside its folder. It plots whichever solvers have been run, reading
*_rcwa.csv and *_neviere.csv and falling back to the older checked-in
artifact only when no fresh RCWA run exists. The differential-method
curve is dashed over the solid RCWA one: they agree to ~1e-11, so drawn
solid the second curve hides the first and the plot reads as though a
curve failed to load.

The solver cross-check moved to tools/solver_comparison/ and out of the
docs. It is a developer tool for quantifying drift between the two
solvers, not part of the validation workflow.

Verified per case, against the pre-restructure runs: the restructured
RCWA runner reproduces them exactly (max |dE| = 0), and the differential
method still tracks it to ~1e-12.

Fixes a bug found by that check: the multilayer stack identifies
top_material by matching it against material_a or material_b, so loading
the same material twice returned two DataFrames and raised. The
optical-constants loaders are now cached.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The batch runner builds a separate payload for the theta-search workflow.
That payload carried backend but not solver, so

    BatchSimulationRunner(default_solver="neviere").run_cases(
        multilayer_theta_search_cases(...))

computed with RCWA. No error, no warning. The result was not mislabelled
-- it reported the solver that actually ran -- but the caller's request
was silently dropped.

run_multilayer_theta_search and run_multilayer_theta_search_sweep now
take solver and neviere_options next to backend, threaded through all
three stages (rough scan, precise scan, final solve) so the selected
angle and the final efficiency come from the same solver.

The root cause was two hand-built copies of the runner-settings mapping:
one in BatchSimulationRunner, one in the theta-search sweep. A setting
added to one was silently missed by the other. Both now go through
batch.runner_settings(), which owns the key list and rejects unknown
names, so the next setting cannot drift the same way.

Regression test asserts a neviere theta-search case reports "neviere";
it fails with assert ['rcwa'] == ['neviere'] before this change. A second
test pins that both solvers select the same angle and efficiency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
run_parameter_study now takes solver and neviere_options. It also gains
backend, which it previously hardcoded to "numba" internally -- it was
the one public entrypoint that silently fixed its own backend, so a
convergence study could not be reproduced against the backend it ran on.
Defaults preserve existing behaviour exactly.

Results also record solver_options alongside solver. The name alone does
not pin a differential-method result: the same "neviere" label covers
every step_phase and both sampling modes, so a checkpoint saying
"neviere" was not reproducible. The field is None for RCWA, populated
from the existing NeviereOptions.to_dict(), and round-trips through
checkpoint serialization -- a restored record rebuilds an equal
NeviereOptions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MeasurementFitConfig gains solver and neviere_options, validated beside
the existing backend check, readable from a spec mapping, and recorded in
the run metadata next to backend_requested / backend_effective.

The objective reads them off the config rather than taking them as
arguments, the same way it already reads diffraction_order and
fourier_orders. backend stays an argument because the caller resolves it
from "auto"; solver has no "auto" -- the two solvers are different
methods, not interchangeable implementations of one, so picking between
them automatically would be wrong.

This is where the differential method pays off most: it runs 2-5x faster
than RCWA at production resolution, and a fit is thousands of solves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The run form gets a solver dropdown beside Polarization, validated by
_normalized_solver() in the same shape as _normalized_polarization().
It reaches all four workflows: the batch runner covers fixed-angle,
monochromator and multilayer theta search, and the parameter-study branch
passes it through separately.

The choice is persisted in the run input and recorded in the manifest, so
a saved run says which solver produced it, and the run detail page shows
it. Without that a stored run was ambiguous the moment a second solver
existed.

This depends on the theta-search payload fix: the form offers that
workflow, so before it the dropdown would have been wrong for one of the
four.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Breaking change, no alias. The class drives whichever solver solver=
selects, so the RCWA-specific name described only one of the two things
it can do. Arguments and behaviour are unchanged; call sites need only
the new spelling.

Also documents where solver selection now reaches -- every workflow
entrypoint, with a table in the choosing-a-solver tutorial -- and how a
result reports the solver and, for the differential method, the
integration settings that produced it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four renames, no behaviour change.

default_ prefix dropped from the BatchSimulationRunner settings. The
prefix was accurate -- each of these really is the value a case inherits
when it omits its own key, and backend, the one setting with no per-case
override, correctly had no prefix -- but it made
default_fourier_orders: int = 25 read as the default of a default. The
runner's arguments now mirror run_simulation's exactly, which is a
stronger consistency story than a prefix encoding an override
relationship most callers never use. Each docstring now states that
relationship explicitly, since the name no longer carries it.

neviere_options -> solver_options everywhere. The input named a specific
solver while the result field it corresponds to was already called
solver_options; one name now covers both directions, and a third solver
would need no new parameter. Validation stays solver-aware, and the
NeviereOptions class keeps its name.

min_efficiency -> min_reflected_efficiency. It was half of a min/max pair
whose other half was already max_reflected_efficiency, and the batch
runner had to translate between the two spellings.

The progress bar printed "RCWA batch" regardless of which solver was
running; it now names the solver. Docstrings on SingleSimulationResult,
BatchSimulationRunner, gratings.py and run_parameter_study no longer
describe solver-agnostic machinery as RCWA-specific.

Left alone deliberately: grax.rcwa_1d (documented compatibility shim),
res0/res1/res2 (RETICOLO-derived, deliberately preserved), and
coerce_neviere_options / NeviereOptions, which are genuinely
Neviere-specific.

Verified: golden RCWA output bit-identical across 8 cases, and
validation/laminar/run_rcwa.py still reproduces its committed CSV exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every example that solves now takes --solver rcwa|neviere, defaulting to
rcwa. Seventeen scripts: each folder's entry script plus the five
polarization_comparison.py siblings. Excluded are the helpers_*.py and
comparison_*.py files in fixed_angle_roughness (they only plot) and
neviere_solver.py (it deliberately runs both).

One flag rather than a second copy of each example. The two solvers agree
to ~1e-11, so an example ending in an overlay of two identical curves
would teach nothing; a flag shows the thing worth showing, which is that
swapping solver is a one-argument change.

Solver-dependent outputs are suffixed, so an rcwa run and a neviere run
sit side by side instead of clobbering each other. Geometry artifacts
(*_profile.png, multilayer_stack_schematic.png) stay unsuffixed because
they do not depend on the solver -- the same split validation/ already
uses. Fourteen committed artifacts were renamed to *_rcwa.*; the moves
are pure renames and the tutorial snippets naming those paths follow.

Also fixes a bug unrelated to solver choice: multilayer_theta_search.py
and blazed_multilayer_memory_comparison.py use max_workers, and on macOS
the runner spawns workers that re-import the script by path. With no
__main__ guard each worker re-ran the whole example and recursively
spawned more. multilayer_theta_search.py was confirmed failing with
BrokenProcessPool before the fix and completes after it. The other two
max_workers examples already had main() guards and needed nothing.

That bug survived because the "example" smoke tests only compile the
scripts and check their structure -- nothing executes them. Worth
closing separately; noted rather than fixed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Duplicating each existing example per solver would have produced ten
plots of two indistinguishable curves. These three cover the cases where
the choice actually changes something, so each plot has a result to show.

deep_grating_limits sweeps groove depth on the published RETICOLO
exemple1_1D lamellar grating. The modal solver evaluates q/sinh(q d)
across a whole layer and stops at 8.4 wavelengths; the differential
method caps the optical thickness of any transfer matrix it forms and
reaches 167. Where both work they agree to 3.3e-12, and the differential
method holds its energy balance within 4e-11 of one throughout.

continuous_vs_staircase sweeps z_resolution_nm on a sinusoidal profile.
Continuous sampling is bit-identical at every resolution -- it never sees
a staircase -- while a staircase run carries 2.7e-3 of discretization
error at 2 nm, falling to 1.3e-5 at 0.05 nm. The two staircase curves
track each other to 6e-13, which is what makes the solvers comparable
elsewhere.

solver_runtime times both across three gratings. The docstring initially
claimed the gap was largest on the multilayer; the measurements did not
support that, so it now reports what was actually observed: the gap is
set by resolution, 1.2-1.4x reduced and 2.4-3.0x at production, because
coarse runs have too few distinct layers for the eigensolve to dominate.
Every timing is printed next to the max efficiency difference so the
speed number can be read against the accuracy it was obtained at.

Two plotting bugs found while checking the output: invert_xaxis() called
on both axes of a sharex pair cancels out, which had the convergence axis
labelled backwards; and the continuous-sampling error is exactly zero,
which a log axis silently drops rather than draws, so it is now pinned to
a labelled floor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The examples can all be run with --solver now, but the tests only
exercised a subset of what they call. Audited the grax API surface the
examples use against what the both-solver tests actually cover, and
filled the gaps:

  monochromator_cases                 7 example files, 0 tests
  energy_angle_cases                  2 example files, 0 tests
  run_parameter_study                 1 example file,  0 tests
  run_multilayer_theta_search_sweep   1 example file,  0 tests
  assemble_custom_stack / LayerSpec   2 example files, 0 tests
  AFMGrating                          2 example files, 0 tests
  write_all_orders_csv and friends   14 example files, 0 tests

Each now has a test asserting the two solvers agree, so swapping
--solver on any example lands on a tested path. run_parameter_study is
worth singling out: it drives GratingSimulation rather than the batch
runner, so it reaches the solver by a path nothing else takes.

The theta-search sweep test failed on the first run and found a real
bug. That sweep builds its own CaseExecutionResult and copied only the
diagnostics across, so solver and solver_options fell back to dataclass
defaults: a neviere sweep reported itself as rcwa. The computation was
right -- the two sweeps differ by 5e-14, so neviere genuinely ran -- but
a saved sweep recorded the wrong provenance. Now copied from the
underlying result.

While checking that, polarization looked like the same bug and is not:
the multilayer theta-search workflow never passes polarization to
run_simulation at all, so it is s-only and reporting "s" was correct.
Noted in the test rather than "fixed".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The library spoke two vocabularies for one concept. Internally it is
TE/TM -- res0's docstring says "1D TE/TM-style entry only", the solve
dispatches to _solve_te_stack or _solve_tm_stack -- while the public API
took only s/p. Anyone reading the theory docs or the Neviere and Li
papers had to already know the two were the same before they could call
the API.

s/p/TE/TM are now all accepted, case-insensitively, and canonicalize to
s/p so results, CSVs and checkpoints still carry only the two canonical
spellings and nothing downstream learns a second name. TE resolves before
any physics happens, and the tests assert bit-identical output rather
than merely close, since anything else would mean the spellings took
different paths.

Validation was copy-pasted with an identical message in core.py,
batch.py and web/app.py, and run_parameter_study did not validate at all
-- it failed several frames down inside the simulation wrapper. All four
now go through one normalize_polarization, exported from grax so callers
building case dicts can canonicalize the same way.

The docstring records why the alias is exact: s and TE name the same
state only in classical mounting. This solver is 1D classical, so the
equivalence holds, but it would stop holding if conical mounting were
ever added, and that constraint should sit where someone would break it.

The audit also found the multilayer theta-search workflow had no
polarization argument anywhere, so every search ran the default s --
awkward, given all four validation cases run p. It now threads through
all three stages, the case builder, the batch payload for that workflow
(which carried zero polarization keys, the same gap that hid the solver
bug) and the web form. Default stays s.

The test that matters asserts p selects a different angle and efficiency
from s (14.5260 vs 14.5849 degrees); asserting only that p runs would
have passed even if the value were stored and ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add Nevière solver and align GraxPy workflows
Brings in the Nevière differential-method solver series (PR #39). The
CHANGELOG conflict was both sides adding bullets under Unreleased; both
sets are kept.

The merge auto-resolved cleanly everywhere else, but that was misleading:
develop renamed the BatchSimulationRunner settings and added solver=/
solver_options= to the single-angle runner construction in objective.py,
while this branch had added a second runner construction for the joint
path that git left untouched with the old default_* kwargs. That call now
matches the single-angle one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Joint fitting was hardcoded to "same grating, several grazing angles".
Each measurement now carries its own conditions -- angle_mode,
grazing_angle_deg, cff, diffraction_order, polarization -- inheriting
whichever it does not set from the run, so one fit can span a whole
measurement campaign. Numerical settings (fourier_orders, solver,
backend, max_workers) stay run-level: they describe how a curve is
computed, not what was measured.

AngleMeasurementSpec becomes MeasurementSpec and JointAngleMeasurement
becomes JointMeasurement, since neither is angle-specific any more.
Neither name has shipped, so there is nothing to deprecate.

polarization was absent from grax_opt entirely, so every optimizer fit
had been silently running the run_simulation default of s -- the same
gap develop just closed for the theta-search workflow. It is now a real
argument on both configs.

solver/solver_options reach the joint path and are recorded in
best_result.json, matching the single-measurement optimizer. They join
the resume fingerprint alongside each measurement's resolved conditions,
so a resumed run cannot silently switch the physics it is fitting.

Two fixes found while reviewing the branch:

An interrupted run over-ran its trial budget on resume. The trial-record
log is appended every trial but the Ax snapshot is only rewritten every
checkpoint_interval trials, so an interruption left the log ahead of the
snapshot; the recovered history then counted trials Ax was about to
generate again, duplicating a row in trial_history.csv and running past
total_trials. Records at or beyond the reconciled cursor are discarded
now, since Ax is authoritative for what was issued. Only reachable with
checkpoint_interval > 1, which is why the default of 1 hid it.

The single-measurement evaluation allocated its efficiency array with
np.empty and filled by result index, so a case the runner never returned
left uninitialized memory as that point's efficiency. The joint path
already guarded against this; both do now.

examples/optimizer/optimizer_joint/ is the first runnable joint-fit
workflow. It simulates a four-condition measurement set from a known
grating, fits it, resumes to extend the budget, and reports how close
each parameter came to the truth. The measurements are always generated
with rcwa, so fitting them with --solver neviere recovers the same
geometry to within a few parts in ten thousand.

The example deliberately stops at 20 trials. The joint loss reaches the
noise floor there; a 100-trial run was measured to improve it by 5.5%
while making parameter recovery slightly worse, because past the floor
the optimizer fits the noise realization rather than the grating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Joint multi-measurement fitting, optimizer resume, and a runnable example
… case generator

Three fixes for the multilayer theta search:

- The `Requested theta half-width ... reaches near/into 0 deg` notice leaked to
  the terminal from the spawned batch workers, which re-import grax but never
  call setup_logging, so the record fell through to logging.lastResort. Attach a
  NullHandler to the `grax` logger at import; setup_logging now also sets
  propagate=False and de-duplicates its file handler. The notice itself drops
  from WARNING to INFO and is emitted once per distinct (center, half-width)
  instead of on every rough/fine re-centring attempt.
- multilayer_theta_search_cases gained `solver` / `solver_options`, matching
  run_multilayer_theta_search. The batch runner already honoured a per-case
  "solver" key; the generator just could not set one.
- The multilayer_theta_search example takes --polarization alongside --solver
  and writes each run under results/<solver>_<polarization>/ so s and p runs no
  longer overwrite each other.

Polarization and solver were already threaded through
run_multilayer_theta_search(_sweep) and the web run form on develop; this closes
the remaining gaps on that branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fix theta-search solver and polarization handling
@simonevadi
simonevadi merged commit 7a73c81 into main Sep 3, 2026
2 checks passed
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