From 0fbd60b87756cc07b8b00b74a2adf69756190fc3 Mon Sep 17 00:00:00 2001 From: Pete Jemian Date: Tue, 9 Jun 2026 11:36:36 -0500 Subject: [PATCH] fix(#293) add ConstraintSet.with_constraint_values and document fixed-axis overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #293 asked how to provide a non-default value for a fixed-axis constraint, and whether it was even possible. The answer was yes — by rebuilding the whole ConstraintSet — but the recipe was hard to discover from the per-mode reference docs, and the rebuild was verbose for any mode pinning more than one value (the psic B3 mode pins three, the sixc zaxis modes pin three, etc.). Add ConstraintSet.with_constraint_values(**updates) — a functional update method that returns a new ConstraintSet with the named constraint values replaced. Constraint order, computed, extras (with sentinel identity), and cut_points are all preserved. Receiver is unchanged; the method always returns a fresh instance. * Sample, detector, and reference constraints are matched by their .name attribute. * BisectConstraint and VirtualBisectConstraint are relational (no scalar value) and are intentionally invisible to the helper; any kwarg targeting one raises KeyError. * Unknown keys raise KeyError with a single message listing every unrecognised key and the available names. * Duplicate names within the receiver (a malformed mode that the YAML loader does not produce) raise ValueError. Document the helper, the rebuild-whole-ConstraintSet alternative, and the rationale for the immutable-constraint design in docs/source/howto/constraints.md. Add a top-level 'Change a fixed-axis value' FAQ heading to docs/source/howto/modes.md. Add a uniform 'Override at run time with g.modes[...] .with_constraint_values(...)' cross-link sentence to every fixed_* mode entry in the per-geometry reference pages (psic, fourcv, fourch, fivec, sixc, kappa4cv, kappa4ch, kappa6c, zaxis, s2d2) so this answer is discoverable from where users land. Contributed by: OpenCode (argo/claudeopus47) --- CHANGES.md | 5 + docs/source/geometries/fivec.md | 9 +- docs/source/geometries/fourch.md | 7 +- docs/source/geometries/fourcv.md | 7 +- docs/source/geometries/kappa4ch.md | 5 + docs/source/geometries/kappa4cv.md | 12 +- docs/source/geometries/kappa6c.md | 6 + docs/source/geometries/psic.md | 17 +- docs/source/geometries/s2d2.md | 4 +- docs/source/geometries/sixc.md | 8 +- docs/source/geometries/zaxis.md | 1 + docs/source/howto/constraints.md | 97 ++++++++++- docs/source/howto/modes.md | 48 ++++-- src/ad_hoc_diffractometer/mode.py | 115 +++++++++++++ tests/test_mode.py | 256 +++++++++++++++++++++++++++++ tests/test_regression_issue_292.py | 18 +- 16 files changed, 566 insertions(+), 49 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 32b54e97..c2d9bcae 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -13,6 +13,11 @@ Note any unreleased items inside the comment here. Not visible until release. ### Added - `register_geometry_yaml` in-memory registration entry point. (#288) +- `ConstraintSet.with_constraint_values()` one-call value override. (#293) + +### Changed + +- Document how to override fixed-axis constraint values. (#293) ### Fixed diff --git a/docs/source/geometries/fivec.md b/docs/source/geometries/fivec.md index 2620d29f..0cd4e314 100644 --- a/docs/source/geometries/fivec.md +++ b/docs/source/geometries/fivec.md @@ -86,8 +86,7 @@ Reduces to standard four-circle bisecting geometry. {class}`~ad_hoc_diffractometer.mode.SampleConstraint` × 2: `mu = 0`. `chi` is held at the value declared in the constraint (default in the demo geometry: 90°). -The caller chooses the value by constructing a {class}`~ad_hoc_diffractometer.mode.ConstraintSet`; the constraint -persists until replaced — see {doc}`../howto/constraints`. +Override at run time with `g.modes["fixed_chi"].with_constraint_values(chi=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -99,7 +98,7 @@ persists until replaced — see {doc}`../howto/constraints`. {class}`~ad_hoc_diffractometer.mode.SampleConstraint` × 2: `mu = 0`. `phi` is held at the value declared in the constraint (default in the demo geometry: 0°). -The caller chooses the value by constructing a {class}`~ad_hoc_diffractometer.mode.ConstraintSet`. +Override at run time with `g.modes["fixed_phi"].with_constraint_values(phi=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -111,7 +110,7 @@ The caller chooses the value by constructing a {class}`~ad_hoc_diffractometer.mo {class}`~ad_hoc_diffractometer.mode.SampleConstraint` + {class}`~ad_hoc_diffractometer.mode.BisectConstraint`: `omega = ttheta / 2`. `mu` is held at the value declared in the constraint (default in the demo geometry: 0°). -The caller chooses the value by constructing a {class}`~ad_hoc_diffractometer.mode.ConstraintSet`. +Override at run time with `g.modes["fixed_mu"].with_constraint_values(mu=...)` — see {doc}`../howto/constraints`. Intended for non-zero mu once the tilted-plane solver is implemented. | | | @@ -124,7 +123,7 @@ Intended for non-zero mu once the tilted-plane solver is implemented. {class}`~ad_hoc_diffractometer.mode.SampleConstraint` × 2: `mu = 0`. `omega` is held at the value declared in the constraint (default in the demo geometry: 0°). -The caller chooses the value by constructing a {class}`~ad_hoc_diffractometer.mode.ConstraintSet`. +Override at run time with `g.modes["fixed_omega_noncoplanar"].with_constraint_values(omega=...)` — see {doc}`../howto/constraints`. | | | |---|---| diff --git a/docs/source/geometries/fourch.md b/docs/source/geometries/fourch.md index dbc9f4f0..38e9af2a 100644 --- a/docs/source/geometries/fourch.md +++ b/docs/source/geometries/fourch.md @@ -81,8 +81,7 @@ Places the sample symmetrically between the incident and diffracted beams. {class}`~ad_hoc_diffractometer.mode.SampleConstraint`: `chi` is held at the value declared in the constraint (default in the demo geometry: 90°). -The caller chooses the value by constructing a {class}`~ad_hoc_diffractometer.mode.ConstraintSet`; the constraint -persists until replaced — see {doc}`../howto/constraints`. +Override at run time with `g.modes["fixed_chi"].with_constraint_values(chi=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -93,7 +92,7 @@ persists until replaced — see {doc}`../howto/constraints`. {class}`~ad_hoc_diffractometer.mode.SampleConstraint`: `phi` is held at the value declared in the constraint (default in the demo geometry: 0°). -The caller chooses the value by constructing a {class}`~ad_hoc_diffractometer.mode.ConstraintSet`. +Override at run time with `g.modes["fixed_phi"].with_constraint_values(phi=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -104,7 +103,7 @@ The caller chooses the value by constructing a {class}`~ad_hoc_diffractometer.mo {class}`~ad_hoc_diffractometer.mode.SampleConstraint`: `omega` is held at the value declared in the constraint (default in the demo geometry: 0°). -The caller chooses the value by constructing a {class}`~ad_hoc_diffractometer.mode.ConstraintSet`. +Override at run time with `g.modes["fixed_omega"].with_constraint_values(omega=...)` — see {doc}`../howto/constraints`. | | | |---|---| diff --git a/docs/source/geometries/fourcv.md b/docs/source/geometries/fourcv.md index 0d1f0ffe..5a0b6cbc 100644 --- a/docs/source/geometries/fourcv.md +++ b/docs/source/geometries/fourcv.md @@ -81,8 +81,7 @@ Places the sample symmetrically between the incident and diffracted beams. {class}`~ad_hoc_diffractometer.mode.SampleConstraint`: `chi` is held at the value declared in the constraint (default in the demo geometry: 90°). -The caller chooses the value by constructing a {class}`~ad_hoc_diffractometer.mode.ConstraintSet`; the constraint -persists until replaced — see {doc}`../howto/constraints`. +Override at run time with `g.modes["fixed_chi"].with_constraint_values(chi=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -93,7 +92,7 @@ persists until replaced — see {doc}`../howto/constraints`. {class}`~ad_hoc_diffractometer.mode.SampleConstraint`: `phi` is held at the value declared in the constraint (default in the demo geometry: 0°). -The caller chooses the value by constructing a {class}`~ad_hoc_diffractometer.mode.ConstraintSet`. +Override at run time with `g.modes["fixed_phi"].with_constraint_values(phi=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -104,7 +103,7 @@ The caller chooses the value by constructing a {class}`~ad_hoc_diffractometer.mo {class}`~ad_hoc_diffractometer.mode.SampleConstraint`: `omega` is held at the value declared in the constraint (default in the demo geometry: 0°). -The caller chooses the value by constructing a {class}`~ad_hoc_diffractometer.mode.ConstraintSet`. +Override at run time with `g.modes["fixed_omega"].with_constraint_values(omega=...)` — see {doc}`../howto/constraints`. | | | |---|---| diff --git a/docs/source/geometries/kappa4ch.md b/docs/source/geometries/kappa4ch.md index 009d1ffe..ffea22f7 100644 --- a/docs/source/geometries/kappa4ch.md +++ b/docs/source/geometries/kappa4ch.md @@ -134,6 +134,7 @@ decomposition. {class}`~ad_hoc_diffractometer.mode.SampleConstraint`: `kphi` held at declared value (default 0°) — real stage, no kappa inversion needed. +Override at run time with `g.modes["fixed_kphi"].with_constraint_values(kphi=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -143,6 +144,7 @@ decomposition. ### `fixed_omega` Fix virtual Eulerian omega at declared value (default 0°) — see {doc}`kappa4cv` for details. +Override at run time with `g.modes["fixed_omega"].with_constraint_values(omega=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -152,6 +154,7 @@ Fix virtual Eulerian omega at declared value (default 0°) — see {doc}`kappa4c ### `fixed_chi` Fix virtual Eulerian chi at declared value (default 90°). +Override at run time with `g.modes["fixed_chi"].with_constraint_values(chi=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -161,6 +164,7 @@ Fix virtual Eulerian chi at declared value (default 90°). ### `fixed_phi` Fix virtual Eulerian phi at declared value (default 0°). +Override at run time with `g.modes["fixed_phi"].with_constraint_values(phi=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -174,6 +178,7 @@ azimuthal angle ψ validation filter. Set ``g.azimuthal_reference = (h, k, l)`` before calling ``forward()``. Returns bisecting solutions only when the natural ψ for (h,k,l) matches the stored target. See {doc}`../howto/surface`. +Override the ψ target at run time with `g.modes["fixed_psi"].with_constraint_values(psi=...)` — see {doc}`../howto/constraints`. | | | |---|---| diff --git a/docs/source/geometries/kappa4cv.md b/docs/source/geometries/kappa4cv.md index 342724bb..de10c6d6 100644 --- a/docs/source/geometries/kappa4cv.md +++ b/docs/source/geometries/kappa4cv.md @@ -140,7 +140,7 @@ decomposition. {class}`~ad_hoc_diffractometer.mode.SampleConstraint`: `kphi` held at declared value (default 0°) — real stage, no kappa inversion needed. -The caller chooses the value by constructing a {class}`~ad_hoc_diffractometer.mode.ConstraintSet`. +Override at run time with `g.modes["fixed_kphi"].with_constraint_values(kphi=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -151,9 +151,8 @@ The caller chooses the value by constructing a {class}`~ad_hoc_diffractometer.mo {class}`~ad_hoc_diffractometer.mode.SampleConstraint`: Fix the virtual Eulerian omega at declared value (default 0°). -Solved analytically via the equivalent-Eulerian dispatch — the -caller chooses the value by constructing a -{class}`~ad_hoc_diffractometer.mode.ConstraintSet`. +Solved analytically via the equivalent-Eulerian dispatch. +Override at run time with `g.modes["fixed_omega"].with_constraint_values(omega=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -164,7 +163,7 @@ caller chooses the value by constructing a {class}`~ad_hoc_diffractometer.mode.SampleConstraint`: Fix the virtual Eulerian chi at declared value (default 90°). -The caller chooses the value by constructing a {class}`~ad_hoc_diffractometer.mode.ConstraintSet`. +Override at run time with `g.modes["fixed_chi"].with_constraint_values(chi=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -175,7 +174,7 @@ The caller chooses the value by constructing a {class}`~ad_hoc_diffractometer.mo {class}`~ad_hoc_diffractometer.mode.SampleConstraint`: Fix the virtual Eulerian phi at declared value (default 0°). -The caller chooses the value by constructing a {class}`~ad_hoc_diffractometer.mode.ConstraintSet`. +Override at run time with `g.modes["fixed_phi"].with_constraint_values(phi=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -189,6 +188,7 @@ azimuthal angle ψ validation filter. Set ``g.azimuthal_reference = (h, k, l)`` before calling ``forward()``. Returns bisecting solutions only when the natural ψ for (h,k,l) matches the stored target. See {doc}`../howto/surface`. +Override the ψ target at run time with `g.modes["fixed_psi"].with_constraint_values(psi=...)` — see {doc}`../howto/constraints`. | | | |---|---| diff --git a/docs/source/geometries/kappa6c.md b/docs/source/geometries/kappa6c.md index 0a7fbb25..305f55c4 100644 --- a/docs/source/geometries/kappa6c.md +++ b/docs/source/geometries/kappa6c.md @@ -140,6 +140,7 @@ Vertical scattering plane (psic-style). {class}`~ad_hoc_diffractometer.mode.SampleConstraint`: `kphi` held at declared value (default 0°), `mu = 0`, `nu = 0`. +Override at run time with `g.modes["fixed_kphi"].with_constraint_values(kphi=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -150,6 +151,7 @@ Vertical scattering plane (psic-style). {class}`~ad_hoc_diffractometer.mode.SampleConstraint` + {class}`~ad_hoc_diffractometer.mode.BisectConstraint` + {class}`~ad_hoc_diffractometer.mode.DetectorConstraint`: `mu` held at declared value (default 0°), `komega = delta/2`, `nu = 0`. +Override at run time with `g.modes["fixed_mu"].with_constraint_values(mu=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -161,6 +163,7 @@ Vertical scattering plane (psic-style). {class}`~ad_hoc_diffractometer.mode.DetectorConstraint` + {class}`~ad_hoc_diffractometer.mode.BisectConstraint` + {class}`~ad_hoc_diffractometer.mode.SampleConstraint`: `nu` held at declared value (default 0°), `komega = delta/2`, `mu = 0`. Analogous to psic `fixed_nu`. +Override at run time with `g.modes["fixed_nu"].with_constraint_values(nu=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -173,6 +176,7 @@ Vertical bisecting with azimuthal angle ψ validation. Set ``g.azimuthal_reference = (h, k, l)`` before calling ``forward()``. The solver returns bisecting solutions only when the natural ψ for the requested (h,k,l) matches the stored target. See {doc}`../howto/surface`. +Override the ψ target at run time with `g.modes["fixed_psi_vertical"].with_constraint_values(psi=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -230,6 +234,7 @@ Horizontal scattering plane. {class}`~ad_hoc_diffractometer.mode.DetectorConstraint` + {class}`~ad_hoc_diffractometer.mode.BisectConstraint` + {class}`~ad_hoc_diffractometer.mode.SampleConstraint`: `delta` held at declared value (default 0°), `mu = nu/2`, `komega = 0`. Horizontal plane with delta frozen. +Override at run time with `g.modes["fixed_delta"].with_constraint_values(delta=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -241,6 +246,7 @@ Horizontal plane with delta frozen. Horizontal bisecting with azimuthal angle ψ validation. Symmetric with `fixed_psi_vertical` in the horizontal plane. Set ``g.azimuthal_reference = (h, k, l)`` before calling ``forward()``. +Override the ψ target at run time with `g.modes["fixed_psi_horizontal"].with_constraint_values(psi=...)` — see {doc}`../howto/constraints`. | | | |---|---| diff --git a/docs/source/geometries/psic.md b/docs/source/geometries/psic.md index 98da1e03..06546ae3 100644 --- a/docs/source/geometries/psic.md +++ b/docs/source/geometries/psic.md @@ -85,6 +85,7 @@ Vertical scattering plane bisecting condition (You 1999, §5.3). ### `fixed_phi_vertical` `phi` held at declared value (default 0°), `mu = 0`, `nu = 0`. +Override at run time with `g.modes["fixed_phi_vertical"].with_constraint_values(phi=...)` — see {doc}`../howto/constraints`. The scattering plane is locked vertical by `mu = 0` and `nu = 0`; `eta`, `chi`, and `delta` are solved from the hkl equations. @@ -96,7 +97,7 @@ The scattering plane is locked vertical by `mu = 0` and `nu = 0`; ### `fixed_chi_vertical` `chi` held at declared value (default 90°), `mu = 0`, `nu = 0`. -The caller chooses the chi value by constructing a {class}`~ad_hoc_diffractometer.mode.ConstraintSet` — see {doc}`../howto/constraints`. +Override at run time with `g.modes["fixed_chi_vertical"].with_constraint_values(chi=...)` — see {doc}`../howto/constraints`. The scattering plane is locked vertical by `mu = 0` and `nu = 0`; `eta`, `phi`, and `delta` are solved from the hkl equations. @@ -109,6 +110,7 @@ The scattering plane is locked vertical by `mu = 0` and `nu = 0`; Incidence angle α_i fixed at declared value (default 0°) in the vertical scattering plane. +Override at run time with `g.modes["fixed_alpha_i_vertical"].with_constraint_values(alpha_i=...)` — see {doc}`../howto/constraints`. Set ``g.surface_normal = (h, k, l)`` before calling ``forward()``. | | | @@ -122,6 +124,7 @@ Set ``g.surface_normal = (h, k, l)`` before calling ``forward()``. Exit angle β_out fixed at declared value (default 0°) in the vertical scattering plane. +Override at run time with `g.modes["fixed_beta_out_vertical"].with_constraint_values(beta_out=...)` — see {doc}`../howto/constraints`. Set ``g.surface_normal = (h, k, l)`` before calling ``forward()``. | | | @@ -148,7 +151,9 @@ Set ``g.surface_normal = (h, k, l)`` before calling ``forward()``. Issue #264 revision. Vertical scattering plane (`nu = 0`) with `mu` fixed at the user-specified value (default 0) and azimuthal angle ψ validation. Set ``g.azimuthal_reference = (h, k, l)`` before calling -``forward()``. The previous bisect(`eta`, `delta`) constraint was +``forward()``. +Override the mu pin or the psi target at run time with `g.modes["fixed_psi_vertical"].with_constraint_values(mu=..., psi=...)` — see {doc}`../howto/constraints`. +The previous bisect(`eta`, `delta`) constraint was dropped per the @jwkim-anl review. The solver returns the fixed-sample solutions only when the natural ψ for the requested (h, k, l) matches the stored target; the free angles ``eta``, ``chi``, @@ -170,6 +175,7 @@ are solved jointly from the Bragg condition plus the α_i target. This is a 4-D Newton solve that routes through the ``_solve_free_detectors`` solver (both detector stages float to lift the detector arm out of plane as needed). +Override any of the three pinned values at run time with `g.modes["fixed_alpha_i_fixed_chi_fixed_phi"].with_constraint_values(chi=..., phi=..., alpha_i=...)` — see {doc}`../howto/constraints`. Set ``g.surface_normal = (h, k, l)`` before calling ``forward()``. @@ -194,6 +200,7 @@ reduces exactly to ``bisecting_vertical`` (above) because OMEGA = 0 ⇔ Q lies in the chi-circle plane ⇔ bisecting condition. Non-zero targets tilt Q out of the chi-circle plane and are solved by a 1-D Newton refinement on the free outer sample stage (`eta`). +Override the OMEGA target at run time with `g.modes["fixed_omega_vertical"].with_constraint_values(omega=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -260,6 +267,7 @@ Horizontal scattering plane bisecting condition (You 1999, §5.1). ### `fixed_phi_horizontal` `phi` held at declared value (default 0°), `eta = 0`, `delta = 0`. +Override at run time with `g.modes["fixed_phi_horizontal"].with_constraint_values(phi=...)` — see {doc}`../howto/constraints`. The scattering plane is locked horizontal by `eta = 0` and `delta = 0`; `mu`, `chi`, and `nu` are solved from the hkl equations. @@ -271,6 +279,7 @@ The scattering plane is locked horizontal by `eta = 0` and `delta = 0`; ### `fixed_chi_horizontal` `chi` held at declared value (default 0°), `eta = 0`, `delta = 0`. +Override at run time with `g.modes["fixed_chi_horizontal"].with_constraint_values(chi=...)` — see {doc}`../howto/constraints`. The scattering plane is locked horizontal by `eta = 0` and `delta = 0`; `mu`, `phi`, and `nu` are solved from the hkl equations. @@ -291,6 +300,7 @@ is kinematically infeasible in this mode. Incidence angle α_i fixed at declared value (default 0°) in the horizontal scattering plane. +Override at run time with `g.modes["fixed_alpha_i_horizontal"].with_constraint_values(alpha_i=...)` — see {doc}`../howto/constraints`. Set ``g.surface_normal = (h, k, l)`` before calling ``forward()``. | | | @@ -304,6 +314,7 @@ Set ``g.surface_normal = (h, k, l)`` before calling ``forward()``. Exit angle β_out fixed at declared value (default 0°) in the horizontal scattering plane. +Override at run time with `g.modes["fixed_beta_out_horizontal"].with_constraint_values(beta_out=...)` — see {doc}`../howto/constraints`. Set ``g.surface_normal = (h, k, l)`` before calling ``forward()``. | | | @@ -332,6 +343,7 @@ Issue #264 revision. Horizontal scattering plane (`delta = 0`) with angle ψ validation. The previous bisect(`mu`, `nu`) constraint was dropped per the @jwkim-anl review. Free angles `mu`, `chi`, `phi`, `nu` jointly satisfy the Bragg condition under the validated ψ. +Override the eta pin or the psi target at run time with `g.modes["fixed_psi_horizontal"].with_constraint_values(eta=..., psi=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -347,6 +359,7 @@ plane (`eta = 0`, `delta = 0`). Same OMEGA pseudo-angle definition as ``fixed_omega_vertical`` above; at ``omega = 0`` the mode reduces exactly to ``bisecting_horizontal``. The free outer sample stage rocked by the 1-D Newton is `mu`. +Override the OMEGA target at run time with `g.modes["fixed_omega_horizontal"].with_constraint_values(omega=...)` — see {doc}`../howto/constraints`. | | | |---|---| diff --git a/docs/source/geometries/s2d2.md b/docs/source/geometries/s2d2.md index 88739d9e..44489fcf 100644 --- a/docs/source/geometries/s2d2.md +++ b/docs/source/geometries/s2d2.md @@ -69,9 +69,7 @@ See {doc}`../howto/constraints` for the constraint framework. {class}`~ad_hoc_diffractometer.mode.SampleConstraint`: `mu` held at declared value (default 0°) — the incidence angle when the surface normal is aligned. -The caller chooses the value by constructing a -{class}`~ad_hoc_diffractometer.mode.ConstraintSet` — see -{doc}`../howto/constraints`. +Override at run time with `g.modes["fixed_mu"].with_constraint_values(mu=...)` — see {doc}`../howto/constraints`. | | | |---|---| diff --git a/docs/source/geometries/sixc.md b/docs/source/geometries/sixc.md index 0203f08f..4bd14e33 100644 --- a/docs/source/geometries/sixc.md +++ b/docs/source/geometries/sixc.md @@ -86,8 +86,7 @@ Reduces to standard four-circle bisecting geometry. {class}`~ad_hoc_diffractometer.mode.DetectorConstraint` + {class}`~ad_hoc_diffractometer.mode.SampleConstraint` + {class}`~ad_hoc_diffractometer.mode.BisectConstraint`: `alpha = 0`, `omega = delta / 2`. `gamma` is held at the value declared in the constraint (default in the demo geometry: 0°). -The caller chooses the value by constructing a {class}`~ad_hoc_diffractometer.mode.ConstraintSet`; the constraint -persists until replaced — see {doc}`../howto/constraints`. +Override at run time with `g.modes["fixed_gamma_5c"].with_constraint_values(gamma=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -99,7 +98,7 @@ persists until replaced — see {doc}`../howto/constraints`. {class}`~ad_hoc_diffractometer.mode.SampleConstraint` + {class}`~ad_hoc_diffractometer.mode.BisectConstraint` + {class}`~ad_hoc_diffractometer.mode.DetectorConstraint`: `omega = delta / 2`, `gamma = 0`. `alpha` is held at the value declared in the constraint (default in the demo geometry: 0°). -The caller chooses the value by constructing a {class}`~ad_hoc_diffractometer.mode.ConstraintSet`. +Override at run time with `g.modes["fixed_alpha_5c"].with_constraint_values(alpha=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -110,6 +109,7 @@ The caller chooses the value by constructing a {class}`~ad_hoc_diffractometer.mo {class}`~ad_hoc_diffractometer.mode.SampleConstraint` × 2 + {class}`~ad_hoc_diffractometer.mode.ReferenceConstraint`: Z-axis mode with fixed incidence angle. Requires ``g.surface_normal = (h, k, l)`` — see {doc}`../howto/surface`. +Override any of the three pinned values at run time with `g.modes["fixed_alpha_zaxis"].with_constraint_values(alpha=..., chi=..., phi=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -122,6 +122,7 @@ Z-axis mode with fixed incidence angle. Requires ``g.surface_normal = (h, k, l)` {class}`~ad_hoc_diffractometer.mode.DetectorConstraint` + {class}`~ad_hoc_diffractometer.mode.SampleConstraint` + {class}`~ad_hoc_diffractometer.mode.ReferenceConstraint`: Z-axis mode with fixed exit angle. Requires ``g.surface_normal = (h, k, l)`` — see {doc}`../howto/surface`. +Override at run time with `g.modes["fixed_beta_zaxis"].with_constraint_values(gamma=..., chi=...)` — see {doc}`../howto/constraints`. | | | |---|---| @@ -134,6 +135,7 @@ Z-axis mode with fixed exit angle. Requires ``g.surface_normal = (h, k, l)`` — {class}`~ad_hoc_diffractometer.mode.SampleConstraint` × 2 + {class}`~ad_hoc_diffractometer.mode.ReferenceConstraint`: Z-axis mode, symmetric reflection (α = γ, β_in = β_out). Requires ``g.surface_normal = (h, k, l)`` — see {doc}`../howto/surface`. +Override the chi or phi pin at run time with `g.modes["alpha_eq_beta_zaxis"].with_constraint_values(chi=..., phi=...)` — see {doc}`../howto/constraints`. | | | |---|---| diff --git a/docs/source/geometries/zaxis.md b/docs/source/geometries/zaxis.md index 37faa77b..5b8345cc 100644 --- a/docs/source/geometries/zaxis.md +++ b/docs/source/geometries/zaxis.md @@ -72,6 +72,7 @@ See {doc}`../howto/constraints` for the extras dict pattern. {class}`~ad_hoc_diffractometer.mode.ReferenceConstraint`: surface normal aligned with the Z-axis; alpha directly equals the incidence angle β_in, gamma directly equals the exit angle β_out. +Override the α_i target at run time with `g.modes["zaxis"].with_constraint_values(alpha_i=...)` — see {doc}`../howto/constraints`. | | | |---|---| diff --git a/docs/source/howto/constraints.md b/docs/source/howto/constraints.md index 86a5c401..907b3a5a 100644 --- a/docs/source/howto/constraints.md +++ b/docs/source/howto/constraints.md @@ -120,6 +120,64 @@ The {class}`~ad_hoc_diffractometer.mode.ConstraintSet` object persists on the geometry until explicitly replaced — there is no need to reassign it if the value does not change between calls. +### Why constraint values are immutable + +Every constraint (`SampleConstraint`, `DetectorConstraint`, +`ReferenceConstraint`) exposes `.value` as a **read-only property** — there +is no setter and no `set_value()` method. This is deliberate: + +- Constraints are *values*, not mutable state, so `ConstraintSet.to_dict()` + / `from_dict()` round-trip cleanly and serialized modes always describe + the run-time state. +- The YAML file under + `src/ad_hoc_diffractometer/geometries/` is the **single source of + truth** for default values; run-time overrides happen by replacing the + containing `ConstraintSet`, never by mutating the constraint object. +- Two callers holding references to the same `ConstraintSet` (e.g. the + active mode and a snapshot taken before a scan) cannot surprise each + other with hidden value changes. + +To override a default value you therefore produce a **new** +`ConstraintSet`. There are two ways to do that — pick whichever is more +readable for your case. + +### Use `with_constraint_values` for a one-call override + +`ConstraintSet.with_constraint_values(**updates)` returns a fresh +`ConstraintSet` with the named constraint values replaced. Each keyword +argument names a constraint by its `.name` attribute (a stage name for +sample / detector constraints, a reference name like `alpha_i` / +`beta_out` / `psi` / `a_eq_b` for reference constraints). Constraint +order, `computed`, `extras`, and `cut_points` are preserved. + +```python +import ad_hoc_diffractometer as ahd + +g = ahd.make_geometry("psic") + +# Multiple values at once (psic B3 mode: chi, phi, and the alpha_i target): +g.modes["fixed_alpha_i_fixed_chi_fixed_phi"] = ( + g.modes["fixed_alpha_i_fixed_chi_fixed_phi"] + .with_constraint_values(chi=15.0, phi=30.0, alpha_i=5.0) +) + +# Single value (psic fixed_chi_vertical: default chi=90° → 45°): +g.modes["fixed_chi_vertical"] = ( + g.modes["fixed_chi_vertical"].with_constraint_values(chi=45.0) +) +``` + +Unknown keys (typos, names that do not appear in the set) raise +`KeyError` listing every unrecognised key at once so a multi-typo edit +can be fixed in one pass. `BisectConstraint` is relational (it has no +scalar value) and is invisible to this method — any kwarg targeting a +bisect raises `KeyError`. + +### Rebuild the whole `ConstraintSet` + +When you want to change which constraints appear (not just their +values), construct a new `ConstraintSet` directly: + ```python from ad_hoc_diffractometer import ConstraintSet, SampleConstraint @@ -138,10 +196,41 @@ g.modes["my_chi"] = ConstraintSet([SampleConstraint("chi", 60.0)]) sols_new = g.forward(1, 0, 0) # chi = 60° from here on ``` -The `"fixed_chi"` mode in the demo geometry has a default chi = 90°. -Replacing the {class}`~ad_hoc_diffractometer.mode.ConstraintSet` -in `g.modes["fixed_chi"]` changes the value for all subsequent calls -until it is replaced again. +A detector-stage value works identically — pass the new +`DetectorConstraint` in the list: + +```python +from ad_hoc_diffractometer import DetectorConstraint + +# psic fixed_delta-style mode: change the detector pin to a non-zero value +g.modes["my_fixed_delta"] = ConstraintSet( + [ + BisectConstraint("eta", "delta"), + SampleConstraint("mu", 0.0), + DetectorConstraint("nu", 5.0), # was 0.0; now 5.0 + ], + computed=["eta", "chi", "phi", "delta"], +) +``` + +For a reference-target value (surface modes), pass a new +`ReferenceConstraint`: + +```python +from ad_hoc_diffractometer import ReferenceConstraint + +# psic B3 mode: pin the incidence angle alpha_i at 5° instead of the +# YAML default 0°. +g.modes["fixed_alpha_i_fixed_chi_fixed_phi"] = ConstraintSet( + [ + SampleConstraint("chi", 0.0), + SampleConstraint("phi", 0.0), + ReferenceConstraint("alpha_i", 5.0), # was 0.0; now 5.0 + ], + computed=g.modes["fixed_alpha_i_fixed_chi_fixed_phi"].computed, + extras=dict(g.modes["fixed_alpha_i_fixed_chi_fixed_phi"].extras), +) +``` The `computed` field on {class}`~ad_hoc_diffractometer.mode.ConstraintSet` is informational (documents which stages the solver computes) and does not diff --git a/docs/source/howto/modes.md b/docs/source/howto/modes.md index af76dd0a..3008b6c3 100644 --- a/docs/source/howto/modes.md +++ b/docs/source/howto/modes.md @@ -59,24 +59,50 @@ solutions = g.forward(1, 0, 0) # sol["chi"] == 90.0 for every solution ``` -To use a **different value**, construct a new {class}`~ad_hoc_diffractometer.mode.ConstraintSet` -and assign it. The constraint persists until replaced — only reassign when -the value changes: +To use a **different value**, the simplest call is +{meth}`~ad_hoc_diffractometer.mode.ConstraintSet.with_constraint_values`, +which returns a fresh `ConstraintSet` with the named constraint values +replaced: ```python -from ad_hoc_diffractometer import ConstraintSet, SampleConstraint +# Single value: chi = 45° on the demo fixed_chi mode +g.modes["fixed_chi"] = g.modes["fixed_chi"].with_constraint_values(chi=45.0) +g.mode_name = "fixed_chi" +sols_45 = g.forward(1, 0, 0) # chi = 45° + +# Several values at once (e.g. psic B3 mode: two sample stages plus +# the alpha_i target — three pinned values in one call): +g.modes["fixed_alpha_i_fixed_chi_fixed_phi"] = ( + g.modes["fixed_alpha_i_fixed_chi_fixed_phi"] + .with_constraint_values(chi=15.0, phi=30.0, alpha_i=5.0) +) +``` -# Call 1: chi = 45° -g.modes["my_chi"] = ConstraintSet([SampleConstraint("chi", 45.0)]) -g.mode_name = "my_chi" -sols_45 = g.forward(1, 0, 0) # chi = 45° this call +For modes where you want to change *which* constraints appear (not just +their values), build a new {class}`~ad_hoc_diffractometer.mode.ConstraintSet` +directly: + +```python +from ad_hoc_diffractometer import ConstraintSet, SampleConstraint -# Call 2: chi = 60° g.modes["my_chi"] = ConstraintSet([SampleConstraint("chi", 60.0)]) -sols_60 = g.forward(1, 0, 0) # chi = 60° this call +g.mode_name = "my_chi" ``` -See {doc}`constraints` for the full run-time pattern. +See {doc}`constraints` for the full run-time pattern, including how to +override detector and reference-target values and the rationale for the +immutable-constraint design. + +## Change a fixed-axis value + +Yes, you can override the YAML default for any `fixed_*` mode at run +time. Use +{meth}`~ad_hoc_diffractometer.mode.ConstraintSet.with_constraint_values` +for a one-call value swap (single or multiple values), or rebuild the +{class}`~ad_hoc_diffractometer.mode.ConstraintSet` from scratch when +you also need to change which constraints appear. Worked examples for +sample, detector, and reference-target overrides are in +{ref}`howto-constraints`. ### fixed_omega diff --git a/src/ad_hoc_diffractometer/mode.py b/src/ad_hoc_diffractometer/mode.py index 728e7a51..d06bc939 100644 --- a/src/ad_hoc_diffractometer/mode.py +++ b/src/ad_hoc_diffractometer/mode.py @@ -1260,6 +1260,121 @@ def bisect_stages(self) -> tuple[str, str] | None: return None return (bc.sample_stage, bc.detector_stage) + # ------------------------------------------------------------------ + # Functional update (issue #293) + # ------------------------------------------------------------------ + + def with_constraint_values(self, **updates: float | bool) -> ConstraintSet: + """Return a new :class:`ConstraintSet` with named constraint values replaced. + + The receiver is left unchanged. Each keyword argument names a + constraint by its ``.name`` attribute and supplies the new value. + Constraint order, :attr:`computed`, :attr:`extras`, and + :attr:`cut_points` are preserved. The method always returns a + fresh instance, even when ``updates`` is empty. + + Parameters + ---------- + **updates : float or bool + Mapping of constraint name → new value. Each key must + exactly match the ``.name`` attribute of an existing + :class:`SampleConstraint`, :class:`DetectorConstraint`, or + :class:`ReferenceConstraint` in the set. Values are floats + (or ``bool`` for the ``"a_eq_b"`` :class:`ReferenceConstraint`). + + Returns + ------- + ConstraintSet + A new instance with the named constraints replaced. + + Raises + ------ + KeyError + If any key in ``updates`` does not match a constraint name in + the set. The message lists every unknown key at once so a + user can fix them all in a single edit. + ValueError + If two constraints in the receiver share the same + ``.name``. This indicates a malformed + :class:`ConstraintSet` (the declarative YAML loader does not + produce these; a user who hand-builds one with duplicate + sample-stage names will get this error). + + Examples + -------- + Single sample-stage value: + + >>> import ad_hoc_diffractometer as ahd + >>> g = ahd.make_geometry("psic") + >>> g.modes["fixed_chi_vertical"] = ( + ... g.modes["fixed_chi_vertical"].with_constraint_values(chi=45.0) + ... ) + + Multiple values at once (psic ``fixed_alpha_i_fixed_chi_fixed_phi``): + + >>> g.modes["fixed_alpha_i_fixed_chi_fixed_phi"] = ( + ... g.modes["fixed_alpha_i_fixed_chi_fixed_phi"] + ... .with_constraint_values(chi=15.0, phi=30.0, alpha_i=5.0) + ... ) + + Notes + ----- + :class:`BisectConstraint` (and :class:`VirtualBisectConstraint`) + carry no scalar value — they are *relational* constraints + between two stages. This method intentionally ignores them: a + kwarg whose key happens to equal a bisect's class-level + ``name`` identifier (``"bisect"`` / ``"virtual_bisect"``) will + not match and raises :class:`KeyError` like any other unknown + key. A bisect cannot be overridden by changing a value + because it has none. + """ + # Build a {name: index} map of constraints that have a settable + # scalar value (SampleConstraint, DetectorConstraint, + # ReferenceConstraint). Bisect constraints are deliberately + # excluded — they are relational and have no `.value` to + # override (see Notes). + settable = (SampleConstraint, DetectorConstraint, ReferenceConstraint) + name_to_index: dict[str, int] = {} + for idx, c in enumerate(self._constraints): + if not isinstance(c, settable): + continue + cname = c.name + if cname in name_to_index: + raise ValueError( + f"with_constraint_values: this ConstraintSet contains " + f"two constraints both named {cname!r}; cannot resolve " + f"an override unambiguously." + ) + name_to_index[cname] = idx + + unknown = sorted(k for k in updates if k not in name_to_index) + if unknown: + available = sorted(name_to_index) + raise KeyError( + f"with_constraint_values: no constraint(s) named " + f"{unknown!r} in this ConstraintSet; available names " + f"are {available!r}." + ) + + new_constraints: list[AnyConstraint] = list(self._constraints) + for name, new_value in updates.items(): + idx = name_to_index[name] + original = new_constraints[idx] + # Each settable-value constraint is constructed with the same + # (name, value) signature, so a single replacement pattern + # suffices across SampleConstraint, DetectorConstraint, and + # ReferenceConstraint. + new_constraints[idx] = type(original)(name, new_value) + + # extras and cut_points are shallow-copied; sentinel identity + # (REQUIRED / OPTIONAL) is preserved by the copy. + return ConstraintSet( + constraints=new_constraints, + computed=list(self.computed) if self.computed is not None else None, + extras=dict(self.extras), + cut_points=dict(self.cut_points), + ) + # ------------------------------------------------------------------ # Serialisation # ------------------------------------------------------------------ diff --git a/tests/test_mode.py b/tests/test_mode.py index 8d58eecb..939c5c4f 100644 --- a/tests/test_mode.py +++ b/tests/test_mode.py @@ -2435,3 +2435,259 @@ def test_qaz_residual_nonzero(): residual = _qaz_residual(angles, g, 90.0) assert residual == pytest.approx(qaz_actual - 90.0, abs=1e-6) assert abs(residual) > 1e-3 # not satisfied + + +# --------------------------------------------------------------------------- +# ConstraintSet.with_constraint_values (issue #293) +# --------------------------------------------------------------------------- + + +def _b3_constraint_set(): + """Build a ConstraintSet matching psic 'fixed_alpha_i_fixed_chi_fixed_phi'. + + Three settable-value constraints: two SampleConstraint plus one + ReferenceConstraint. Used by several with_constraint_values tests + that exercise the multi-value path. + """ + return ConstraintSet( + [ + SampleConstraint("chi", 0.0), + SampleConstraint("phi", 0.0), + ReferenceConstraint("alpha_i", 0.0), + ], + computed=["mu", "eta", "nu", "delta"], + extras={"n_hat": REQUIRED, "alpha_i": None, "beta_out": None}, + cut_points={"eta": -180.0}, + ) + + +@pytest.mark.parametrize( + "build_cs, updates, expected_values, context", + [ + pytest.param( + lambda: ConstraintSet( + [ + SampleConstraint("chi", 90.0), + SampleConstraint("mu", 0.0), + DetectorConstraint("nu", 0.0), + ], + computed=["eta", "phi", "delta"], + ), + {"chi": 45.0}, + {"chi": 45.0, "mu": 0.0, "nu": 0.0}, + does_not_raise(), + id="sample-chi-45", + ), + pytest.param( + lambda: ConstraintSet( + [ + SampleConstraint("eta", 0.0), + DetectorConstraint("delta", 0.0), + SampleConstraint("phi", 0.0), + ], + computed=["mu", "chi", "nu"], + ), + {"delta": 30.0}, + {"eta": 0.0, "delta": 30.0, "phi": 0.0}, + does_not_raise(), + id="detector-delta-30", + ), + pytest.param( + _b3_constraint_set, + {"alpha_i": 5.0}, + {"chi": 0.0, "phi": 0.0, "alpha_i": 5.0}, + does_not_raise(), + id="reference-alpha_i-5", + ), + pytest.param( + # ReferenceConstraint('a_eq_b', ...) only accepts True + # (the constraint expresses the *boolean* condition + # alpha_i = beta_out; there is no False variant). This + # case verifies the bool branch of with_constraint_values + # by re-applying the only legal value. + lambda: ConstraintSet( + [ReferenceConstraint("a_eq_b", True)], + extras={"n_hat": REQUIRED}, + ), + {"a_eq_b": True}, + {"a_eq_b": True}, + does_not_raise(), + id="reference-a_eq_b-True", + ), + pytest.param( + _b3_constraint_set, + {"chi": 15.0, "phi": 30.0, "alpha_i": 5.0}, + {"chi": 15.0, "phi": 30.0, "alpha_i": 5.0}, + does_not_raise(), + id="b3-three-values", + ), + pytest.param( + _b3_constraint_set, + {}, + {"chi": 0.0, "phi": 0.0, "alpha_i": 0.0}, + does_not_raise(), + id="empty-updates-no-change", + ), + ], +) +def test_with_constraint_values_replaces_values( + build_cs, updates, expected_values, context +): + """Each named constraint's value is replaced; others untouched.""" + cs = build_cs() + with context: + new = cs.with_constraint_values(**updates) + # Always a fresh instance, even when updates is empty. + assert new is not cs + # Names mapped to actual values in the new set. + new_values = {c.name: c.value for c in new.constraints if hasattr(c, "name")} + for name, expected in expected_values.items(): + assert new_values[name] == expected + # Constraint order preserved. + original_names = [getattr(c, "name", None) for c in cs.constraints] + new_names = [getattr(c, "name", None) for c in new.constraints] + assert new_names == original_names + # Receiver is unmodified. + original_values = { + c.name: c.value for c in cs.constraints if hasattr(c, "name") + } + for c in cs.constraints: + if hasattr(c, "name"): + assert c.value == original_values[c.name] + + +def test_with_constraint_values_preserves_computed_extras_cut_points(): + """computed, extras (with sentinel identity), and cut_points survive.""" + cs = _b3_constraint_set() + new = cs.with_constraint_values(chi=15.0) + # computed is a fresh list with the same contents. + assert new.computed == cs.computed + assert new.computed is not cs.computed + # extras is a fresh dict; REQUIRED sentinel identity preserved; + # None placeholders preserved. + assert new.extras is not cs.extras + assert new.extras == cs.extras + assert new.extras["n_hat"] is REQUIRED + assert new.extras["alpha_i"] is None + assert new.extras["beta_out"] is None + # OPTIONAL sentinel identity survives too. + cs2 = ConstraintSet( + [SampleConstraint("chi", 0.0)], + extras={"opt": OPTIONAL}, + ) + new2 = cs2.with_constraint_values(chi=10.0) + assert new2.extras["opt"] is OPTIONAL + # cut_points is a fresh dict with the same contents. + assert new.cut_points is not cs.cut_points + assert new.cut_points == cs.cut_points + + +def test_with_constraint_values_empty_updates_returns_equal_fresh_instance(): + """Empty kwargs return a fresh instance that compares equal to self.""" + cs = _b3_constraint_set() + new = cs.with_constraint_values() + assert new is not cs + assert new == cs + + +def test_with_constraint_values_unknown_key_raises_KeyError(): + """A single typo lists the typo and the available names.""" + cs = _b3_constraint_set() + with pytest.raises( + KeyError, + match=re.escape( + "with_constraint_values: no constraint(s) named " + "['cho'] in this ConstraintSet; available names " + "are ['alpha_i', 'chi', 'phi']." + ), + ): + cs.with_constraint_values(cho=45.0) + + +def test_with_constraint_values_multiple_unknown_keys_batched_error(): + """Every unknown key appears in a single error message.""" + cs = _b3_constraint_set() + with pytest.raises( + KeyError, + match=re.escape( + "with_constraint_values: no constraint(s) named " + "['bad_one', 'bad_two'] in this ConstraintSet" + ), + ): + cs.with_constraint_values(bad_two=1.0, bad_one=2.0, chi=15.0) + + +def test_with_constraint_values_bisect_constraint_ignored(): + """BisectConstraint is relational (no value); the helper skips it. + + A ConstraintSet containing only a BisectConstraint exposes zero + settable-value names, so any kwarg falls through to the unknown-key + path with an empty ``available names`` list. This pins the design + decision that bisects cannot be overridden via with_constraint_values + (they carry sample/detector *stages*, not a value). + """ + cs = ConstraintSet([BisectConstraint("eta", "delta")]) + with pytest.raises( + KeyError, + match=re.escape( + "with_constraint_values: no constraint(s) named " + "['eta'] in this ConstraintSet; available names are []." + ), + ): + cs.with_constraint_values(eta=10.0) + + +def test_with_constraint_values_bisect_alongside_settable_ignored(): + """A bisect mixed with settable constraints does not appear in + available names, and a kwarg matching the bisect class identifier + ``'bisect'`` is rejected as unknown.""" + cs = ConstraintSet( + [ + BisectConstraint("eta", "delta"), + SampleConstraint("mu", 0.0), + DetectorConstraint("nu", 0.0), + ], + ) + # Settable names are mu and nu only — not the bisect's class-level + # 'bisect' identifier. + with pytest.raises( + KeyError, + match=re.escape( + "with_constraint_values: no constraint(s) named " + "['bisect'] in this ConstraintSet; available names " + "are ['mu', 'nu']." + ), + ): + cs.with_constraint_values(bisect=10.0) + + +def test_with_constraint_values_duplicate_name_raises_ValueError(): + """A ConstraintSet hand-built with two same-named constraints rejects override.""" + # Two SampleConstraint entries sharing the stage name "chi". This is + # a malformed mode (the YAML loader does not produce these) but the + # ConstraintSet constructor does not detect it, so with_constraint_values + # is the guard. + cs = ConstraintSet( + [ + SampleConstraint("chi", 0.0), + SampleConstraint("chi", 90.0), + ], + ) + with pytest.raises( + ValueError, + match=re.escape( + "with_constraint_values: this ConstraintSet contains " + "two constraints both named 'chi'; cannot resolve " + "an override unambiguously." + ), + ): + cs.with_constraint_values(chi=45.0) + + +def test_with_constraint_values_round_trips_via_to_dict(): + """Replace then restore via to_dict comparison: identical dicts.""" + cs = _b3_constraint_set() + original_dict = cs.to_dict() + intermediate = cs.with_constraint_values(chi=15.0, phi=30.0, alpha_i=5.0) + restored = intermediate.with_constraint_values(chi=0.0, phi=0.0, alpha_i=0.0) + assert restored.to_dict() == original_dict diff --git a/tests/test_regression_issue_292.py b/tests/test_regression_issue_292.py index bdaf96c0..4abcde9a 100644 --- a/tests/test_regression_issue_292.py +++ b/tests/test_regression_issue_292.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026-2026 UChicago Argonne, LLC +# SPDX-License-Identifier: LicenseRef-UChicago-Argonne-LLC-License """ Regression tests for issue #292. @@ -111,9 +113,7 @@ def test_psic_b3_populates_alpha_i_and_beta_out_extras( for ai_stored, bo_stored, sol in zip( mode.extras["alpha_i"], mode.extras["beta_out"], sols, strict=True ): - assert ai_stored == pytest.approx( - incidence_angle(g, angles=sol), abs=1e-8 - ) + assert ai_stored == pytest.approx(incidence_angle(g, angles=sol), abs=1e-8) assert bo_stored == pytest.approx(exit_angle(g, angles=sol), abs=1e-8) assert ai_stored == pytest.approx(alpha_target, abs=1e-3) @@ -211,9 +211,11 @@ def test_fourcv_fixed_psi_populates_psi_extra( assert stored == pytest.approx(psi_angle(g, angles=sol), abs=1e-6) # And by the validation-filter property, every solution must # have the natural psi (modulo 360). - assert stored == pytest.approx(natural, abs=1e-3) or stored == pytest.approx( - natural - 360.0, abs=1e-3 - ) or stored == pytest.approx(natural + 360.0, abs=1e-3) + assert ( + stored == pytest.approx(natural, abs=1e-3) + or stored == pytest.approx(natural - 360.0, abs=1e-3) + or stored == pytest.approx(natural + 360.0, abs=1e-3) + ) # --------------------------------------------------------------------------- @@ -312,7 +314,9 @@ def test_populate_extras_soft_fails_when_reference_helper_raises(monkeypatch, ca def _explode(*_args, **_kwargs): raise ValueError("synthetic failure for issue #292 coverage") - monkeypatch.setattr(forward_mod, "_populate_output_extras", forward_mod._populate_output_extras) + monkeypatch.setattr( + forward_mod, "_populate_output_extras", forward_mod._populate_output_extras + ) monkeypatch.setattr( "ad_hoc_diffractometer.reference.omega_pseudo", _explode,