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

ad_hoc_diffractometer_geometry

yes

mapping

Document marker (see below)

name

yes

non-empty string

Registry key (must equal filename stem for files in the package directory)

documentation

recommended

string

Free-form prose; the first line is the one-line summary (Python-docstring convention)

basis

optional

string or mapping

Coordinate basis (see basis)

parameters

optional

mapping

Caller-tunable defaults; currently only alpha_deg

kappa_chi_eq

conditional

string or 3-vector

Equivalent-Eulerian chi axis; required for kappa geometries

stages

yes

non-empty list

Rotary stages (see stages)

modes

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

KIND_KEY

"ad_hoc_diffractometer_geometry"

The marker key name

SUPPORTED_REVISIONS

frozenset({1})

Schema revisions this build can parse

CURRENT_REVISION

1

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:

  1. 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)

  2. Mapping of signed-axis strings

    basis:
        vertical: '+z'
        longitudinal: '+y'
        transverse: '+x'
    
  3. 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):

  1. Caller-supplied basis= keyword to make_geometry(), load_geometry_file(), or register_geometry_file().

  2. YAML file’s basis: key (any of the three forms above).

  3. 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

alpha_deg

float

Kappa tilt angle (degrees); used by kappa_eulerian axis specs

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

name

string

Stage name (e.g. omega, kappa, mu)

axis

see below

Rotation axis (signed)

parent

string or null

Name of the stage this one sits on, or null for the lab frame

role

string

"sample", "detector", or any other role label

Axis grammar#

Three accepted forms:

  1. Signed physical-direction string — resolved against the active basis:

    axis: -transverse
    axis: +longitudinal
    
  2. Signed Cartesian string — resolved directly against XHAT, YHAT, ZHAT:

    axis: '+x'
    axis: '-z'
    
  3. Length-3 numeric vector (used as-is, sign included; no automatic normalisation other than what the Stage constructor performs):

    axis: [0.0, 0.5, 0.866]
    
  4. Kappa-tilt mapping — only meaningful when parameters.alpha_deg and kappa_chi_eq are 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

default

optional

bool

When true, this is the geometry’s default mode. At most one mode per file may set this.

constraints

yes

list

Constraint specs (may be empty)

computed

optional

list of strings

Stage names the solver writes

extras

optional

mapping

Additional inputs/outputs (see below)

cut_points

optional

mapping

Per-stage SPEC #G4 cut-points

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.

type

Required keys

Constraint class

bisect

stage1, stage2

BisectConstraint

virtual_bisect

stage1, stage2

VirtualBisectConstraint

sample

stage, value

SampleConstraint

detector

stage, value

DetectorConstraint

reference

name, value

ReferenceConstraint

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 REQUIRED is mapped to the REQUIRED sentinel; the solver checks for the sentinel before running and raises if the caller didn’t supply a real value.

  • YAML null → Python None. Used for output slots that the solver populates after forward() 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, any os.PathLike, or a path string. The loader reads the file and parses its contents. FileNotFoundError if 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:

  1. Editor tooling. VS Code, JetBrains, and other editors can validate .yml files against a JSON Schema for autocomplete and live linting. Point your editor at the file above (or at the $id URL once published).

  2. Documentation. The page you are reading is generated from the same schema source.

  3. 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" }
                            ]
                        }
                    }
                }
            ]
        }
    }
}

See also#