Declarative Geometry Schema#
Authoritative reference for the YAML schema used by
ad_hoc_diffractometer.geometry_loader.load_geometry_file() and
ad_hoc_diffractometer.geometry_loader.register_geometry_file().
See the how-to for a task-oriented
walkthrough.
The 10 YAML files shipped under
src/ad_hoc_diffractometer/geometries/ are demonstrations of this
schema, not authoritative descriptions of any production
diffractometer. Real instruments at real beamlines should ship their
own YAML — copy the closest demo file and adapt it.
Top-level structure#
Every declarative geometry file is a single YAML document (one mapping at the top level) with the following keys. The strict unknown-key policy rejects any key not listed here.
Key |
Required |
Type |
Purpose |
|---|---|---|---|
|
yes |
mapping |
Document marker (see below) |
|
yes |
non-empty string |
Registry key (must equal filename stem for files in the package directory) |
|
recommended |
string |
Free-form prose; the first line is the one-line summary (Python-docstring convention) |
|
optional |
string or mapping |
Coordinate basis (see basis) |
|
optional |
mapping |
Caller-tunable defaults; currently only |
|
conditional |
string or 3-vector |
Equivalent-Eulerian chi axis; required for kappa geometries |
|
yes |
non-empty list |
Rotary stages (see stages) |
|
yes |
non-empty mapping |
Diffraction modes (see modes) |
Document marker#
Every file must begin with the marker that identifies it as an
ad_hoc_diffractometer geometry declaration:
ad_hoc_diffractometer_geometry:
schema_revision: 1
The presence of the ad_hoc_diffractometer_geometry top-level key is
the unambiguous signal that the document is a geometry declaration.
The schema_revision integer selects which version of this schema
the file conforms to. It is a fixed property of the schema, not a
per-file edit counter — users should treat the value verbatim and
only change it when migrating the file to a different declarative-
geometry schema revision.
Loader constant |
Value |
Meaning |
|---|---|---|
|
The marker key name |
|
|
Schema revisions this build can parse |
|
|
Revision newly-authored YAML files SHOULD declare |
The schema revision is independent of the package version; schema revisions are deliberate editorial events that change far less often than package releases.
Important
When a string passed to
load_geometry_file()
lacks the marker, the loader treats the string as a filesystem
path rather than as YAML text. When the marker is present but
malformed (wrong type for the value, unsupported schema_revision,
unknown sub-key, …) the loader raises ValueError immediately and
does not retry as a path — once the document declares itself a
geometry, the loader honors that declaration by reporting what is
wrong with it.
basis#
Optional. Specifies the coordinate basis used to resolve physical
direction names (vertical, longitudinal, transverse) in stage
axis fields and elsewhere.
Three accepted forms:
Shorthand string — one of:
"BL"→BASIS_BL(Busing & Levy 1967: vertical=Z, longitudinal=Y, transverse=X)"YOU"→BASIS_YOU(You 1999: vertical=X, longitudinal=Y, transverse=Z)"DEFAULT"→BASIS_DEFAULT(vertical=Y, longitudinal=Z, transverse=X)
Mapping of signed-axis strings
basis: vertical: '+z' longitudinal: '+y' transverse: '+x'
Mapping of numeric unit vectors — axis-aligned:
basis: vertical: [0.0, 0.0, 1.0] longitudinal: [0.0, 1.0, 0.0] transverse: [1.0, 0.0, 0.0]
…or any orthonormal triple, including non-axis-aligned bases. For example, a 45° rotation of the (vertical, longitudinal) pair within the X-Y plane:
basis: vertical: [0.7071, 0.7071, 0.0] longitudinal: [-0.7071, 0.7071, 0.0] transverse: [0.0, 0.0, 1.0]
The loader validates orthonormality (each vector has unit length and
the three are mutually orthogonal, both within tolerance 1e-9). A
defensive |det([v, l, t])| ≈ 1 cross-check rejects degenerate or
co-planar inputs. Either chirality of the
(vertical, longitudinal, transverse) ordering is accepted because
both BL and YOU conventions are physically meaningful right-handed
frames in their own canonical orderings.
Precedence#
A geometry’s basis is resolved in this order (first match wins):
Caller-supplied
basis=keyword tomake_geometry(),load_geometry_file(), orregister_geometry_file().YAML file’s
basis:key (any of the three forms above).BASIS_DEFAULT— the neutral fallback.
Omitting the YAML key emits a UserWarning (“does not declare a
basis; using BASIS_DEFAULT …”) so accidental omission is visible. Real
instruments should declare basis: explicitly.
Worked example of the precedence rule#
import ad_hoc_diffractometer as ahd
from ad_hoc_diffractometer.factories import BASIS_BL, BASIS_YOU
# (1) Caller wins over YAML default.
g = ahd.make_geometry("fourcv", basis=BASIS_YOU)
assert (g.basis["vertical"] == BASIS_YOU["vertical"]).all()
# (2) No kwarg → YAML's `basis: BL` wins.
g = ahd.make_geometry("fourcv")
assert (g.basis["vertical"] == BASIS_BL["vertical"]).all()
parameters#
Optional mapping of caller-tunable parameters with their defaults. Currently the only recognized key is:
Key |
Type |
Purpose |
|---|---|---|
|
float |
Kappa tilt angle (degrees); used by |
When parameters.alpha_deg is declared, the loader also requires
kappa_chi_eq and synthesizes a
KappaPseudoAngleConvention from
the stages named komega, kappa, kphi.
Note
A caller can override alpha_deg at call time:
ahd.make_geometry("kappa4cv", alpha_deg=55).
kappa_chi_eq#
Required when the file declares parameters.alpha_deg. Specifies the
equivalent-Eulerian chi axis used by kappa_axis_from_eulerian.
Accepts the same forms as a stage axis: a signed-axis string
('+vertical', '-x', …) or a length-3 numeric vector.
stages#
A non-empty list. Each entry is a mapping with all four keys:
Key |
Type |
Purpose |
|---|---|---|
|
string |
Stage name (e.g. |
|
see below |
Rotation axis (signed) |
|
string or null |
Name of the stage this one sits on, or |
|
string |
|
Axis grammar#
Three accepted forms:
Signed physical-direction string — resolved against the active basis:
axis: -transverse axis: +longitudinal
Signed Cartesian string — resolved directly against
XHAT,YHAT,ZHAT:axis: '+x' axis: '-z'
Length-3 numeric vector (used as-is, sign included; no automatic normalisation other than what the
Stageconstructor performs):axis: [0.0, 0.5, 0.866]
Kappa-tilt mapping — only meaningful when
parameters.alpha_degandkappa_chi_eqare declared. The single argument is the outer (komega) axis:axis: {kappa_eulerian: '+transverse'}
The loader resolves this via
kappa_axis_from_eulerian().
Important
The sign of the axis vector encodes handedness: +nHat is
right-handed rotation about nHat; -nHat is left-handed. The
rotation: left/right shorthand discussed in early issue drafts is
not part of the schema — use signed-axis strings instead.
modes#
A non-empty mapping of mode-name → mode-spec. Each mode-spec is a mapping with these keys:
Key |
Required |
Type |
Purpose |
|---|---|---|---|
|
optional |
bool |
When |
|
yes |
list |
Constraint specs (may be empty) |
|
optional |
list of strings |
Stage names the solver writes |
|
optional |
mapping |
Additional inputs/outputs (see below) |
|
optional |
mapping |
Per-stage SPEC |
Constraint specs#
Each entry of constraints: is a mapping with a type: key selecting
one of five constraint classes. Each type accepts a fixed set of
sibling keys; the unknown-key policy rejects
any others.
|
Required keys |
Constraint class |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
For reference, name is one of "psi", "alpha_i", "beta_out",
"a_eq_b", "naz", "omega"; value is a float for the angular
constraints and the literal true for a_eq_b. The "omega" name
selects the SPEC OMEGA pseudo-angle (the angle between Q and the
plane of the chi circle, SPEC psic def OMEGA 'Q[6]'); it applies
to psic-family geometries (those with a sample stage named chi) and
does not require any reference vector.
extras and the REQUIRED sentinel#
extras accepts arbitrary key→value pairs. Two special values:
The literal string
REQUIREDis mapped to theREQUIREDsentinel; the solver checks for the sentinel before running and raises if the caller didn’t supply a real value.YAML
null→ PythonNone. Used for output slots that the solver populates afterforward()runs.
extras:
n_hat: REQUIRED # caller must supply; solver refuses if absent
psi: null # output slot; solver writes to it
Strict unknown-key policy#
The loader rejects any key it does not recognize at every nesting
level: top level, marker mapping, parameters mapping, each stage
entry, each mode entry, and each constraint spec. The error names
the offending key, the containing context, and the accepted-key set,
so misspellings (documantation: for documentation:) surface
immediately.
Future schema revisions may relax this policy if a use case justifies extension keys; revision 1 is strict.
What load_geometry_file() accepts#
load_geometry_file() accepts
either a filesystem path or YAML text held in a Python string. The
two cases work as follows:
A filesystem path — passed as a
pathlib.Path, anyos.PathLike, or a path string. The loader reads the file and parses its contents.FileNotFoundErrorif the file does not exist.YAML text in memory — passed as a string that begins (after optional leading whitespace) with the
ad_hoc_diffractometer_geometry:schema marker. The loader parses the string directly without touching the filesystem.
A string that does not contain the marker is interpreted as a path,
not as YAML text. If neither attempt succeeds, the
FileNotFoundError message names both (“first attempted as
YAML text … then attempted as a path …”) so you can tell what went
wrong.
Examples#
import pathlib
import ad_hoc_diffractometer as ahd
# (1) Path object
g = ahd.load_geometry_file(pathlib.Path("/lab/diffr.yml"))
# (2) Path string
g = ahd.load_geometry_file("/lab/diffr.yml")
# (3) YAML text in memory
g = ahd.load_geometry_file("""
ad_hoc_diffractometer_geometry:
schema_revision: 1
name: my_diffr
basis: BL
stages:
- {name: omega, axis: -transverse, parent: null, role: sample}
- {name: ttheta, axis: -transverse, parent: null, role: detector}
modes:
bisecting:
default: true
constraints:
- {type: bisect, stage1: omega, stage2: ttheta}
computed: [omega, ttheta]
""")
Run-time registration#
To make a user-authored YAML file available via
make_geometry(), register it once:
import ad_hoc_diffractometer as ahd
name = ahd.register_geometry_file("/lab/diffr.yml")
g = ahd.make_geometry(name) # name is the filename stem
g = ahd.make_geometry(name, basis=ahd.factories.BASIS_YOU) # override
A second call with a colliding name raises ValueError; pass
name="my_lab_diffr" to register under a different name. The
registry stores a wrapper that re-reads the file on each call, so
on-disk edits are picked up without re-registration.
Migrating between schema revisions#
No migrations are required at this time. This section will be populated when a new schema revision is introduced.
JSON Schema (revision 1)#
A machine-readable JSON Schema is shipped alongside the package at
src/ad_hoc_diffractometer/geometries/schema.json and is accessible at
runtime via:
from ad_hoc_diffractometer.geometry_loader import get_schema, get_schema_text
schema = get_schema() # parsed dict
text = get_schema_text() # raw JSON string
The loader does not consume this schema at runtime — its hand-written validator enforces the same rules in pure Python and produces context-rich error messages. The JSON Schema exists for three audiences:
Editor tooling. VS Code, JetBrains, and other editors can validate
.ymlfiles against a JSON Schema for autocomplete and live linting. Point your editor at the file above (or at the$idURL once published).Documentation. The page you are reading is generated from the same schema source.
Human readers who want a single dense source of truth.
The full schema:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://prjemian.github.io/ad_hoc_diffractometer/schemas/geometry-revision-1.json",
"title": "ad_hoc_diffractometer declarative geometry (revision 1)",
"description": "Schema for declarative diffractometer geometry files consumed by ad_hoc_diffractometer.geometry_loader (issue #267). The authoritative reference is docs/source/reference/declarative_geometry_schema.md; this JSON Schema is the machine-readable equivalent and is shipped alongside the package for editor tooling and documentation.",
"type": "object",
"additionalProperties": false,
"required": [
"ad_hoc_diffractometer_geometry",
"name",
"stages",
"modes"
],
"properties": {
"ad_hoc_diffractometer_geometry": {
"description": "Document marker. Its presence identifies the file as an ad_hoc_diffractometer geometry; the integer schema_revision selects which version of this schema the file conforms to. schema_revision is a fixed property of the schema, not a per-file edit counter; users should treat the value verbatim.",
"type": "object",
"additionalProperties": false,
"required": ["schema_revision"],
"properties": {
"schema_revision": {
"description": "Schema revision (currently must equal 1). Independent of the package version; treat this as a verbatim identifier of the schema this file conforms to, not a per-file edit counter.",
"type": "integer",
"enum": [1]
}
}
},
"name": {
"description": "Registry key. For files inside src/ad_hoc_diffractometer/geometries/, this MUST equal the filename stem.",
"type": "string",
"minLength": 1
},
"documentation": {
"description": "Free-form prose. The first line is the one-line summary (Python-docstring convention).",
"type": "string"
},
"basis": {
"description": "Coordinate basis. Three accepted forms: (1) shorthand string ('BL', 'YOU', 'DEFAULT'); (2) mapping of signed-axis or signed-physical-direction strings (e.g. {vertical: '+z', longitudinal: '+y', transverse: '+x'}); (3) mapping of length-3 numeric unit vectors, including non-axis-aligned bases such as {vertical: [0.7071, 0.7071, 0.0], longitudinal: [-0.7071, 0.7071, 0.0], transverse: [0.0, 0.0, 1.0]}. The three vectors must be orthonormal (each unit length, mutually orthogonal); the loader validates this within tolerance 1e-9 and rejects degenerate or non-orthonormal bases. Either chirality of the (vertical, longitudinal, transverse) ordering is accepted. Caller-supplied basis= overrides whatever is declared here. When omitted, the loader uses BASIS_DEFAULT (vertical=YHAT, longitudinal=ZHAT, transverse=XHAT) and emits a UserWarning.",
"oneOf": [
{
"type": "string",
"enum": ["BL", "YOU", "DEFAULT"]
},
{
"type": "object",
"additionalProperties": false,
"required": ["vertical", "longitudinal", "transverse"],
"properties": {
"vertical": { "$ref": "#/$defs/basis_vector" },
"longitudinal": { "$ref": "#/$defs/basis_vector" },
"transverse": { "$ref": "#/$defs/basis_vector" }
}
}
]
},
"parameters": {
"description": "Caller-tunable defaults. Currently only alpha_deg is recognized.",
"type": "object",
"additionalProperties": false,
"properties": {
"alpha_deg": {
"description": "Kappa tilt angle in degrees. Must be in (0, 90).",
"type": "number",
"exclusiveMinimum": 0,
"exclusiveMaximum": 90
}
}
},
"kappa_chi_eq": {
"description": "Equivalent-Eulerian chi axis. Required when parameters.alpha_deg is declared. Used by axis fields of the form {kappa_eulerian: ...}.",
"$ref": "#/$defs/axis_value"
},
"stages": {
"description": "Ordered list of rotary stages. Order in this list is informational; mechanical stacking is determined by the parent field of each stage.",
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/stage" }
},
"modes": {
"description": "Mapping of mode-name to mode-spec. At most one mode may set 'default: true'.",
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/$defs/mode" }
}
},
"$defs": {
"basis_vector": {
"description": "One basis vector. Two accepted forms: (1) a signed-axis or signed-physical-direction string (e.g. '+x', '-z', '+vertical', '-transverse') -- when this form is used inside the 'basis:' mapping itself, only the signed Cartesian forms ('+x', '-y', '+z', ...) are meaningful, since physical-direction names cannot be self-referential; (2) a length-3 numeric vector. Numeric vectors need not be axis-aligned -- any orthonormal triple is accepted, e.g. {vertical: [0.7071, 0.7071, 0.0], longitudinal: [-0.7071, 0.7071, 0.0], transverse: [0.0, 0.0, 1.0]}. The loader validates that the three vectors form an orthonormal frame within tolerance 1e-9. Unlike a stage 'axis:' field, a basis vector cannot use the {kappa_eulerian: ...} mapping form.",
"oneOf": [
{
"type": "string",
"minLength": 1
},
{
"type": "array",
"minItems": 3,
"maxItems": 3,
"items": { "type": "number" }
}
]
},
"axis_value": {
"description": "A stage axis specification. Three accepted forms: (1) signed physical-direction or Cartesian string ('+transverse', '-vertical', '+x', '-z'); (2) length-3 numeric vector (sign included; the Stage constructor handles normalisation for rotation calculations); (3) kappa-tilt mapping {kappa_eulerian: <axis>}, valid only when parameters.alpha_deg and the top-level kappa_chi_eq are both declared.",
"oneOf": [
{
"type": "string",
"minLength": 1
},
{
"type": "array",
"minItems": 3,
"maxItems": 3,
"items": { "type": "number" }
},
{
"type": "object",
"additionalProperties": false,
"required": ["kappa_eulerian"],
"properties": {
"kappa_eulerian": {
"oneOf": [
{ "type": "string", "minLength": 1 },
{
"type": "array",
"minItems": 3,
"maxItems": 3,
"items": { "type": "number" }
}
]
}
}
}
]
},
"stage": {
"type": "object",
"additionalProperties": false,
"required": ["name", "axis", "parent", "role"],
"properties": {
"name": {
"description": "Stage name; unique within the geometry.",
"type": "string",
"minLength": 1
},
"axis": { "$ref": "#/$defs/axis_value" },
"parent": {
"description": "Name of the stage this one sits on, or null for the lab frame.",
"type": ["string", "null"]
},
"role": {
"description": "Conventional values are 'sample' and 'detector'. Any other string is accepted (e.g. 'analyzer', 'polarizer') but is ignored by the forward/inverse solvers.",
"type": "string",
"minLength": 1
}
}
},
"mode": {
"type": "object",
"additionalProperties": false,
"required": ["constraints"],
"properties": {
"default": {
"description": "Mark this mode as the geometry's default. At most one mode per file may set this to true.",
"type": "boolean"
},
"constraints": {
"description": "List of constraint specs. May be empty.",
"type": "array",
"items": { "$ref": "#/$defs/constraint" }
},
"computed": {
"description": "Stage names the solver writes (informational).",
"type": "array",
"items": { "type": "string" }
},
"extras": {
"description": "Additional inputs and output slots. The literal string 'REQUIRED' is mapped to the ad_hoc_diffractometer.mode.REQUIRED sentinel; YAML null is preserved as Python None.",
"type": "object"
},
"cut_points": {
"description": "Per-stage SPEC #G4 cut-points. Maps stage name to a float; the returned angle lies in [C, C + 360 deg).",
"type": "object",
"additionalProperties": { "type": "number" }
}
}
},
"constraint": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"required": ["type", "stage1", "stage2"],
"properties": {
"type": { "const": "bisect" },
"stage1": { "type": "string", "minLength": 1 },
"stage2": { "type": "string", "minLength": 1 }
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["type", "stage1", "stage2"],
"properties": {
"type": { "const": "virtual_bisect" },
"stage1": { "type": "string", "minLength": 1 },
"stage2": { "type": "string", "minLength": 1 }
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["type", "stage", "value"],
"properties": {
"type": { "const": "sample" },
"stage": { "type": "string", "minLength": 1 },
"value": { "type": "number" }
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["type", "stage", "value"],
"properties": {
"type": { "const": "detector" },
"stage": { "type": "string", "minLength": 1 },
"value": { "type": "number" }
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["type", "name", "value"],
"properties": {
"type": { "const": "reference" },
"name": {
"type": "string",
"enum": ["psi", "alpha_i", "beta_out", "a_eq_b", "naz", "omega"]
},
"value": {
"oneOf": [
{ "type": "number" },
{ "type": "boolean" }
]
}
}
}
]
}
}
}