Skip to content

Add Nevière solver and align GraxPy workflows - #39

Merged
simonevadi merged 26 commits into
developfrom
feature/neviere-differential-method
Aug 17, 2026
Merged

Add Nevière solver and align GraxPy workflows#39
simonevadi merged 26 commits into
developfrom
feature/neviere-differential-method

Conversation

@simonevadi

@simonevadi simonevadi commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Summary

GraxPy now provides two selectable 1D X-ray grating solver paths: the independent modal RCWA implementation inspired by RETICOLO and the Nevière differential method. RCWA remains the default.

  • Added the Nevière differential propagation engine, including phase-based integration controls and continuous permittivity sampling. Both solvers share the established Fourier/discretization setup but use different layer-propagation methods.
  • Threaded solver and solver options through single simulations, batch runs, theta searches, parameter studies, optimizer fitting, validation workflows, checkpoints/results, and the Web UI. Results record solver provenance.
  • Refactored shared solver infrastructure and added parity, Fresnel, energy-conservation, RETICOLO-reference, roughness, workflow, and polarization coverage.
  • Added solver-selection examples, specialist solver-difference studies, per-solver validation runners/artifacts, and full documentation/README repositioning for the two-solver package.
  • Updated example runners: grouped --grating, --optimizer, and --simulations execution; safe automatic workers and live progress plots; practical Fourier/resolution defaults; and a bounded roughness supercell demonstration.
  • Improved multilayer stack schematics with a bracket identifying repeated bilayers, their count, and period.

Compatibility

  • solver="rcwa" remains the default and the existing solver names are unchanged.
  • RCWASimulation was renamed to GratingSimulation; this is the intentional breaking API rename in this branch.
  • Public polarization input additionally accepts TE/TM, canonicalized to existing s/p output values.

Validation

  • Full solver parity/validation suite and checked-in comparison artifacts for laminar, blazed, multilayer, sinusoidal, roughness, optimizer, parameter-study, and theta-search paths.
  • pytest tests/unit/test_gratings.py -q — 34 passed.
  • Python compilation and shell syntax checks for the updated example runners.
  • Built HTML documentation with synchronized tutorial figures.

simonevadi and others added 25 commits August 15, 2026 09:23
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>
block_phase was doing two unrelated jobs. In the staircase modes it only
bounds the dynamic range of an explicitly formed transfer matrix, so its
default of 2.0 is fine. In continuous mode it also set the slab thickness
at which the true profile is read, which is an accuracy knob, and 2.0 is
far too coarse there: on a laminar grating it put the result 15% off the
converged answer.

Split it into sample_phase, the optical depth between permittivity
samples, defaulting to 0.02. At that setting continuous sampling lands
within a few 1e-6 to 4e-5 of a staircase converged at
z_resolution_nm = 0.005, for roughly the cost of one at 0.05.

Note that x_resolution_nm remains an accuracy floor for this mode: it
quantizes where the profile crosses each depth cut, which is what limits
the laminar case rather than sample_phase.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each sweep script gains --solver, --stride and --tag. With no flags a
script behaves exactly as before and writes to its historical paths, so
the checked-in artifacts stay reproducible; --solver neviere writes to
*_neviere.* siblings, and --tag writes a fresh run alongside the
committed files instead of over them.

validation/compare_solvers.py writes a per-order deviation table and a
side-by-side plot with a log-scale difference panel for each case, and
also prints the drift between the checked-in RCWA artifacts and a
current-code RCWA run. That drift is worth knowing about: the committed
CSVs were last written in June and the solver and its inputs have moved
since, so they no longer reproduce from current code. The solver
comparison therefore uses a fresh RCWA baseline on both sides.

Also fixes the sweep scripts crashing with BrokenProcessPool on macOS.
The batch runner spawns workers there, and a spawned worker re-imports
the script by path; without a __main__ guard each worker re-ran the whole
sweep and recursively spawned more workers, so none of these scripts
could run in parallel on macOS at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- 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>
@simonevadi
simonevadi changed the base branch from main to develop August 17, 2026 09:18
@simonevadi
simonevadi marked this pull request as ready for review August 17, 2026 09:18
@simonevadi simonevadi changed the title Improve example runners and defaults Add Nevière solver and align GraxPy workflows Aug 17, 2026
@simonevadi
simonevadi merged commit 4d7a872 into develop Aug 17, 2026
1 check passed
@simonevadi
simonevadi deleted the feature/neviere-differential-method branch August 17, 2026 09:20
simonevadi added a commit that referenced this pull request Aug 17, 2026
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>
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