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,