From 3b6e181d7ed9abb5a15217d68a4e5347356ea91b Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 25 Aug 2026 01:09:14 +0200 Subject: [PATCH 01/24] feat(schema): added schema for handling devices --- TODOS.md | 2 + backend/Configs/Devices.yml | 96 +++ backend/Configs/Devices_broken.yml | 22 + backend/Configs/README.md | 516 ++++++++++++ backend/Configs/__note__.md | 4 + backend/Configs/data/Devices.modified.json | 149 ++++ backend/Configs/data/Devices.modified.yml | 108 +++ .../data/hik_mv_ca023_10um.modified.yaml | 36 + backend/Configs/hik_mv_ca023_10um.json | 32 + backend/Configs/hik_mv_ca023_10um.yaml | 45 + backend/Configs/schemas/camera.schema.json | 376 +++++++++ backend/Configs/schemas/devices.schema.json | 766 ++++++++++++++++++ backend/example_schemas.py | 177 ++++ backend/newswitch/config.py | 191 +++++ backend/newswitch/schemas/__init__.py | 98 +++ backend/newswitch/schemas/__main__.py | 40 + backend/newswitch/schemas/bounded.py | 130 +++ backend/newswitch/schemas/camera_io.py | 148 ++++ backend/newswitch/schemas/camera_schema.py | 168 ++++ backend/newswitch/schemas/device_io.py | 186 +++++ backend/newswitch/schemas/device_schema.py | 158 ++++ backend/newswitch/schemas/document.py | 115 +++ backend/newswitch/schemas/errors.py | 20 + backend/newswitch/schemas/loader.py | 202 +++++ backend/pyproject.toml | 3 + backend/tests/conftest.py | 141 +++- backend/tests/test_bounded.py | 144 ++++ backend/tests/test_camera_io.py | 210 +++++ backend/tests/test_camera_schema.py | 206 +++++ backend/tests/test_config_paths.py | 187 +++++ backend/tests/test_device_io.py | 223 +++++ backend/tests/test_device_schema.py | 181 +++++ backend/tests/test_schema_export_drift.py | 97 +++ backend/tests/test_schema_loader.py | 337 ++++++++ backend/uv.lock | 170 ++++ 35 files changed, 5683 insertions(+), 1 deletion(-) create mode 100644 TODOS.md create mode 100644 backend/Configs/Devices.yml create mode 100644 backend/Configs/Devices_broken.yml create mode 100644 backend/Configs/README.md create mode 100644 backend/Configs/__note__.md create mode 100644 backend/Configs/data/Devices.modified.json create mode 100644 backend/Configs/data/Devices.modified.yml create mode 100644 backend/Configs/data/hik_mv_ca023_10um.modified.yaml create mode 100644 backend/Configs/hik_mv_ca023_10um.json create mode 100644 backend/Configs/hik_mv_ca023_10um.yaml create mode 100644 backend/Configs/schemas/camera.schema.json create mode 100644 backend/Configs/schemas/devices.schema.json create mode 100644 backend/example_schemas.py create mode 100644 backend/newswitch/config.py create mode 100644 backend/newswitch/schemas/__init__.py create mode 100644 backend/newswitch/schemas/__main__.py create mode 100644 backend/newswitch/schemas/bounded.py create mode 100644 backend/newswitch/schemas/camera_io.py create mode 100644 backend/newswitch/schemas/camera_schema.py create mode 100644 backend/newswitch/schemas/device_io.py create mode 100644 backend/newswitch/schemas/device_schema.py create mode 100644 backend/newswitch/schemas/document.py create mode 100644 backend/newswitch/schemas/errors.py create mode 100644 backend/newswitch/schemas/loader.py create mode 100644 backend/tests/test_bounded.py create mode 100644 backend/tests/test_camera_io.py create mode 100644 backend/tests/test_camera_schema.py create mode 100644 backend/tests/test_config_paths.py create mode 100644 backend/tests/test_device_io.py create mode 100644 backend/tests/test_device_schema.py create mode 100644 backend/tests/test_schema_export_drift.py create mode 100644 backend/tests/test_schema_loader.py diff --git a/TODOS.md b/TODOS.md new file mode 100644 index 0000000..2e43c2f --- /dev/null +++ b/TODOS.md @@ -0,0 +1,2 @@ +- fix /backend/configs.py for correct deployment +- remove /backend/configs -> thats only for development purposes diff --git a/backend/Configs/Devices.yml b/backend/Configs/Devices.yml new file mode 100644 index 0000000..38db1c6 --- /dev/null +++ b/backend/Configs/Devices.yml @@ -0,0 +1,96 @@ +# Device registry for one microscope setup. +# Each entry is dispatched by its `type` field to the matching schema. +version: 1 +setup: openUC2-bench-01 + +devices: + - type: camera + name: MV-CA023-10UM + manufacturer: HIK ROBOTICS + camera_id: hik-mv-ca023-001 + pixelcount: + x: 1920 + y: 1200 + pixelpitch_um: + x: 10.0 # y omitted -> derived as 10.0 + channels: 1 + # Expanded form: value plus the limits the device reports. + exposure_time_ms: + value: 10.0 + min: 0.024 + max: 10000.0 + increment: 0.001 + framerate_per_sec: + value: 163.0 + min: 0.5 + max: 163.0 + gain_db: + value: 0.0 + min: 0.0 + max: 23.98 + increment: 0.01 + trigger: + type: hardware + edge: rising + tags: [monochrome, global-shutter, gige] + + - type: camera + name: U3-3800CP-C-HQ + manufacturer: IDS Imaging + camera_id: ids-u3-3800-002 + pixelcount: + x: 3088 + y: 2076 + pixelpitch_um: + x: 2.4 + pixelsize_um: + x: 2.2 # fill factor < 1 + channels: 3 + # Shorthand form: a bare number means {value: }. + framerate_per_sec: 30.0 + exposure_time_ms: 20.0 + tags: [color, rolling-shutter, usb3] + + - type: stage + name: XY-Linear-100 + manufacturer: openUC2 + device_id: stage-xy-001 + axes: + - label: x + travel_um: 100000.0 + steps_per_um: 8.0 + - label: y + travel_um: 100000.0 + steps_per_um: 8.0 + - label: z + travel_um: 10000.0 + steps_per_um: 25.0 + inverted: true + max_speed_um_per_sec: 5000.0 + homing_required: true + + - type: laser + name: LD-488-100 + manufacturer: openUC2 + device_id: laser-488-001 + wavelength_nm: 488.0 + max_power_mw: 100.0 + modulation: digital + shutter: false + + - type: filterwheel + name: FW-6 + manufacturer: openUC2 + device_id: fw-001 + slots: + - position: 0 + label: empty + - position: 1 + label: GFP-525/50 + center_nm: 525.0 + bandwidth_nm: 50.0 + - position: 2 + label: mCherry-600/50 + center_nm: 600.0 + bandwidth_nm: 50.0 + switch_time_ms: 120.0 diff --git a/backend/Configs/Devices_broken.yml b/backend/Configs/Devices_broken.yml new file mode 100644 index 0000000..7487510 --- /dev/null +++ b/backend/Configs/Devices_broken.yml @@ -0,0 +1,22 @@ +# Deliberately broken - demonstrates the error output of validate_registry() +version: 1 +setup: broken-demo + +devices: + - type: camera + name: BadCam + pixelcount: + x: -5 # must be > 0 + pixelpitch_um: + x: 3.45 + binning: 3 # not in [1, 2, 4] + colour: red # unknown key + + - type: stage + name: NoAxes + axes: [] # min_length=1 + + - type: spectrometer # unknown device type + name: Ghost + + - name: NoType # missing discriminator diff --git a/backend/Configs/README.md b/backend/Configs/README.md new file mode 100644 index 0000000..ae43781 --- /dev/null +++ b/backend/Configs/README.md @@ -0,0 +1,516 @@ +# Device Configuration Schemas + +Pydantic-backed schemas for describing devices (sensor geometry, identity, +acquisition settings), plus an importer that reads and validates **JSON or +YAML** files against them. + +The code lives in `backend/newswitch/schemas/`; this folder holds the sample +configs and the exported JSON Schemas it works on. + +## Where things are + +| Location | Purpose | +| --- | --- | +| `newswitch/schemas/camera_schema.py` | The `CameraSchema` model and its sub-models | +| `newswitch/schemas/camera_io.py` | Camera-only loader, validator, writer, schema exporter | +| `newswitch/schemas/bounded.py` | `Bounded`: a numeric parameter with its own min/max/increment | +| `newswitch/schemas/document.py` | File parsing/writing and path resolution | +| `newswitch/schemas/loader.py` | `load_config` / `load_device`: one entry point for any layout | +| `newswitch/config.py` | Which folders configs, schemas, data and logs live in | +| `Configs/schemas/camera.schema.json` | Exported Draft 2020-12 JSON Schema (generated) | +| `Configs/hik_mv_ca023_10um.json` | Example: HIKROBOT MV-CA023-10UM | +| `Configs/hik_mv_ca023_10um.yaml` | Same example in YAML | +| `example_schemas.py` | Runnable tour of the whole API | +| `tests/test_*.py` | Unit tests for each of the modules above | + +Dependencies (pydantic, jsonschema, pyyaml, pydantic-settings, platformdirs) are +declared in `backend/pyproject.toml`; `uv sync` installs them. + +## Quick start + +```bash +cd backend + +uv run python example_schemas.py # the full tour, load -> inspect -> store +uv run python -m newswitch.schemas hik_mv_ca023_10um Devices.yml +# setup: - (v1) +# [camera ] hik-mv-ca023-001 1920x1200 px, 19.2x12.0 mm +# setup: openUC2-bench-01 (v1) +# ... +``` + +```python +from newswitch.schemas import load_config, load_device, dump_config + +# One call for either layout -- always a DeviceRegistry. +reg = load_config("Devices.yml") # 5 devices +reg = load_config("hik_mv_ca023_10um") # single-device file -> 1 entry + +# One call for a single device, from either kind of file. +cam = load_device("hik_mv_ca023_10um", expect="camera") +cam = load_device("Devices.yml", "hik-mv-ca023-001") + +print(cam.sensor_size_mm) # (19.2, 12.0) +print(cam.pixelpitch_um.y) # 10.0 <- derived from x + +dump_config(cam, "data/out.json") # round-trip to either format +``` + +The camera-only entry points are still there when a file must be a camera: + +```python +from newswitch.schemas import load_camera, dump_camera, export_camera_schema + +cam = load_camera("hik_mv_ca023_10um.yaml") +dump_camera(cam, "data/out.yaml") +export_camera_schema() # regenerate Configs/schemas/camera.schema.json +``` + +## Where files are looked up + +Paths go through `newswitch.config.Paths`, so callers name a config instead of +building a path: + +| Argument | Resolves to | +| --- | --- | +| `"Devices.yml"` | `/Devices.yml` | +| `"Devices"` | the first of `.yaml`, `.yml`, `.json` that exists in `` | +| `"data/out.json"`, `Path("/etc/...")` | used exactly as given | + +`config_dir` defaults to this folder and is overridable with +`NEWSWITCH_CONFIG_DIR` (likewise `NEWSWITCH_SCHEMA_DIR`, `NEWSWITCH_DATA_DIR`, +`NEWSWITCH_LOG_DIR`). Writing follows the same rules, so `dump_config(reg, +"backup.yml")` lands in the config directory. + +## Single file, several layouts + +`load_config()` accepts every shape a device file occurs in and always returns a +`DeviceRegistry`: + +| Document | Handling | +| --- | --- | +| mapping with a `devices:` key | multi-device file, as-is | +| a bare list | treated as `devices:` | +| mapping without `devices:` | single device, wrapped into a one-entry registry | + +A single-device file without a `type:` key is read as a camera — that is why +`hik_mv_ca023_10um.yaml` works unchanged. Any other device type has to spell out +its `type:`. Errors of a wrapped file are reported without the `devices[0]` +prefix, so they read the way the file looks. + +## Required vs. optional + +In Pydantic the **default value** decides: a field without a default is +required, a field with a default (including `None`) is optional. +`Optional[X]` alone only makes a field *nullable*, not optional. + +**Required:** `name`, `pixelcount.x`, `pixelcount.y`, `pixelpitch_um.x` +Everything else is optional. + +> Note on `camera_id` and `exposure_time_ms`: these were required in the +> earlier standalone example. Here they are optional, because this file +> describes a camera *model* — the HIK example carries no serial number, and +> exposure is a runtime setting rather than a device property. Move them back +> to required by simply removing their defaults. + +## Field reference + +### Tooling + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `$schema` | `str` | `None` | Optional path/URL to `camera.schema.json`. Editors like VS Code use it for autocomplete and inline validation. | + +### Identity + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `str` | **required** | Model designation, e.g. `MV-CA023-10UM`. | +| `manufacturer` | `str \| None` | `None` | Vendor name, e.g. `HIK ROBOTICS`. | +| `camera_id` | `str \| None` | `None` | Identifier of the individual physical unit (serial number, device path). | + +### Sensor geometry + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `pixelcount.x` | `int > 0` | **required** | Sensor resolution along x, in pixels. | +| `pixelcount.y` | `int > 0` | **required** | Sensor resolution along y, in pixels. | +| `pixelpitch_um.x` | `float > 0` | **required** | Center-to-center pixel distance along x, in µm. | +| `pixelpitch_um.y` | `float > 0` | `= x` | Pitch along y. **Omit it for square pixels** — it is filled with `x`. | +| `pixelsize_um.x` | `float > 0` | `= pixelpitch_um.x` | Photosensitive width per pixel, in µm. Smaller than the pitch when there is dead space between pixels. | +| `pixelsize_um.y` | `float > 0` | `= pixelsize_um.x` | Photosensitive height per pixel. Note the fallback is `pixelsize_um.x`, not `pixelpitch_um.y`. | +| `channels` | `int >= 1` | `1` | Number of color/spectral channels. `1` = monochrome, `3` = RGB. | + +If the whole `pixelsize_um` object is omitted, it is copied from +`pixelpitch_um` (both components) — i.e. a fill factor of 1.0. + +### Acquisition + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `exposure_time_ms` | `Bounded` | `None` | Integration time in ms. Bare number or `{value, min, max, increment}` — see "Min/max limits" below. | +| `framerate_per_sec` | `Bounded` | `None` | Frame rate in Hz, same two forms. | +| `gain_db` | `Bounded` | `None` | Analog gain in dB, same two forms. | +| `binning` | `1 \| 2 \| 4` | `1` | Symmetric hardware binning factor. | +| `roi` | `[int, int, int, int]` | `None` | Region of interest as `(x, y, width, height)` in pixels. Validated against `pixelcount`. | +| `tags` | `list[str]` | `[]` | Free-form labels, e.g. `monochrome`, `global-shutter`, `gige`. | + +### Trigger + +Omit the whole `trigger` object for free-running acquisition (default `None`). + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `trigger.type` | `"software" \| "hardware"` | **required within `trigger`** | Trigger source. | +| `trigger.edge` | `"rising" \| "falling"` | `None` | Signal edge the exposure starts on. Hardware triggers only. | +| `trigger.level` | `"high" \| "low"` | `None` | Active level for level-sensitive triggering. Hardware triggers only. | + +Setting `edge` or `level` together with `type: software` is rejected — assumed +to be a configuration mistake. Drop the `_check_software` validator in +`camera_schema.py` if your hardware allows that combination. + +### Quantum efficiency (commented out) + +`quantum_efficiency: Optional[np.ndarray]` is present but disabled in +`camera_schema.py`. A raw `np.ndarray` requires +`ConfigDict(arbitrary_types_allowed=True)` plus a custom serializer, and it +**cannot be expressed in JSON Schema at all**, which would break the +jsonschema validation path and any non-Python consumer. + +The JSON-native alternative sketched in the file is a pair of parallel lists: + +```python +qe_wavelength_nm: Optional[list[float]] = None +qe_values: Optional[list[float]] = None # 0.0 .. 1.0, same length +``` + +Convert to numpy at the call site with `np.asarray(cam.qe_values)`. + +## Derived properties + +| Property | Description | +| --- | --- | +| `sensor_size_mm` | `(width, height)` in mm, from `pixelcount * pixelpitch_um`. | +| `fill_factor` | `(pixelsize.x * pixelsize.y) / (pitch.x * pitch.y)`, range 0–1. | + +## Validation layers + +The importer runs two passes, which catch different things: + +1. **`jsonschema`** against the exported schema — reports *all* structural + errors at once (missing keys, wrong types, out-of-range values, unknown + keys), without building an object. +2. **Pydantic** — coerces types, applies defaults, and runs the cross-field + rules (`roi` inside `pixelcount`, software-trigger check). + +Cross-field rules and derived defaults live **only** in layer 2. A tool that +validates against `camera.schema.json` alone will accept an oversized `roi` +and will not fill in `pixelpitch_um.y`. + +```python +from newswitch.schemas import read_document, schema_errors +for err in schema_errors(read_document("broken.yaml")): + print(err) +# : 'name' is a required property +# pixelpitch_um.x: -1 is less than or equal to the minimum of 0 +# binning: 3 is not one of [1, 2, 4] +``` + +Unknown keys are rejected (`extra="forbid"`) so typos like `pixelcont` fail +loudly instead of being silently ignored. Relax with `extra="ignore"` if you +need forward compatibility with newer config files. + +## YAML instead of JSON + +Yes — and it needs no separate schema. YAML 1.2 is a superset of JSON, so +`yaml.safe_load` returns the same plain dicts that `json.loads` does, and the +identical `CameraSchema` / `camera.schema.json` validates both. +`load_camera()` picks the parser from the file extension. + +Practical differences: + +| | JSON | YAML | +| --- | --- | --- | +| Comments | no | yes | +| Human editing | verbose | compact | +| Editor schema support | native via `$schema` | via YAML extension + schema mapping | +| Parser in stdlib | yes | no (`pyyaml`) | +| Ambiguity risk | low | `1.0` vs `1`, `on`/`yes` as booleans, indentation errors | + +For hand-maintained device descriptions YAML is usually the better choice, if +only for the comments. Always use `yaml.safe_load`, never `yaml.load` — +the latter can instantiate arbitrary Python objects from the file. + +TOML (`tomllib`, stdlib since Python 3.11) is a third option: less ambiguous +than YAML, comments supported, but nested structures are more awkward. + +--- + +# Multi-device files (`Devices.yml`) + +A setup usually holds more than cameras. `Devices.yml` is a single file with a +list of mixed device types, each validated against its own schema. + +## Additional files + +| File | Purpose | +| --- | --- | +| `newswitch/schemas/device_schema.py` | `DeviceBase`, the device types, the `Device` union, `DeviceRegistry` | +| `newswitch/schemas/device_io.py` | Loader and per-type validator | +| `Configs/schemas/devices.schema.json` | Exported Draft 2020-12 schema for the whole registry (generated) | +| `Configs/Devices.yml` | Example setup: 2 cameras, stage, laser, filter wheel | +| `Configs/Devices_broken.yml` | Deliberately broken, to show the error output | + +```bash +uv run python -m newswitch.schemas Devices.yml +# setup: openUC2-bench-01 (v1) +# [camera ] hik-mv-ca023-001 1920x1200 px, 19.2x12.0 mm +# [stage ] stage-xy-001 axes x/y/z +# ... +``` + +## The discriminator + +Every device carries a `type` field with a `Literal` value. Pydantic uses it to +dispatch each list entry to exactly one model: + +```python +Device = Annotated[ + Union[CameraSchema, StageDevice, LaserDevice, FilterWheelDevice], + Field(discriminator="type"), +] + +class DeviceRegistry(BaseModel): + version: int = 1 + setup: Optional[str] = None + devices: list[Device] = Field(default_factory=list) +``` + +Without the discriminator Pydantic tries every branch and reports the failures +of all of them. With it, errors point straight at the field: + +``` +devices.0.stage.axes.0.travel_um -> Input should be greater than 0 +``` + +## Why not validate the whole file with jsonschema directly + +A mixed list becomes `oneOf` in JSON Schema. A plain validator then dumps one +error block per branch, each ending in *"is not valid under any of the given +schemas"* and echoing the entire offending object. On `Devices_broken.yml` that +produces 4 unusable top-level errors. + +`validate_registry()` in `device_io.py` reads the `type` field **first** and +validates the entry against only that sub-schema: + +```python +TYPE_VALIDATORS = { + name: Draft202012Validator(model.model_json_schema()) + for name, model in DEVICE_MODELS.items() +} +``` + +Result on the same file: + +``` +devices[0].pixelcount: 'y' is a required property +devices[0].pixelcount.x: -5 is less than or equal to the minimum of 0 +devices[0].binning: 3 is not one of [1, 2, 4] +devices[0]: Additional properties are not allowed ('colour' was unexpected) +devices[1].axes: [] should be non-empty +devices[2]: unknown type 'spectrometer' (known: camera, filterwheel, laser, stage) +devices[3]: missing 'type' discriminator +``` + +Each model's `model_json_schema()` is self-contained (it carries its own +`$defs`), so no `$ref` resolution across files is needed. + +## Registry API + +```python +from newswitch.schemas import load_devices + +reg = load_devices("Devices.yml") # or load_config(), which also takes single-device files +reg.by_type("camera") # [CameraSchema, CameraSchema] +reg.get("laser-488-001") # lookup by device_id, falls back to name +reg.devices[0].sensor_size_mm # camera-specific properties stay available +``` + +Static type checkers narrow the union correctly after a `dev.type == "camera"` +check, so `dev.pixelcount` is only accessible where it exists. + +## Device types + +Common to all (`DeviceBase`): `name` (required), `manufacturer`, `device_id`, +`tags`. Cameras use `camera_id` instead of `device_id` — `device_key()` in +`device_schema.py` papers over that difference. + +| Type | Fields beyond the common ones | +| --- | --- | +| `camera` | see the camera field reference above | +| `stage` | `axes[]` (`label` x–c, `travel_um`, `steps_per_um`, `inverted`), `max_speed_um_per_sec`, `homing_required` | +| `laser` | `wavelength_nm`, `max_power_mw`, `modulation` (`none`/`analog`/`digital`), `shutter` | +| `filterwheel` | `slots[]` (`position`, `label`, `center_nm`, `bandwidth_nm`), `switch_time_ms` | + +Cross-field rules enforced by Pydantic only (not by the JSON Schema): unique +axis labels, unique filter slot positions, unique device identifiers across the +whole registry. + +## Adding a device type + +1. Subclass `DeviceBase` with a new `type: Literal["..."]`. +2. Add the class to the `Device` union and to `DEVICE_MODELS`. +3. Done — registry, loader, error reporting and the exported JSON Schema pick + it up automatically. + +## Single-device files still work + +`load_camera()` is unchanged. `CameraSchema.type` has a default of +`"camera"`, so `hik_mv_ca023_10um.yaml` validates without the field, while the +same model works as a union member where `type` is present. `load_config()` uses +the same default when it wraps a single-device file, and `load_device()` reads +either kind of file: + +```python +load_device("hik_mv_ca023_10um") # from a single-device file +load_device("Devices.yml", "hik-mv-ca023-001") # from the registry +``` + +--- + +# Min/max limits for a parameter + +## Recommendation: nested, with scalar shorthand + +Sibling tags (`exposure_time_ms`, `exposure_time_ms_min`, `exposure_time_ms_max`) +are flat and readable, but they scale badly: + +* three fields per parameter, and nothing in the schema ties them together +* the rule "value lies between min and max" has to be written once **per + parameter** instead of once in total +* adding `increment` or `unit` later means touching every parameter again +* a typo like `exposure_time_ms_mim` becomes a new top-level key rather than an + error inside a known object + +The nested form is one reusable model, `Bounded` (see `bounded.py`): + +```yaml +exposure_time_ms: + value: 10.0 + min: 0.024 + max: 10000.0 + increment: 0.001 +``` + +The usual objection to nesting is verbosity, so `Bounded` also accepts a **bare +number as shorthand** for `{value: }`: + +```yaml +exposure_time_ms: 10.0 # equivalent to {value: 10.0} +``` + +Both forms validate in **both** layers. `Bounded.__get_pydantic_json_schema__` +emits `anyOf: [number, object]`, so the jsonschema pass accepts the scalar too — +without that override the shorthand would pass Pydantic and fail jsonschema. + +All four members are optional, which covers every case that occurs in practice: + +| Content | Meaning | +| --- | --- | +| `{value: 10}` or `10` | a setting; limits unknown or irrelevant | +| `{min: 0.024, max: 10000}` | pure capability, no value chosen | +| `{value: 10, min: 0.024, max: 10000}` | both | +| omitted / `null` | parameter not applicable to this device | + +## Fields of `Bounded` + +| Field | Description | +| --- | --- | +| `value` | Current or default value | +| `min` | Smallest settable value | +| `max` | Largest settable value | +| `increment` | Step size; settable values are `min + n * increment` | +| `unit` | Override for the unit implied by the field name | + +`increment` is worth carrying. GenICam devices silently snap a non-representable +value to the nearest grid point, so a config that asks for 10.0005 ms quietly +becomes 10.0 ms. `Bounded.supports()` catches that up front, `Bounded.clamp()` +performs the snap deliberately. + +```python +e = cam.exposure_time_ms +e.clamp(1e6) # 10000.0 -> clamped to max +e.clamp(10.00047) # 10.0 -> snapped to the 0.001 grid +e.supports(10.0005) # False +``` + +## What JSON Schema can and cannot express here + +`minimum`/`maximum` in JSON Schema are **global constants**. They cannot refer +to another field of the same document, so "value must lie inside *this device's* +min/max" is a cross-field rule and lives in Pydantic only: + +``` +Bounded(value=20000, min=0.024, max=10000) + -> value 20000.0 above max 10000.0 +``` + +This is the same split as elsewhere in this project: the exported +`camera.schema.json` checks structure and types, Pydantic checks relations. +Consumers in other languages get the structure for free but must re-implement +the relation — three lines, and worth doing. + +Note that the previous hard-coded `Field(gt=0, le=10_000)` on +`exposure_time_ms` is now **gone**, deliberately. A global ceiling in the schema +is wrong once each device brings its own limits; a 10 s cap is nonsense for a +long-exposure astro sensor. + +## The larger question: limits and value in the same object? + +They have different lifecycles: + +| | Limits | Value | +| --- | --- | --- | +| Source | datasheet, or queried from the SDK | user, experiment, GUI | +| Changes | with the hardware model | every acquisition | +| Scope | shared by all units of that model | per unit, per run | + +Keeping them together (as above) gives locality — everything about exposure in +one place — and is the right call for a device *description*. If you later find +yourself diffing two setups' settings, or reusing one limits block across five +identical cameras, split them: + +```yaml +limits: # static, from the datasheet + exposure_time_ms: {min: 0.024, max: 10000.0, increment: 0.001} + gain_db: {min: 0.0, max: 23.98, increment: 0.01} + +settings: # runtime, per experiment + exposure_time_ms: 10.0 + gain_db: 0.0 +``` + +`Bounded` serves both layouts unchanged — in the split form you simply never +populate `value` in the `limits` block. + +## Static limits are a cache, not the truth + +For GenICam-based SDKs (HIKROBOT MVS, IDS peak, Basler pylon) min/max/increment +are **queryable at runtime and are dynamic**: the maximum exposure depends on +the current frame rate, the gain range depends on the pixel format, and enabling +an ROI shifts both. A number written into a YAML file is a snapshot of one +configuration. + +So treat the file limits as a fallback for offline work and UI slider bounds, +and re-query on connect: + +```python +node = nodemap.FindNode("ExposureTime") +live = Bounded(min=node.Minimum(), max=node.Maximum(), increment=node.Increment()) +if not live.supports(cam.exposure_time_ms.value): + log.warning("configured exposure not settable, snapping to %s", + live.clamp(cam.exposure_time_ms.value)) +``` + +That also gives you a cheap consistency check: if the queried limits disagree +with the file, either the file is stale or the wrong device is connected. diff --git a/backend/Configs/__note__.md b/backend/Configs/__note__.md new file mode 100644 index 0000000..a1af684 --- /dev/null +++ b/backend/Configs/__note__.md @@ -0,0 +1,4 @@ +KC 24.08.26: + +This is only temporarily! Once the deployment is set, the configs.py should be adapted in order to access the final file paths! + diff --git a/backend/Configs/data/Devices.modified.json b/backend/Configs/data/Devices.modified.json new file mode 100644 index 0000000..a20f564 --- /dev/null +++ b/backend/Configs/data/Devices.modified.json @@ -0,0 +1,149 @@ +{ + "version": 1, + "setup": "openUC2-bench-01", + "devices": [ + { + "type": "camera", + "name": "MV-CA023-10UM", + "manufacturer": "HIK ROBOTICS", + "camera_id": "hik-mv-ca023-001", + "pixelcount": { + "x": 1920, + "y": 1200 + }, + "pixelpitch_um": { + "x": 10.0, + "y": 10.0 + }, + "pixelsize_um": { + "x": 10.0, + "y": 10.0 + }, + "channels": 1, + "exposure_time_ms": { + "value": 25.0, + "min": 0.024, + "max": 10000.0, + "increment": 0.001 + }, + "framerate_per_sec": { + "value": 163.0, + "min": 0.5, + "max": 163.0 + }, + "gain_db": { + "value": 0.0, + "min": 0.0, + "max": 23.98, + "increment": 0.01 + }, + "binning": 1, + "trigger": { + "type": "hardware", + "edge": "rising" + }, + "tags": [ + "monochrome", + "global-shutter", + "gige" + ] + }, + { + "type": "camera", + "name": "U3-3800CP-C-HQ", + "manufacturer": "IDS Imaging", + "camera_id": "ids-u3-3800-002", + "pixelcount": { + "x": 3088, + "y": 2076 + }, + "pixelpitch_um": { + "x": 2.4, + "y": 2.4 + }, + "pixelsize_um": { + "x": 2.2, + "y": 2.2 + }, + "channels": 3, + "exposure_time_ms": { + "value": 20.0 + }, + "framerate_per_sec": { + "value": 30.0 + }, + "binning": 1, + "tags": [ + "color", + "rolling-shutter", + "usb3" + ] + }, + { + "name": "XY-Linear-100", + "manufacturer": "openUC2", + "device_id": "stage-xy-001", + "tags": [], + "type": "stage", + "axes": [ + { + "label": "x", + "travel_um": 100000.0, + "steps_per_um": 8.0, + "inverted": false + }, + { + "label": "y", + "travel_um": 100000.0, + "steps_per_um": 8.0, + "inverted": false + }, + { + "label": "z", + "travel_um": 10000.0, + "steps_per_um": 25.0, + "inverted": true + } + ], + "max_speed_um_per_sec": 5000.0, + "homing_required": true + }, + { + "name": "LD-488-100", + "manufacturer": "openUC2", + "device_id": "laser-488-001", + "tags": [], + "type": "laser", + "wavelength_nm": 488.0, + "max_power_mw": 100.0, + "modulation": "digital", + "shutter": false + }, + { + "name": "FW-6", + "manufacturer": "openUC2", + "device_id": "fw-001", + "tags": [], + "type": "filterwheel", + "slots": [ + { + "position": 0, + "label": "empty" + }, + { + "position": 1, + "label": "GFP-525/50", + "center_nm": 525.0, + "bandwidth_nm": 50.0 + }, + { + "position": 2, + "label": "mCherry-600/50", + "center_nm": 600.0, + "bandwidth_nm": 50.0 + } + ], + "switch_time_ms": 120.0 + } + ] +} diff --git a/backend/Configs/data/Devices.modified.yml b/backend/Configs/data/Devices.modified.yml new file mode 100644 index 0000000..666c29e --- /dev/null +++ b/backend/Configs/data/Devices.modified.yml @@ -0,0 +1,108 @@ +version: 1 +setup: openUC2-bench-01 +devices: +- type: camera + name: MV-CA023-10UM + manufacturer: HIK ROBOTICS + camera_id: hik-mv-ca023-001 + pixelcount: + x: 1920 + y: 1200 + pixelpitch_um: + x: 10.0 + y: 10.0 + pixelsize_um: + x: 10.0 + y: 10.0 + channels: 1 + exposure_time_ms: + value: 25.0 + min: 0.024 + max: 10000.0 + increment: 0.001 + framerate_per_sec: + value: 163.0 + min: 0.5 + max: 163.0 + gain_db: + value: 0.0 + min: 0.0 + max: 23.98 + increment: 0.01 + binning: 1 + trigger: + type: hardware + edge: rising + tags: + - monochrome + - global-shutter + - gige +- type: camera + name: U3-3800CP-C-HQ + manufacturer: IDS Imaging + camera_id: ids-u3-3800-002 + pixelcount: + x: 3088 + y: 2076 + pixelpitch_um: + x: 2.4 + y: 2.4 + pixelsize_um: + x: 2.2 + y: 2.2 + channels: 3 + exposure_time_ms: + value: 20.0 + framerate_per_sec: + value: 30.0 + binning: 1 + tags: + - color + - rolling-shutter + - usb3 +- name: XY-Linear-100 + manufacturer: openUC2 + device_id: stage-xy-001 + tags: [] + type: stage + axes: + - label: x + travel_um: 100000.0 + steps_per_um: 8.0 + inverted: false + - label: y + travel_um: 100000.0 + steps_per_um: 8.0 + inverted: false + - label: z + travel_um: 10000.0 + steps_per_um: 25.0 + inverted: true + max_speed_um_per_sec: 5000.0 + homing_required: true +- name: LD-488-100 + manufacturer: openUC2 + device_id: laser-488-001 + tags: [] + type: laser + wavelength_nm: 488.0 + max_power_mw: 100.0 + modulation: digital + shutter: false +- name: FW-6 + manufacturer: openUC2 + device_id: fw-001 + tags: [] + type: filterwheel + slots: + - position: 0 + label: empty + - position: 1 + label: GFP-525/50 + center_nm: 525.0 + bandwidth_nm: 50.0 + - position: 2 + label: mCherry-600/50 + center_nm: 600.0 + bandwidth_nm: 50.0 + switch_time_ms: 120.0 diff --git a/backend/Configs/data/hik_mv_ca023_10um.modified.yaml b/backend/Configs/data/hik_mv_ca023_10um.modified.yaml new file mode 100644 index 0000000..27f3e00 --- /dev/null +++ b/backend/Configs/data/hik_mv_ca023_10um.modified.yaml @@ -0,0 +1,36 @@ +type: camera +name: MV-CA023-10UM +manufacturer: HIK ROBOTICS +camera_id: hik-mv-ca023-001 +pixelcount: + x: 1920 + y: 1200 +pixelpitch_um: + x: 10.0 + y: 10.0 +pixelsize_um: + x: 10.0 + y: 10.0 +channels: 1 +exposure_time_ms: + value: 25.0 + min: 0.024 + max: 10000.0 + increment: 0.001 +framerate_per_sec: + value: 163.0 + min: 0.5 + max: 163.0 +gain_db: + value: 0.0 + min: 0.0 + max: 23.98 + increment: 0.01 +binning: 1 +trigger: + type: hardware + edge: rising +tags: +- monochrome +- global-shutter +- gige diff --git a/backend/Configs/hik_mv_ca023_10um.json b/backend/Configs/hik_mv_ca023_10um.json new file mode 100644 index 0000000..aced8e3 --- /dev/null +++ b/backend/Configs/hik_mv_ca023_10um.json @@ -0,0 +1,32 @@ +{ + "$schema": "./camera.schema.json", + "name": "MV-CA023-10UM", + "manufacturer": "HIK ROBOTICS", + "camera_id": "hik-mv-ca023-001", + "pixelcount": { + "x": 1920, + "y": 1200 + }, + "pixelpitch_um": { + "x": 10.0 + }, + "channels": 1, + "exposure_time_ms": { + "value": 10.0, + "min": 0.024, + "max": 10000.0, + "increment": 0.001 + }, + "framerate_per_sec": { + "value": 163.0, + "min": 0.5, + "max": 163.0 + }, + "gain_db": 0.0, + "binning": 1, + "trigger": { + "type": "hardware", + "edge": "rising" + }, + "tags": ["monochrome", "global-shutter", "gige"] +} diff --git a/backend/Configs/hik_mv_ca023_10um.yaml b/backend/Configs/hik_mv_ca023_10um.yaml new file mode 100644 index 0000000..cbdf8d8 --- /dev/null +++ b/backend/Configs/hik_mv_ca023_10um.yaml @@ -0,0 +1,45 @@ +# HIKROBOT MV-CA023-10UM - same content as the JSON variant +name: MV-CA023-10UM +manufacturer: HIK ROBOTICS +camera_id: hik-mv-ca023-001 + +pixelcount: + x: 1920 + y: 1200 + +pixelpitch_um: + x: 10.0 # y omitted -> derived as 10.0 (square pixels) + +channels: 1 + +# Expanded form: current value plus the limits the device reports. +exposure_time_ms: + value: 10.0 + min: 0.024 + max: 10000.0 + increment: 0.001 + +framerate_per_sec: + value: 163.0 + min: 0.5 + max: 163.0 + +gain_db: + value: 0.0 + min: 0.0 + max: 23.98 + increment: 0.01 + +# Shorthand form is still accepted where limits do not matter: +# exposure_time_ms: 10.0 + +binning: 1 + +trigger: + type: hardware + edge: rising + +tags: + - monochrome + - global-shutter + - gige diff --git a/backend/Configs/schemas/camera.schema.json b/backend/Configs/schemas/camera.schema.json new file mode 100644 index 0000000..4aa618c --- /dev/null +++ b/backend/Configs/schemas/camera.schema.json @@ -0,0 +1,376 @@ +{ + "$defs": { + "Bounded": { + "anyOf": [ + { + "description": "Shorthand for {value: }", + "type": "number" + }, + { + "additionalProperties": false, + "description": "A numeric parameter together with the device limits that apply to it.\n\nAll four members are optional, which covers every practical case:\n\n* ``{value: 10}`` -- setting, limits unknown\n* ``{min: 0.024, max: 10000}`` -- capability only, no default set\n* ``{value: 10, min: 0.024, max: 1e4}`` -- both\n* ``null`` / omitted -- parameter not applicable", + "properties": { + "value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Current or default value", + "title": "Value" + }, + "min": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Smallest settable value", + "title": "Min" + }, + "max": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Largest settable value", + "title": "Max" + }, + "increment": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Step size; settable values are min + n*increment", + "title": "Increment" + }, + "unit": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Override for the unit implied by the field name", + "title": "Unit" + } + }, + "title": "Bounded", + "type": "object" + } + ], + "description": "A numeric parameter together with the device limits that apply to it." + }, + "Trigger": { + "additionalProperties": false, + "description": "Trigger configuration. Omit the whole object for free-running mode.", + "properties": { + "type": { + "enum": [ + "software", + "hardware" + ], + "title": "Type", + "type": "string" + }, + "edge": { + "anyOf": [ + { + "enum": [ + "rising", + "falling" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Edge" + }, + "level": { + "anyOf": [ + { + "enum": [ + "high", + "low" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Level" + } + }, + "required": [ + "type" + ], + "title": "Trigger", + "type": "object" + }, + "Vector2Float": { + "additionalProperties": false, + "description": "Float pair where y is optional and falls back to x (square geometry).", + "properties": { + "x": { + "description": "Value along x in micrometers", + "exclusiveMinimum": 0, + "title": "X", + "type": "number" + }, + "y": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Value along y; defaults to x when omitted", + "title": "Y" + } + }, + "required": [ + "x" + ], + "title": "Vector2Float", + "type": "object" + }, + "Vector2Int": { + "additionalProperties": false, + "description": "Integer pair, both components required (e.g. pixel counts).", + "properties": { + "x": { + "description": "Extent along x in pixels", + "exclusiveMinimum": 0, + "title": "X", + "type": "integer" + }, + "y": { + "description": "Extent along y in pixels", + "exclusiveMinimum": 0, + "title": "Y", + "type": "integer" + } + }, + "required": [ + "x", + "y" + ], + "title": "Vector2Int", + "type": "object" + } + }, + "additionalProperties": false, + "description": "Full camera description.", + "properties": { + "$schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional pointer to the schema file, for editor autocomplete", + "title": "$Schema" + }, + "type": { + "const": "camera", + "default": "camera", + "description": "Discriminator used in multi-device files", + "title": "Type", + "type": "string" + }, + "name": { + "description": "Model designation, e.g. MV-CA023-10UM", + "title": "Name", + "type": "string" + }, + "manufacturer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Vendor name", + "title": "Manufacturer" + }, + "camera_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Unique identifier of the physical unit", + "title": "Camera Id" + }, + "pixelcount": { + "$ref": "#/$defs/Vector2Int", + "description": "Sensor resolution in pixels" + }, + "pixelpitch_um": { + "$ref": "#/$defs/Vector2Float", + "description": "Center-to-center pixel distance; y defaults to x" + }, + "pixelsize_um": { + "anyOf": [ + { + "$ref": "#/$defs/Vector2Float" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Photosensitive area per pixel; defaults to pixelpitch_um" + }, + "channels": { + "default": 1, + "description": "Color/spectral channels", + "minimum": 1, + "title": "Channels", + "type": "integer" + }, + "exposure_time_ms": { + "anyOf": [ + { + "$ref": "#/$defs/Bounded" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Integration time in ms, with device limits" + }, + "framerate_per_sec": { + "anyOf": [ + { + "$ref": "#/$defs/Bounded" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Frame rate in Hz, with device limits" + }, + "gain_db": { + "anyOf": [ + { + "$ref": "#/$defs/Bounded" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Analog gain in dB, with device limits" + }, + "binning": { + "default": 1, + "enum": [ + 1, + 2, + 4 + ], + "title": "Binning", + "type": "integer" + }, + "roi": { + "anyOf": [ + { + "maxItems": 4, + "minItems": 4, + "prefixItems": [ + { + "type": "integer" + }, + { + "type": "integer" + }, + { + "type": "integer" + }, + { + "type": "integer" + } + ], + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Region of interest as (x, y, width, height)", + "title": "Roi" + }, + "trigger": { + "anyOf": [ + { + "$ref": "#/$defs/Trigger" + }, + { + "type": "null" + } + ], + "default": null + }, + "tags": { + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + } + }, + "required": [ + "name", + "pixelcount", + "pixelpitch_um" + ], + "title": "CameraSchema", + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/backend/Configs/schemas/devices.schema.json b/backend/Configs/schemas/devices.schema.json new file mode 100644 index 0000000..dde721d --- /dev/null +++ b/backend/Configs/schemas/devices.schema.json @@ -0,0 +1,766 @@ +{ + "$defs": { + "Bounded": { + "anyOf": [ + { + "description": "Shorthand for {value: }", + "type": "number" + }, + { + "additionalProperties": false, + "description": "A numeric parameter together with the device limits that apply to it.\n\nAll four members are optional, which covers every practical case:\n\n* ``{value: 10}`` -- setting, limits unknown\n* ``{min: 0.024, max: 10000}`` -- capability only, no default set\n* ``{value: 10, min: 0.024, max: 1e4}`` -- both\n* ``null`` / omitted -- parameter not applicable", + "properties": { + "value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Current or default value", + "title": "Value" + }, + "min": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Smallest settable value", + "title": "Min" + }, + "max": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Largest settable value", + "title": "Max" + }, + "increment": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Step size; settable values are min + n*increment", + "title": "Increment" + }, + "unit": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Override for the unit implied by the field name", + "title": "Unit" + } + }, + "title": "Bounded", + "type": "object" + } + ], + "description": "A numeric parameter together with the device limits that apply to it." + }, + "CameraSchema": { + "additionalProperties": false, + "description": "Full camera description.", + "properties": { + "$schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional pointer to the schema file, for editor autocomplete", + "title": "$Schema" + }, + "type": { + "const": "camera", + "default": "camera", + "description": "Discriminator used in multi-device files", + "title": "Type", + "type": "string" + }, + "name": { + "description": "Model designation, e.g. MV-CA023-10UM", + "title": "Name", + "type": "string" + }, + "manufacturer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Vendor name", + "title": "Manufacturer" + }, + "camera_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Unique identifier of the physical unit", + "title": "Camera Id" + }, + "pixelcount": { + "$ref": "#/$defs/Vector2Int", + "description": "Sensor resolution in pixels" + }, + "pixelpitch_um": { + "$ref": "#/$defs/Vector2Float", + "description": "Center-to-center pixel distance; y defaults to x" + }, + "pixelsize_um": { + "anyOf": [ + { + "$ref": "#/$defs/Vector2Float" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Photosensitive area per pixel; defaults to pixelpitch_um" + }, + "channels": { + "default": 1, + "description": "Color/spectral channels", + "minimum": 1, + "title": "Channels", + "type": "integer" + }, + "exposure_time_ms": { + "anyOf": [ + { + "$ref": "#/$defs/Bounded" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Integration time in ms, with device limits" + }, + "framerate_per_sec": { + "anyOf": [ + { + "$ref": "#/$defs/Bounded" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Frame rate in Hz, with device limits" + }, + "gain_db": { + "anyOf": [ + { + "$ref": "#/$defs/Bounded" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Analog gain in dB, with device limits" + }, + "binning": { + "default": 1, + "enum": [ + 1, + 2, + 4 + ], + "title": "Binning", + "type": "integer" + }, + "roi": { + "anyOf": [ + { + "maxItems": 4, + "minItems": 4, + "prefixItems": [ + { + "type": "integer" + }, + { + "type": "integer" + }, + { + "type": "integer" + }, + { + "type": "integer" + } + ], + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Region of interest as (x, y, width, height)", + "title": "Roi" + }, + "trigger": { + "anyOf": [ + { + "$ref": "#/$defs/Trigger" + }, + { + "type": "null" + } + ], + "default": null + }, + "tags": { + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + } + }, + "required": [ + "name", + "pixelcount", + "pixelpitch_um" + ], + "title": "CameraSchema", + "type": "object" + }, + "FilterSlot": { + "additionalProperties": false, + "properties": { + "position": { + "description": "Slot index on the wheel", + "minimum": 0, + "title": "Position", + "type": "integer" + }, + "label": { + "description": "Filter designation, e.g. GFP-525/50", + "title": "Label", + "type": "string" + }, + "center_nm": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Center Nm" + }, + "bandwidth_nm": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Bandwidth Nm" + } + }, + "required": [ + "position", + "label" + ], + "title": "FilterSlot", + "type": "object" + }, + "FilterWheelDevice": { + "additionalProperties": false, + "description": "Motorized filter wheel.", + "properties": { + "name": { + "description": "Model designation", + "title": "Name", + "type": "string" + }, + "manufacturer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Vendor name", + "title": "Manufacturer" + }, + "device_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Identifier of the individual physical unit", + "title": "Device Id" + }, + "tags": { + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "type": { + "const": "filterwheel", + "title": "Type", + "type": "string" + }, + "slots": { + "items": { + "$ref": "#/$defs/FilterSlot" + }, + "minItems": 1, + "title": "Slots", + "type": "array" + }, + "switch_time_ms": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Switch Time Ms" + } + }, + "required": [ + "name", + "type", + "slots" + ], + "title": "FilterWheelDevice", + "type": "object" + }, + "LaserDevice": { + "additionalProperties": false, + "description": "Single-wavelength illumination source.", + "properties": { + "name": { + "description": "Model designation", + "title": "Name", + "type": "string" + }, + "manufacturer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Vendor name", + "title": "Manufacturer" + }, + "device_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Identifier of the individual physical unit", + "title": "Device Id" + }, + "tags": { + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "type": { + "const": "laser", + "title": "Type", + "type": "string" + }, + "wavelength_nm": { + "exclusiveMinimum": 0, + "maximum": 20000, + "title": "Wavelength Nm", + "type": "number" + }, + "max_power_mw": { + "exclusiveMinimum": 0, + "title": "Max Power Mw", + "type": "number" + }, + "modulation": { + "default": "none", + "enum": [ + "none", + "analog", + "digital" + ], + "title": "Modulation", + "type": "string" + }, + "shutter": { + "default": false, + "description": "Has a mechanical shutter", + "title": "Shutter", + "type": "boolean" + } + }, + "required": [ + "name", + "type", + "wavelength_nm", + "max_power_mw" + ], + "title": "LaserDevice", + "type": "object" + }, + "StageAxis": { + "additionalProperties": false, + "properties": { + "label": { + "enum": [ + "x", + "y", + "z", + "a", + "b", + "c" + ], + "title": "Label", + "type": "string" + }, + "travel_um": { + "description": "Usable travel range in micrometers", + "exclusiveMinimum": 0, + "title": "Travel Um", + "type": "number" + }, + "steps_per_um": { + "description": "Motor steps per micrometer", + "exclusiveMinimum": 0, + "title": "Steps Per Um", + "type": "number" + }, + "inverted": { + "default": false, + "description": "Flip the positive direction", + "title": "Inverted", + "type": "boolean" + } + }, + "required": [ + "label", + "travel_um", + "steps_per_um" + ], + "title": "StageAxis", + "type": "object" + }, + "StageDevice": { + "additionalProperties": false, + "description": "Motorized positioning stage.", + "properties": { + "name": { + "description": "Model designation", + "title": "Name", + "type": "string" + }, + "manufacturer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Vendor name", + "title": "Manufacturer" + }, + "device_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Identifier of the individual physical unit", + "title": "Device Id" + }, + "tags": { + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "type": { + "const": "stage", + "title": "Type", + "type": "string" + }, + "axes": { + "items": { + "$ref": "#/$defs/StageAxis" + }, + "minItems": 1, + "title": "Axes", + "type": "array" + }, + "max_speed_um_per_sec": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Speed Um Per Sec" + }, + "homing_required": { + "default": true, + "title": "Homing Required", + "type": "boolean" + } + }, + "required": [ + "name", + "type", + "axes" + ], + "title": "StageDevice", + "type": "object" + }, + "Trigger": { + "additionalProperties": false, + "description": "Trigger configuration. Omit the whole object for free-running mode.", + "properties": { + "type": { + "enum": [ + "software", + "hardware" + ], + "title": "Type", + "type": "string" + }, + "edge": { + "anyOf": [ + { + "enum": [ + "rising", + "falling" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Edge" + }, + "level": { + "anyOf": [ + { + "enum": [ + "high", + "low" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Level" + } + }, + "required": [ + "type" + ], + "title": "Trigger", + "type": "object" + }, + "Vector2Float": { + "additionalProperties": false, + "description": "Float pair where y is optional and falls back to x (square geometry).", + "properties": { + "x": { + "description": "Value along x in micrometers", + "exclusiveMinimum": 0, + "title": "X", + "type": "number" + }, + "y": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Value along y; defaults to x when omitted", + "title": "Y" + } + }, + "required": [ + "x" + ], + "title": "Vector2Float", + "type": "object" + }, + "Vector2Int": { + "additionalProperties": false, + "description": "Integer pair, both components required (e.g. pixel counts).", + "properties": { + "x": { + "description": "Extent along x in pixels", + "exclusiveMinimum": 0, + "title": "X", + "type": "integer" + }, + "y": { + "description": "Extent along y in pixels", + "exclusiveMinimum": 0, + "title": "Y", + "type": "integer" + } + }, + "required": [ + "x", + "y" + ], + "title": "Vector2Int", + "type": "object" + } + }, + "additionalProperties": false, + "description": "Top-level object of a Devices.yml file.", + "properties": { + "version": { + "default": 1, + "description": "Registry format version", + "minimum": 1, + "title": "Version", + "type": "integer" + }, + "setup": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Name of the setup", + "title": "Setup" + }, + "devices": { + "items": { + "discriminator": { + "mapping": { + "camera": "#/$defs/CameraSchema", + "filterwheel": "#/$defs/FilterWheelDevice", + "laser": "#/$defs/LaserDevice", + "stage": "#/$defs/StageDevice" + }, + "propertyName": "type" + }, + "oneOf": [ + { + "$ref": "#/$defs/CameraSchema" + }, + { + "$ref": "#/$defs/StageDevice" + }, + { + "$ref": "#/$defs/LaserDevice" + }, + { + "$ref": "#/$defs/FilterWheelDevice" + } + ] + }, + "title": "Devices", + "type": "array" + } + }, + "title": "DeviceRegistry", + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/backend/example_schemas.py b/backend/example_schemas.py new file mode 100644 index 0000000..0426739 --- /dev/null +++ b/backend/example_schemas.py @@ -0,0 +1,177 @@ +"""Worked example: importing, inspecting and storing device configs. + +Run it from `backend/`:: + + uv run python example_schemas.py + +It walks through the whole surface of `newswitch.schemas` against the sample files in +`Configs/`, and writes its output files to `Configs/data/`. Nothing here is a test — +it is meant to be read alongside its output. The unit tests live in `tests/`. +""" + +from __future__ import annotations + +from newswitch.config import get_paths +from newswitch.schemas import ( + CameraSchema, + ConfigError, + describe, + dump_config, + export_camera_schema, + export_devices_schema, + load_camera, + load_config, + load_device, + read_document, + validate_registry, +) + + +def section(title: str) -> None: + """Print a section header. + + Args: + title: Text of the header. + """ + print(f"\n=== {title} ===") + + +def show_folders() -> None: + """1. Where the schemas package looks for files.""" + section("1. folders (newswitch/config.py)") + paths = get_paths() + print(f"config_dir: {paths.config_dir}") + print(f"schema_dir: {paths.schema_dir}") + print(f"data_dir: {paths.data_dir}") + + +def load_same_camera_twice() -> None: + """2. The same camera from YAML and from JSON, plus the derived values.""" + section("2. one camera, two formats") + from_yaml = load_camera("hik_mv_ca023_10um.yaml") + from_json = load_camera("hik_mv_ca023_10um.json") + + # Same model class, same fields, one code path -- the parser is picked by suffix. + # The two sample files are not byte-equivalent though: the JSON one adds a + # `$schema` pointer for editor autocomplete and writes gain as the bare-number + # shorthand instead of the full {value, min, max, increment} block. + yaml_fields, json_fields = from_yaml.model_dump(), from_json.model_dump() + differing = sorted(k for k in yaml_fields if yaml_fields[k] != json_fields[k]) + print(f"fields that differ between the two files: {differing}") + print(f"gain_db yaml: {from_yaml.gain_db}") + print(f"gain_db json: {from_json.gain_db} <- scalar shorthand, same model") + print(f"name: {from_yaml.name} ({from_yaml.manufacturer})") + print(f"pixelpitch y: {from_yaml.pixelpitch_um.y} <- derived from x") + print(f"pixelsize: {from_yaml.pixelsize_um} <- defaults to the pitch") + print(f"sensor_size_mm: {from_yaml.sensor_size_mm}") + print(f"fill_factor: {from_yaml.fill_factor:.2f}") + + +def load_whole_setup() -> None: + """3. A multi-device file, and the registry lookups it enables.""" + section("3. a whole setup (Devices.yml)") + registry = load_config("Devices.yml") + print(describe(registry)) + cameras = registry.by_type("camera") + print(f"\ncameras: {[c.name for c in cameras]}") + + +def one_call_for_both_layouts() -> None: + """4./5. `load_device` reads single-device files and registries alike.""" + section("4. single-device file, name without extension") + # "hik_mv_ca023_10um" -> Configs/hik_mv_ca023_10um.yaml, and the file has no + # `type:` key at all -- a bare device document is read as a camera. + camera = load_device("hik_mv_ca023_10um", expect="camera") + print(f"{type(camera).__name__}: {camera.name}") + + section("5. one device out of a registry") + laser = load_device("Devices.yml", "laser-488-001") + print(f"{type(laser).__name__}: {laser.name} @ {laser.wavelength_nm:.0f} nm") + + stage = load_device("Devices.yml", "stage-xy-001") + print(f"{type(stage).__name__}: axes {'/'.join(a.label for a in stage.axes)}") + + # Asking for the wrong type, or for a device that is not there, is an error + # rather than a surprise later on. + for key, expect in (("laser-488-001", "camera"), ("nope-001", None)): + try: + load_device("Devices.yml", key, expect=expect) + except ConfigError as exc: + print(f"refused: {exc}") + + +def limits_of_a_parameter() -> None: + """6. `Bounded` carries the device's own limits, not just a value.""" + section("6. exposure limits (Bounded)") + # load_camera() is the camera-only entry point, so the result is a CameraSchema + # rather than the device union -- no narrowing needed. + exposure = load_camera("hik_mv_ca023_10um").exposure_time_ms + assert exposure is not None, "the sample file defines exposure limits" + print(f"exposure: {exposure}") + print(f"clamp(1e6): {exposure.clamp(1e6)} <- clamped to max") + print(f"clamp(10.00047): {exposure.clamp(10.00047)} <- snapped to the grid") + print(f"supports(10.0005): {exposure.supports(10.0005)} <- not on the grid") + print(f"supports(10.001): {exposure.supports(10.001)}") + + +def report_broken_file() -> None: + """7. What a broken file looks like: one message per problem.""" + section("7. error reporting (Devices_broken.yml)") + for error in validate_registry(read_document("Devices_broken.yml")): + print(f" - {error}") + + +def store_and_reload() -> None: + """8. Change something, store it in both formats, read it back.""" + section("8. storing") + registry = load_config("Devices.yml") + + # `devices` is a discriminated union, so narrow before touching camera fields -- + # a type checker then knows exactly which attributes exist. + camera = next(dev for dev in registry.devices if isinstance(dev, CameraSchema)) + assert camera.exposure_time_ms is not None, "the sample file defines exposure limits" + + # validate_assignment=True, so this is checked against the device's own limits. + camera.exposure_time_ms.value = 25.0 + + out_dir = get_paths().data_dir + for suffix in (".yml", ".json"): + target = dump_config(registry, out_dir / f"Devices.modified{suffix}") + reloaded = load_config(target) + same = reloaded == registry + print(f"wrote {target} -> reload matches: {same}") + + single = dump_config(camera, out_dir / "hik_mv_ca023_10um.modified.yaml") + print(f"wrote {single} -> exposure {load_camera(single).exposure_time_ms.value}") + + # Out-of-range assignments are refused. Note the caveat: Pydantic assigns first + # and validates after, so the object keeps the rejected value and has to be + # discarded (or reset) rather than reused -- see tests/test_bounded.py. + try: + camera.exposure_time_ms.value = 99_999.0 + except ValueError as exc: + print(f"refused: {str(exc).splitlines()[1].strip()}") + print(f"but the model now holds: {camera.exposure_time_ms.value} -> reload before reuse") + + +def refresh_exported_schemas() -> None: + """9. Regenerate the JSON Schemas other tools and editors consume.""" + section("9. exported JSON Schemas") + print(f"wrote {export_camera_schema()}") + print(f"wrote {export_devices_schema()}") + + +def main() -> None: + """Run every step of the example.""" + show_folders() + load_same_camera_twice() + load_whole_setup() + one_call_for_both_layouts() + limits_of_a_parameter() + report_broken_file() + store_and_reload() + refresh_exported_schemas() + + +if __name__ == "__main__": + main() diff --git a/backend/newswitch/config.py b/backend/newswitch/config.py new file mode 100644 index 0000000..f32cf5d --- /dev/null +++ b/backend/newswitch/config.py @@ -0,0 +1,191 @@ +""" +General configuration for newswitch, including paths to config, data, and log directories. + +For now (24.08.2026) simply hard coded paths. + +Todo: -> below +""" + +from __future__ import annotations + +import os +from functools import lru_cache +from pathlib import Path + +from platformdirs import user_config_path, user_data_path, user_log_path +from pydantic_settings import BaseSettings, SettingsConfigDict + +APP_NAME = "newswitch" + +# Extensions understood by the config loaders, in the order they are tried when a +# name is given without one. +CONFIG_SUFFIXES = (".yaml", ".yml", ".json") + + +def _systemd_dir(env_var: str) -> Path | None: + # systemd may pass a colon-separated list; the first entry is ours. + raw = os.environ.get(env_var) + return Path(raw.split(":")[0]) if raw else None + + +def _resolve(name: Path, folder: Path, *, must_exist: bool) -> Path: + """Resolve a bare file name against `folder`; pass explicit paths through. + + A name that is absolute or carries a directory part is taken as-is. A bare name + keeps its suffix if it has one, otherwise every entry of `CONFIG_SUFFIXES` is + tried in order and the first existing candidate wins. + + Args: + name: The name or path to resolve. + folder: Managed directory a bare name is resolved against. + must_exist: Raise instead of returning a path that does not exist. + + Returns: + The resolved path. + + Raises: + FileNotFoundError: `must_exist` is True and no candidate exists. + """ + if name.is_absolute() or name.parent != Path("."): + candidates = [name] + elif name.suffix.lower() in CONFIG_SUFFIXES: + candidates = [folder / name] + else: + candidates = [folder / f"{name}{suffix}" for suffix in CONFIG_SUFFIXES] + + for candidate in candidates: + if candidate.is_file(): + return candidate + + if must_exist: + tried = ", ".join(str(c) for c in candidates) + raise FileNotFoundError(f"No config file for {str(name)!r}; tried: {tried}") + + # No match: hand back the first candidate as the canonical target to write to. + return candidates[0] + + +class Paths(BaseSettings): + """Where newswitch keeps configs, schemas, data and logs. + + Every field can be overridden with its own environment variable + (``NEWSWITCH_CONFIG_DIR``, ``NEWSWITCH_SCHEMA_DIR``, ...). The four are + independent, so redirecting `config_dir` alone does not move the others. + """ + + # this is same like model_config = {"env_prefix": "NEWSWITCH_", "env_file": ".env"}, but with type checking and autocompletion + # extends BaseSettings to allow for environment variable overrides and .env file loading + model_config = SettingsConfigDict(env_prefix="NEWSWITCH_", env_file=".env") + + # config_dir: Path = _systemd_dir("CONFIGURATION_DIRECTORY") or user_config_path(APP_NAME) + # data_dir: Path = _systemd_dir("STATE_DIRECTORY") or user_data_path(APP_NAME) + # log_dir: Path = _systemd_dir("LOGS_DIRECTORY") or user_log_path(APP_NAME) + config_dir: Path = Path(__file__).parent.parent / "Configs" + data_dir: Path = Path(__file__).parent.parent / "Configs" / "data" + log_dir: Path = Path(__file__).parent.parent / "Configs" / "logs" + schema_dir: Path = Path(__file__).parent.parent / "Configs" / "schemas" + + @property + def device_settings_file(self) -> Path: + """Path of the UC2 device settings file inside the config directory.""" + return self.config_dir / "uc2DevSettings.json" + + def config_file(self, name: str | Path, *, must_exist: bool = True) -> Path: + """Resolve a device/config file name against `config_dir`. + + An explicit path (absolute, or carrying a directory part such as + ``./cams/hik.yaml``) is returned unchanged, so callers can always bypass the + managed folder. A bare name is looked up inside `config_dir`: + + * with a known suffix -> ``config_dir / name`` + * without a suffix -> the first of .yaml/.yml/.json that exists + + Args: + name: Bare file name, name without suffix, or an explicit path. + must_exist: Raise `FileNotFoundError` when nothing was found. Pass False + when resolving a *target* to write to, which does not exist yet. + + Returns: + The resolved path. + + Raises: + FileNotFoundError: `must_exist` is True and no candidate exists. + """ + return _resolve(Path(name), self.config_dir, must_exist=must_exist) + + def schema_file(self, name: str | Path, *, must_exist: bool = False) -> Path: + """Resolve a JSON Schema file name against `schema_dir`. + + Same rules as `config_file`, but rooted at `schema_dir` and defaulting to + `must_exist=False`, because its main use is naming an export target. + + Args: + name: Bare file name, name without suffix, or an explicit path. + must_exist: Raise `FileNotFoundError` when nothing was found. + + Returns: + The resolved path. + + Raises: + FileNotFoundError: `must_exist` is True and no candidate exists. + """ + return _resolve(Path(name), self.schema_dir, must_exist=must_exist) + + def ensure_writable_dirs(self) -> None: + """Ensure that the data and log directories exist and are writable. Not the log directory, which should be created by the deployment.""" + for path in (self.data_dir, self.log_dir, self.schema_dir): + try: + path.mkdir(parents=True, exist_ok=True) + except PermissionError as exc: + raise RuntimeError( + f"Cannot create {path}. Either run under systemd with " + f"StateDirectory=/LogsDirectory=, or override via " + f"NEWSWITCH_DATA_DIR / NEWSWITCH_LOG_DIR." + ) from exc + if not os.access(path, os.W_OK): + raise RuntimeError(f"{path} exists but is not writable by this user.") + + +@lru_cache(maxsize=1) +def get_paths() -> Paths: + """Return the shared `Paths` instance, reading the environment once. + + Returns: + The cached settings object. Call ``get_paths.cache_clear()`` after changing + a NEWSWITCH_* variable at runtime, as the tests do. + """ + return Paths() + + +""" + + + - To be copied to deplay/newswtich.service: + - than 'sudo systemctl daemon-reload && sudo systemctl enable --now newswitch' + +[Unit] +Description=newswitch microscope control backend +After=network-online.target +Wants=network-online.target + +[Service] +Type=exec +User=newswitch +Group=newswitch + +ConfigurationDirectory=newswitch +StateDirectory=newswitch +LogsDirectory=newswitch + +WorkingDirectory=/opt/newswitch +ExecStart=/opt/newswitch/.venv/bin/python -m newswitch.main +Restart=on-failure +RestartSec=5 + +# Camera/serial access without root: +SupplementaryGroups=video dialout + +[Install] +WantedBy=multi-user.target + +""" diff --git a/backend/newswitch/schemas/__init__.py b/backend/newswitch/schemas/__init__.py new file mode 100644 index 0000000..689f8a5 --- /dev/null +++ b/backend/newswitch/schemas/__init__.py @@ -0,0 +1,98 @@ +"""Schema-checked import and export of device configurations (JSON or YAML). + +Layout +------ +* `bounded` -- `Bounded`: a numeric parameter carrying its own min/max/increment +* `camera_schema` -- `CameraSchema` and its sub-models +* `device_schema` -- the other device types, the discriminated union, `DeviceRegistry` +* `document` -- file parsing/writing and path resolution via `newswitch.config` +* `camera_io` -- camera-only loader, writer and schema exporter +* `device_io` -- registry loader, per-type validator, writer, schema exporter +* `loader` -- `load_config` / `load_device`: one entry point for every layout + +Typical use:: + + from newswitch.schemas import load_config, load_device, dump_config + + registry = load_config("Devices.yml") # 5 devices + registry = load_config("hik_mv_ca023_10um") # single-device file, 1 entry + cam = load_device("Devices.yml", "hik-mv-ca023-001", expect="camera") + dump_config(registry, "data/Devices.backup.yml") + +Bare names are resolved against the managed config directory (see +`newswitch.config.Paths`), explicit paths are used as given. Every loader raises +`ConfigError`; `CameraConfigError` and `DeviceConfigError` are subclasses of it. +""" + +from .bounded import Bounded +from .camera_io import ( + CameraConfigError, + dump_camera, + export_json_schema as export_camera_schema, + load_camera, + schema_errors, +) +from .camera_schema import CameraSchema, Trigger, Vector2Float, Vector2Int +from .device_io import ( + DeviceConfigError, + describe, + dump_devices, + export_json_schema as export_devices_schema, + load_devices, + validate_registry, +) +from .device_schema import ( + DEVICE_MODELS, + Device, + DeviceBase, + DeviceModel, + DeviceRegistry, + FilterWheelDevice, + LaserDevice, + StageAxis, + StageDevice, + device_key, +) +from .document import read_document, write_document +from .errors import ConfigError +from .loader import dump_config, load_config, load_device + +__all__ = [ + # models + "Bounded", + "CameraSchema", + "Trigger", + "Vector2Int", + "Vector2Float", + "DeviceBase", + "StageAxis", + "StageDevice", + "LaserDevice", + "FilterWheelDevice", + "Device", + "DeviceModel", + "DeviceRegistry", + "DEVICE_MODELS", + "device_key", + # one entry point for any layout + "load_config", + "load_device", + "dump_config", + # type-specific entry points + "load_camera", + "dump_camera", + "schema_errors", + "load_devices", + "dump_devices", + "validate_registry", + "describe", + # documents and schema export + "read_document", + "write_document", + "export_camera_schema", + "export_devices_schema", + # errors + "ConfigError", + "CameraConfigError", + "DeviceConfigError", +] diff --git a/backend/newswitch/schemas/__main__.py b/backend/newswitch/schemas/__main__.py new file mode 100644 index 0000000..31a3125 --- /dev/null +++ b/backend/newswitch/schemas/__main__.py @@ -0,0 +1,40 @@ +"""Inspect config files from the shell. + + python -m newswitch.schemas # the default Devices.yml + python -m newswitch.schemas Devices.yml hik_mv_ca023_10um + python -m newswitch.schemas /path/to/Devices.json + +Every argument is a bare config name (resolved against the managed config directory) +or an explicit path, and may hold a single device or a whole registry. +""" + +from __future__ import annotations + +import sys + +from .device_io import describe +from .errors import ConfigError +from .loader import load_config + + +def main(args: list[str]) -> int: + """Print one description block per file. + + Args: + args: File names or paths; defaults to ``["Devices.yml"]``. + + Returns: + Process exit code: 0 if every file validated, 1 otherwise. + """ + failed = False + for arg in args or ["Devices.yml"]: + try: + print(describe(load_config(arg))) + except ConfigError as exc: + failed = True + print(f"[FAIL] {exc}") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/backend/newswitch/schemas/bounded.py b/backend/newswitch/schemas/bounded.py new file mode 100644 index 0000000..14fcd43 --- /dev/null +++ b/backend/newswitch/schemas/bounded.py @@ -0,0 +1,130 @@ +"""A parameter that carries its own limits. + +Design decision +--------------- +Limits are modelled as a **nested object**, not as sibling tags: + + exposure_time_ms: + value: 10.0 + min: 0.024 + max: 10000.0 + increment: 0.001 + +rather than:: + + exposure_time_ms: 10.0 + exposure_time_ms_min: 0.024 + exposure_time_ms_max: 10000.0 + +The nested form is one reusable model instead of three flat fields per +parameter, so the "value must lie between min and max" rule is written once +and applies everywhere. + +To keep the common case short, a **bare scalar is accepted as shorthand** for +``{"value": }``. Both of these are valid and equivalent: + + exposure_time_ms: 10.0 + exposure_time_ms: {value: 10.0} + +Note what JSON Schema can and cannot do here. ``minimum``/``maximum`` in the +schema are *global* constants — they cannot refer to another field of the same +document. "value within this device's own min/max" is therefore a cross-field +rule and lives in Pydantic only. +""" + +from __future__ import annotations + +import math +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class Bounded(BaseModel): + """A numeric parameter together with the device limits that apply to it. + + All four members are optional, which covers every practical case: + + * ``{value: 10}`` -- setting, limits unknown + * ``{min: 0.024, max: 10000}`` -- capability only, no default set + * ``{value: 10, min: 0.024, max: 1e4}`` -- both + * ``null`` / omitted -- parameter not applicable + """ + + model_config = ConfigDict(extra="forbid", validate_assignment=True) + + value: Optional[float] = Field(default=None, description="Current or default value") + min: Optional[float] = Field(default=None, description="Smallest settable value") + max: Optional[float] = Field(default=None, description="Largest settable value") + increment: Optional[float] = Field( + default=None, gt=0, description="Step size; settable values are min + n*increment" + ) + unit: Optional[str] = Field( + default=None, description="Override for the unit implied by the field name" + ) + + @model_validator(mode="before") + @classmethod + def _accept_scalar(cls, data: Any) -> Any: + """Allow ``exposure_time_ms: 10.0`` as shorthand for ``{value: 10.0}``.""" + if isinstance(data, (int, float)) and not isinstance(data, bool): + return {"value": float(data)} + return data + + @model_validator(mode="after") + def _check_consistency(self) -> "Bounded": + if self.min is not None and self.max is not None and self.min > self.max: + raise ValueError(f"min ({self.min}) must not exceed max ({self.max})") + + if self.value is not None: + if self.min is not None and self.value < self.min: + raise ValueError(f"value {self.value} below min {self.min}") + if self.max is not None and self.value > self.max: + raise ValueError(f"value {self.value} above max {self.max}") + + if self.increment is not None: + base = self.min if self.min is not None else 0.0 + steps = (self.value - base) / self.increment + if not math.isclose(steps, round(steps), abs_tol=1e-9): + raise ValueError( + f"value {self.value} is not on the {self.increment} grid starting at {base}" + ) + return self + + @classmethod + def __get_pydantic_json_schema__(cls, core_schema, handler): # noqa: ANN001 + """Emit ``anyOf: [number, object]`` so the shorthand validates too.""" + schema = handler(core_schema) + schema = handler.resolve_ref_schema(schema) + return { + "anyOf": [ + {"type": "number", "description": "Shorthand for {value: }"}, + dict(schema), + ], + "description": cls.__doc__.splitlines()[0] if cls.__doc__ else None, + } + + def clamp(self, value: float) -> float: + """Clamp to [min, max] and snap onto the increment grid.""" + base = self.min if self.min is not None else 0.0 + if self.min is not None: + value = max(value, self.min) + if self.max is not None: + value = min(value, self.max) + if self.increment is not None: + value = base + round((value - base) / self.increment) * self.increment + if self.max is not None and value > self.max: + value -= self.increment + return value + + def supports(self, value: float) -> bool: + """True if `value` can be set exactly.""" + try: + Bounded(value=value, min=self.min, max=self.max, increment=self.increment) + except ValueError: + return False + return True + + def __repr__(self) -> str: + parts = [f"{k}={v}" for k, v in self.model_dump(exclude_none=True).items()] + return f"Bounded({', '.join(parts)})" diff --git a/backend/newswitch/schemas/camera_io.py b/backend/newswitch/schemas/camera_io.py new file mode 100644 index 0000000..0213842 --- /dev/null +++ b/backend/newswitch/schemas/camera_io.py @@ -0,0 +1,148 @@ +"""Load and validate camera descriptions from JSON or YAML. + +Two validation layers are available: + +1. ``jsonschema`` against the exported Draft 2020-12 schema -> reports *all* + structural errors at once, without constructing a model. +2. ``pydantic`` -> coerces types, applies defaults and runs the derivation + rules (pixelpitch y -> x, pixelsize -> pixelpitch). + +YAML and JSON share the same code path: both parsers produce plain Python +dicts, and validation happens on the dict. Parsing and path resolution live in +`document.py`, so a bare name such as ``"hik_mv_ca023_10um"`` is looked up in the +managed config directory while an explicit path is used as given. + +This module is the *camera-only* entry point and rejects anything else. Use +`loader.load_config` when the file may hold a whole device registry. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Optional + +from jsonschema import Draft202012Validator +from pydantic import ValidationError + +from ..config import get_paths +from .camera_schema import CameraSchema +from .document import read_document, write_document +from .errors import ConfigError + +CAMERA_VALIDATOR = Draft202012Validator(CameraSchema.model_json_schema()) + + +class CameraConfigError(ConfigError): + """Raised when a camera description does not satisfy the schema.""" + + +def load_raw(path: str | Path) -> dict[str, Any]: + """Parse a .json, .yaml or .yml file into a plain dict. + + Args: + path: Bare config name or an explicit path. + + Returns: + The parsed document. + + Raises: + CameraConfigError: File missing/unparseable, or the top level is not a mapping. + """ + try: + data = read_document(path) + except ConfigError as exc: + raise CameraConfigError(str(exc)) from exc + + if not isinstance(data, dict): + raise CameraConfigError(f"{Path(path).name}: expected a mapping at the top level") + return data + + +def schema_errors(data: dict[str, Any]) -> list[str]: + """Return all JSON Schema violations as readable strings (empty = valid).""" + return [ + f"{'.'.join(str(p) for p in err.absolute_path) or ''}: {err.message}" + for err in sorted(CAMERA_VALIDATOR.iter_errors(data), key=str) + ] + + +def load_camera(path: str | Path, *, strict_schema: bool = True) -> CameraSchema: + """Load a camera description and return a validated model. + + Parameters + ---------- + path + Path to a .json, .yaml or .yml file. + strict_schema + Run the jsonschema pass first, so that *all* structural problems are + reported together instead of only the first Pydantic failure. + """ + data = load_raw(path) + + if strict_schema: + errors = schema_errors(data) + if errors: + joined = "\n - ".join(errors) + raise CameraConfigError(f"{Path(path).name} failed validation:\n - {joined}") + + try: + return CameraSchema.model_validate(data) + except ValidationError as exc: + raise CameraConfigError(f"{Path(path).name}: {exc}") from exc + + +def dump_camera(camera: CameraSchema, path: str | Path) -> Path: + """Write a validated camera back to .json or .yaml. + + Args: + camera: The validated model to store. + path: Bare name (stored in the config dir) or an explicit path. + + Returns: + The path written to. + + Raises: + CameraConfigError: Unsupported file type. + """ + payload = camera.model_dump(mode="json", by_alias=True, exclude_none=True) + try: + return write_document(payload, path) + except ConfigError as exc: + raise CameraConfigError(str(exc)) from exc + + +def export_json_schema(path: Optional[str | Path] = None) -> Path: + """Write the JSON Schema itself to disk (for editors, docs, other langs). + + Args: + path: Target file. Defaults to ``camera.schema.json`` in the managed + schema directory (see `newswitch.config.Paths.schema_dir`). + + Returns: + The path written to. + """ + target = get_paths().schema_file("camera.schema.json") if path is None else Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + schema = CameraSchema.model_json_schema() + schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" + target.write_text(json.dumps(schema, indent=2) + "\n", encoding="utf-8") + return target + + +if __name__ == "__main__": + import sys + + for arg in sys.argv[1:] or ["hik_mv_ca023_10um.json", "hik_mv_ca023_10um.yaml"]: + try: + cam = load_camera(arg) + except CameraConfigError as exc: + print(f"[FAIL] {exc}") + continue + w, h = cam.sensor_size_mm + print( + f"[ OK ] {arg}: {cam.manufacturer} {cam.name} | " + f"{cam.pixelcount.x}x{cam.pixelcount.y} px | " + f"pitch {cam.pixelpitch_um.x}/{cam.pixelpitch_um.y} um | " + f"sensor {w:.2f}x{h:.2f} mm | fill {cam.fill_factor:.2f}" + ) diff --git a/backend/newswitch/schemas/camera_schema.py b/backend/newswitch/schemas/camera_schema.py new file mode 100644 index 0000000..7e4ff1f --- /dev/null +++ b/backend/newswitch/schemas/camera_schema.py @@ -0,0 +1,168 @@ +"""Camera description schema. + +Defines the geometry, identity and acquisition parameters of a camera as a +Pydantic model. `CameraSchema.model_json_schema()` exports a Draft 2020-12 +JSON Schema that can validate raw dicts loaded from JSON or YAML. + +Field conventions +----------------- +* A field WITHOUT a default is required. +* A field WITH a default (including ``None``) is optional. +* Some optional fields are *derived* by model validators when omitted + (pixelpitch y -> x, pixelsize -> pixelpitch). Derivation happens in + Pydantic, not in the exported JSON Schema. +""" + +from __future__ import annotations + +from typing import Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from .bounded import Bounded + +# import numpy as np # noqa: ERA001 -- needed for quantum_efficiency, see below + + +class Vector2Int(BaseModel): + """Integer pair, both components required (e.g. pixel counts).""" + + model_config = ConfigDict(extra="forbid") + + x: int = Field(gt=0, description="Extent along x in pixels") + y: int = Field(gt=0, description="Extent along y in pixels") + + +class Vector2Float(BaseModel): + """Float pair where y is optional and falls back to x (square geometry).""" + + model_config = ConfigDict(extra="forbid") + + x: float = Field(gt=0, description="Value along x in micrometers") + y: Optional[float] = Field( + default=None, gt=0, description="Value along y; defaults to x when omitted" + ) + + @model_validator(mode="after") + def _fill_y(self) -> "Vector2Float": + if self.y is None: + self.y = self.x + return self + + +class Trigger(BaseModel): + """Trigger configuration. Omit the whole object for free-running mode.""" + + model_config = ConfigDict(extra="forbid") + + type: Literal["software", "hardware"] + edge: Optional[Literal["rising", "falling"]] = None + level: Optional[Literal["high", "low"]] = None + + @model_validator(mode="after") + def _check_software(self) -> "Trigger": + if self.type == "software" and (self.edge or self.level): + raise ValueError("edge/level are only meaningful for hardware triggers") + return self + + +class CameraSchema(BaseModel): + """Full camera description.""" + + model_config = ConfigDict(extra="forbid", validate_assignment=True, populate_by_name=True) + + # --- tooling -------------------------------------------------------- + schema_ref: Optional[str] = Field( + default=None, + alias="$schema", + serialization_alias="$schema", + description="Optional pointer to the schema file, for editor autocomplete", + ) + + # --- identity ------------------------------------------------------- + type: Literal["camera"] = Field( + default="camera", description="Discriminator used in multi-device files" + ) + name: str = Field(description="Model designation, e.g. MV-CA023-10UM") + manufacturer: Optional[str] = Field(default=None, description="Vendor name") + camera_id: Optional[str] = Field( + default=None, description="Unique identifier of the physical unit" + ) + + # --- sensor geometry ------------------------------------------------ + pixelcount: Vector2Int = Field(description="Sensor resolution in pixels") + pixelpitch_um: Vector2Float = Field( + description="Center-to-center pixel distance; y defaults to x" + ) + pixelsize_um: Optional[Vector2Float] = Field( + default=None, + description="Photosensitive area per pixel; defaults to pixelpitch_um", + ) + channels: int = Field(default=1, ge=1, description="Color/spectral channels") + + # --- acquisition ---------------------------------------------------- + # Bounded = value + the device's own min/max/increment. A bare number is + # accepted as shorthand for {"value": }; see bounded.py. + exposure_time_ms: Optional[Bounded] = Field( + default=None, description="Integration time in ms, with device limits" + ) + framerate_per_sec: Optional[Bounded] = Field( + default=None, description="Frame rate in Hz, with device limits" + ) + gain_db: Optional[Bounded] = Field( + default=None, description="Analog gain in dB, with device limits" + ) + binning: Literal[1, 2, 4] = 1 + roi: Optional[tuple[int, int, int, int]] = Field( + default=None, description="Region of interest as (x, y, width, height)" + ) + trigger: Optional[Trigger] = None + tags: list[str] = Field(default_factory=list) + + # --- spectral response ---------------------------------------------- + # quantum_efficiency: Optional[np.ndarray] = None + # + # Enabling this requires ConfigDict(arbitrary_types_allowed=True) and a + # custom serializer, and the field cannot be expressed in JSON Schema. + # Preferred JSON-native alternative: + # qe_wavelength_nm: Optional[list[float]] = None + # qe_values: Optional[list[float]] = None # 0.0 .. 1.0, same length + + @model_validator(mode="after") + def _derive_pixelsize(self) -> "CameraSchema": + if self.pixelsize_um is None: + self.pixelsize_um = Vector2Float(x=self.pixelpitch_um.x, y=self.pixelpitch_um.y) + return self + + @model_validator(mode="after") + def _check_roi(self) -> "CameraSchema": + if self.roi is not None: + x, y, w, h = self.roi + if w <= 0 or h <= 0: + raise ValueError("roi width and height must be positive") + if x < 0 or y < 0: + raise ValueError("roi origin must be non-negative") + if x + w > self.pixelcount.x or y + h > self.pixelcount.y: + raise ValueError("roi exceeds sensor pixelcount") + return self + + # --- convenience ---------------------------------------------------- + @property + def sensor_size_mm(self) -> tuple[float, float]: + """Physical sensor extent (width, height) in millimeters.""" + return ( + self.pixelcount.x * self.pixelpitch_um.x / 1000.0, + self.pixelcount.y * self.pixelpitch_um.y / 1000.0, + ) + + @property + def fill_factor(self) -> float: + """Photosensitive area fraction per pixel (0.0 .. 1.0).""" + ps, pp = self.pixelsize_um, self.pixelpitch_um + return (ps.x * ps.y) / (pp.x * pp.y) + + +if __name__ == "__main__": + import json + + print(json.dumps(CameraSchema.model_json_schema(), indent=2)) diff --git a/backend/newswitch/schemas/device_io.py b/backend/newswitch/schemas/device_io.py new file mode 100644 index 0000000..458605c --- /dev/null +++ b/backend/newswitch/schemas/device_io.py @@ -0,0 +1,186 @@ +"""Load and validate a multi-device file (Devices.yml / Devices.json). + +Why not just point jsonschema at the whole file +----------------------------------------------- +A list of mixed types becomes ``oneOf`` in JSON Schema. A plain validator then +reports every branch it tried, so one typo in a camera entry produces four +error blocks ending in "is not valid under any of the given schemas". + +`validate_registry()` avoids that by reading the ``type`` field first and +validating each entry against **only** the matching sub-schema. Errors come +back as `devices[2].pixelcount.x: -5 is less than or equal to 0`. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Optional + +from jsonschema import Draft202012Validator +from pydantic import ValidationError + +from ..config import get_paths +from .device_schema import DEVICE_MODELS, DeviceRegistry, device_key +from .document import read_document, write_document +from .errors import ConfigError + + +class DeviceConfigError(ConfigError): + """Raised when a device file does not satisfy the schema.""" + + +# One self-contained validator per device type. +TYPE_VALIDATORS: dict[str, Draft202012Validator] = { + name: Draft202012Validator(model.model_json_schema()) for name, model in DEVICE_MODELS.items() +} + + +def load_raw(path: str | Path) -> Any: # noqa: ANN401 - a parsed document is Any + """Parse .json, .yaml or .yml into plain Python objects. + + Args: + path: Bare config name or an explicit path. + + Returns: + The parsed document. + + Raises: + DeviceConfigError: File missing, unsupported suffix, or unparseable. + """ + try: + return read_document(path) + except ConfigError as exc: + raise DeviceConfigError(str(exc)) from exc + + +def validate_registry(data: Any) -> list[str]: + """Return all schema violations as readable strings (empty list = valid). + + Each device is checked against the schema of its own ``type`` only. + """ + errors: list[str] = [] + + if not isinstance(data, dict): + return [": expected a mapping with a 'devices' key"] + + if "devices" not in data: + hint = "" + if "name" in data or "type" in data: + hint = ( + " -- this looks like a single-device file; wrap it as " + "'devices: [ ... ]' or load it with camera_io.load_camera()" + ) + return [f": 'devices' is a required property{hint}"] + + unknown = set(data) - set(DeviceRegistry.model_fields) + for key in sorted(unknown): + errors.append(f": unknown key {key!r}") + + devices = data.get("devices") + if not isinstance(devices, list): + return errors + ["devices: expected a list of device objects"] + + for i, entry in enumerate(devices): + where = f"devices[{i}]" + + if not isinstance(entry, dict): + errors.append(f"{where}: expected an object, got {type(entry).__name__}") + continue + + type_ = entry.get("type") + if type_ is None: + errors.append(f"{where}: missing 'type' discriminator") + continue + if type_ not in TYPE_VALIDATORS: + known = ", ".join(sorted(TYPE_VALIDATORS)) + errors.append(f"{where}: unknown type {type_!r} (known: {known})") + continue + + for err in sorted(TYPE_VALIDATORS[type_].iter_errors(entry), key=str): + loc = ".".join(str(p) for p in err.absolute_path) + errors.append(f"{where}{'.' + loc if loc else ''}: {err.message}") + + return errors + + +def load_devices(path: str | Path, *, strict_schema: bool = True) -> DeviceRegistry: + """Load a device file and return a validated registry.""" + data = load_raw(path) + + if strict_schema: + errors = validate_registry(data) + if errors: + joined = "\n - ".join(errors) + raise DeviceConfigError(f"{Path(path).name} failed validation:\n - {joined}") + + try: + return DeviceRegistry.model_validate(data) + except ValidationError as exc: + raise DeviceConfigError(f"{Path(path).name}: {exc}") from exc + + +def dump_devices(registry: DeviceRegistry, path: str | Path) -> Path: + """Write a registry back to .json or .yaml. + + Args: + registry: The validated registry to store. + path: Bare name (stored in the config dir) or an explicit path. + + Returns: + The path written to. + + Raises: + DeviceConfigError: Unsupported file type. + """ + payload = registry.model_dump(mode="json", by_alias=True, exclude_none=True) + try: + return write_document(payload, path) + except ConfigError as exc: + raise DeviceConfigError(str(exc)) from exc + + +def export_json_schema(path: Optional[str | Path] = None) -> Path: + """Write the registry JSON Schema to disk (editors, docs, other languages). + + Args: + path: Target file. Defaults to ``devices.schema.json`` in the managed + schema directory (see `newswitch.config.Paths.schema_dir`). + + Returns: + The path written to. + """ + target = get_paths().schema_file("devices.schema.json") if path is None else Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + schema = DeviceRegistry.model_json_schema() + schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" + target.write_text(json.dumps(schema, indent=2) + "\n", encoding="utf-8") + return target + + +def describe(registry: DeviceRegistry) -> str: + """One human-readable line per device.""" + lines = [f"setup: {registry.setup or '-'} (v{registry.version})"] + for dev in registry.devices: + detail = "" + if dev.type == "camera": + w, h = dev.sensor_size_mm + detail = f"{dev.pixelcount.x}x{dev.pixelcount.y} px, {w:.1f}x{h:.1f} mm" + elif dev.type == "stage": + detail = "axes " + "/".join(a.label for a in dev.axes) + elif dev.type == "laser": + detail = f"{dev.wavelength_nm:.0f} nm, {dev.max_power_mw:.0f} mW" + elif dev.type == "filterwheel": + detail = f"{len(dev.slots)} slots" + lines.append(f" [{dev.type:<11}] {device_key(dev):<22} {detail}") + return "\n".join(lines) + + +if __name__ == "__main__": + import sys + + for arg in sys.argv[1:] or ["Devices.yml"]: + try: + print(describe(load_devices(arg))) + except DeviceConfigError as exc: + print(f"[FAIL] {exc}") diff --git a/backend/newswitch/schemas/device_schema.py b/backend/newswitch/schemas/device_schema.py new file mode 100644 index 0000000..553a799 --- /dev/null +++ b/backend/newswitch/schemas/device_schema.py @@ -0,0 +1,158 @@ +"""Multi-device registry: one file, many device types. + +The `type` field acts as a **discriminator**. Pydantic uses it to dispatch each +list entry to exactly one model instead of trying all of them, which keeps +error messages precise: + + devices.1.stage.axes.0.travel_um -> Input should be greater than 0 + +instead of the usual union noise ("not valid under any of the given schemas"). + +Adding a device type takes three steps: + 1. subclass DeviceBase with a new `type: Literal[...]` + 2. add it to the `Device` union below + 3. nothing else -- registry, loader and JSON Schema pick it up automatically +""" + +from __future__ import annotations + +from typing import Annotated, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from .camera_schema import CameraSchema + + +class DeviceBase(BaseModel): + """Fields shared by every device type.""" + + model_config = ConfigDict(extra="forbid", validate_assignment=True) + + name: str = Field(description="Model designation") + manufacturer: Optional[str] = Field(default=None, description="Vendor name") + device_id: Optional[str] = Field( + default=None, description="Identifier of the individual physical unit" + ) + tags: list[str] = Field(default_factory=list) + + +class StageAxis(BaseModel): + model_config = ConfigDict(extra="forbid") + + label: Literal["x", "y", "z", "a", "b", "c"] + travel_um: float = Field(gt=0, description="Usable travel range in micrometers") + steps_per_um: float = Field(gt=0, description="Motor steps per micrometer") + inverted: bool = Field(default=False, description="Flip the positive direction") + + +class StageDevice(DeviceBase): + """Motorized positioning stage.""" + + type: Literal["stage"] + axes: list[StageAxis] = Field(min_length=1) + max_speed_um_per_sec: Optional[float] = Field(default=None, gt=0) + homing_required: bool = True + + @model_validator(mode="after") + def _unique_axis_labels(self) -> "StageDevice": + labels = [a.label for a in self.axes] + if len(labels) != len(set(labels)): + raise ValueError(f"duplicate axis labels: {labels}") + return self + + +class LaserDevice(DeviceBase): + """Single-wavelength illumination source.""" + + type: Literal["laser"] + wavelength_nm: float = Field(gt=0, le=20_000) + max_power_mw: float = Field(gt=0) + modulation: Literal["none", "analog", "digital"] = "none" + shutter: bool = Field(default=False, description="Has a mechanical shutter") + + +class FilterSlot(BaseModel): + model_config = ConfigDict(extra="forbid") + + position: int = Field(ge=0, description="Slot index on the wheel") + label: str = Field(description="Filter designation, e.g. GFP-525/50") + center_nm: Optional[float] = Field(default=None, gt=0) + bandwidth_nm: Optional[float] = Field(default=None, gt=0) + + +class FilterWheelDevice(DeviceBase): + """Motorized filter wheel.""" + + type: Literal["filterwheel"] + slots: list[FilterSlot] = Field(min_length=1) + switch_time_ms: Optional[float] = Field(default=None, gt=0) + + @model_validator(mode="after") + def _unique_positions(self) -> "FilterWheelDevice": + positions = [s.position for s in self.slots] + if len(positions) != len(set(positions)): + raise ValueError(f"duplicate slot positions: {positions}") + return self + + +# The discriminated union. CameraSchema already carries type="camera". +# `DeviceModel` is the bare union, usable as a return annotation; `Device` adds the +# discriminator and is what the registry field uses. +DeviceModel = Union[CameraSchema, StageDevice, LaserDevice, FilterWheelDevice] +Device = Annotated[DeviceModel, Field(discriminator="type")] + +DEVICE_MODELS: dict[str, type[BaseModel]] = { + "camera": CameraSchema, + "stage": StageDevice, + "laser": LaserDevice, + "filterwheel": FilterWheelDevice, +} + + +def device_key(dev: DeviceModel) -> str: + """Stable identifier of a device. + + CameraSchema predates DeviceBase and spells the field ``camera_id``; the + other types use ``device_id``. Both are optional, so the name is the + fallback. + """ + return getattr(dev, "device_id", None) or getattr(dev, "camera_id", None) or dev.name + + +class DeviceRegistry(BaseModel): + """Top-level object of a Devices.yml file.""" + + model_config = ConfigDict(extra="forbid") + + version: int = Field(default=1, ge=1, description="Registry format version") + setup: Optional[str] = Field(default=None, description="Name of the setup") + devices: list[Device] = Field(default_factory=list) + + @model_validator(mode="after") + def _unique_names(self) -> "DeviceRegistry": + seen: dict[str, int] = {} + for i, dev in enumerate(self.devices): + key = device_key(dev) + if key in seen: + raise ValueError( + f"devices[{i}]: duplicate identifier {key!r} (also at devices[{seen[key]}])" + ) + seen[key] = i + return self + + def by_type(self, type_: str) -> list[DeviceModel]: + """All devices of one type, e.g. registry.by_type('camera').""" + return [d for d in self.devices if d.type == type_] + + def get(self, key: str) -> DeviceModel: + """Look up one device by device_id, falling back to name.""" + for dev in self.devices: + if device_key(dev) == key or dev.name == key: + return dev + raise KeyError(f"no device with id or name {key!r}") + + +if __name__ == "__main__": + import json + + print(json.dumps(DeviceRegistry.model_json_schema(), indent=2)) diff --git a/backend/newswitch/schemas/document.py b/backend/newswitch/schemas/document.py new file mode 100644 index 0000000..289fab5 --- /dev/null +++ b/backend/newswitch/schemas/document.py @@ -0,0 +1,115 @@ +"""Reading and writing config documents, independent of any schema. + +This is the one place that knows about file formats and about `config.py`'s folder +layout. Everything above it (`camera_io`, `device_io`, `loader`) works on plain +Python objects and never touches a parser itself. + +YAML and JSON share the code path: both parsers produce plain dicts and lists, and +validation happens on those. `yaml.safe_load` would in fact read .json too — YAML 1.2 +is a superset — but the suffix is honoured so that a .json file is guaranteed to be +parsed by a strict JSON parser. + +Paths are resolved through `newswitch.config.get_paths()`, so a bare name like +``"Devices.yml"`` finds the file in the managed config directory while an explicit +path is used as given. This module sits below `errors.ConfigError` and imports +nothing else from the package, which keeps `loader` importable from `camera_io` and +`device_io` without a cycle. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import yaml + +from ..config import CONFIG_SUFFIXES, get_paths +from .errors import ConfigError + + +def resolve_source(src: str | Path) -> Path: + """Resolve a name or path to an existing config file. + + Args: + src: Bare name (with or without suffix) or an explicit path. + + Returns: + Path to the existing file. + + Raises: + ConfigError: Nothing matched; the message lists what was tried. + """ + try: + return get_paths().config_file(src) + except FileNotFoundError as exc: + raise ConfigError(str(exc)) from exc + + +def resolve_target(dest: str | Path) -> Path: + """Resolve a name or path to write to, creating the parent directory. + + Args: + dest: Bare name or an explicit path. A bare name lands in the config dir. + + Returns: + Path to write to. It does not have to exist yet. + """ + path = get_paths().config_file(dest, must_exist=False) + path.parent.mkdir(parents=True, exist_ok=True) + return path + + +def read_document(src: str | Path) -> Any: # noqa: ANN401 - a parsed document is Any + """Parse a .json, .yaml or .yml config file into plain Python objects. + + Args: + src: Bare name (with or without suffix) or an explicit path. + + Returns: + Whatever the file contains — usually a dict, possibly a list. + + Raises: + ConfigError: Unsupported suffix, missing file, or unparseable content. + """ + path = resolve_source(src) + text = path.read_text(encoding="utf-8") + suffix = path.suffix.lower() + + if suffix not in CONFIG_SUFFIXES: + raise ConfigError(f"Unsupported file type: {path.suffix}") + + try: + if suffix == ".json": + return json.loads(text) + return yaml.safe_load(text) + except (json.JSONDecodeError, yaml.YAMLError) as exc: + raise ConfigError(f"{path.name}: cannot be parsed: {exc}") from exc + + +def write_document(payload: Any, dest: str | Path) -> Path: # noqa: ANN401 - JSON-shaped data + """Serialize `payload` to .json or .yaml, picking the format from the suffix. + + Args: + payload: JSON-compatible object, e.g. the result of `model_dump(mode="json")`. + dest: Bare name or an explicit path. + + Returns: + The path written to. + + Raises: + ConfigError: Unsupported suffix. + """ + path = resolve_target(dest) + suffix = path.suffix.lower() + + if suffix == ".json": + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + elif suffix in {".yaml", ".yml"}: + path.write_text( + yaml.safe_dump(payload, sort_keys=False, allow_unicode=True), + encoding="utf-8", + ) + else: + raise ConfigError(f"Unsupported file type: {path.suffix}") + return path diff --git a/backend/newswitch/schemas/errors.py b/backend/newswitch/schemas/errors.py new file mode 100644 index 0000000..1fed0d6 --- /dev/null +++ b/backend/newswitch/schemas/errors.py @@ -0,0 +1,20 @@ +"""Common error base for every config loader in this package. + +`CameraConfigError` and `DeviceConfigError` both derive from `ConfigError`, so a +caller that does not care which loader it went through can write:: + + try: + registry = load_config(path) + except ConfigError as exc: + ... + +The base lives in its own module rather than in one of the io modules, because both +of them plus `loader` need it and importing it from either would create a cycle. +`ConfigError` derives from `ValueError`, which is what the loaders raised before. +""" + +from __future__ import annotations + + +class ConfigError(ValueError): + """Raised when a configuration document does not satisfy its schema.""" diff --git a/backend/newswitch/schemas/loader.py b/backend/newswitch/schemas/loader.py new file mode 100644 index 0000000..5fd3e8a --- /dev/null +++ b/backend/newswitch/schemas/loader.py @@ -0,0 +1,202 @@ +"""One entry point for every config layout. + +`camera_io` and `device_io` each expect one specific document shape. In practice a +caller has a file and wants the devices in it, without caring whether that file +describes a single device (``hik_mv_ca023_10um.yaml``) or a whole setup +(``Devices.yml``), and without caring whether it is JSON or YAML. + +`load_config()` normalizes all of those to the same result — a `DeviceRegistry`: + + ================================= ================================================== + Document Handling + ================================= ================================================== + ``dict`` with a ``devices`` key multi-device file, used as-is + ``list`` bare device list, treated as ``{"devices": [...]}`` + ``dict`` without ``devices`` single device, wrapped into a one-entry registry + anything else ConfigError + ================================= ================================================== + +A single-device document without a ``type`` key is read as a camera, mirroring the +default of `CameraSchema.type`; that is what lets ``hik_mv_ca023_10um.yaml`` load +unchanged. Every other device type has to spell out its ``type:``. A document that +carries both ``devices`` and ``name`` counts as a registry. + +Validation is unchanged and still runs in two layers: the per-type jsonschema pass +from `device_io` for structure, then Pydantic for the cross-field rules. Errors of a +wrapped single-device file are reported without the ``devices[0]`` prefix, so they +read the way the file looks. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any, Optional, Union + +from pydantic import BaseModel, ValidationError + +from .camera_io import dump_camera +from .camera_schema import CameraSchema +from .device_io import dump_devices, validate_registry +from .device_schema import DEVICE_MODELS, DeviceModel, DeviceRegistry, device_key +from .document import read_document +from .errors import ConfigError + +# A single-device file may omit `type`; CameraSchema defaults to "camera" too. +DEFAULT_DEVICE_TYPE = "camera" + +_INDEX_PREFIX = re.compile(r"^devices\[0\]\.?") + + +def _unwrap_error(message: str) -> str: + """Strip the ``devices[0]`` prefix a wrapped single-device file would produce. + + Args: + message: One entry of `validate_registry`'s result. + + Returns: + The message as it would read for a single-device file. + """ + stripped = _INDEX_PREFIX.sub("", message) + return f"{stripped}" if stripped.startswith(":") else stripped + + +def normalize_document(data: Any) -> tuple[dict[str, Any], bool]: # noqa: ANN401 - parsed doc + """Bring any supported document shape into registry form. + + Args: + data: Parsed document, as returned by `read_document`. + + Returns: + A ``(registry_document, was_wrapped)`` pair. ``was_wrapped`` is True when the + input described a single device and had to be put into a ``devices`` list. + + Raises: + ConfigError: The document is neither a mapping nor a list. + """ + if isinstance(data, list): + return {"devices": data}, False + + if not isinstance(data, dict): + raise ConfigError( + f"expected a mapping or a list at the top level, got {type(data).__name__}" + ) + + if "devices" in data: + return data, False + + entry = dict(data) + entry.setdefault("type", DEFAULT_DEVICE_TYPE) + return {"devices": [entry]}, True + + +def load_config(src: str | Path, *, strict_schema: bool = True) -> DeviceRegistry: + """Load any device config file and return it as a validated registry. + + Args: + src: Bare config name (``"Devices"``, ``"Devices.yml"``) resolved against the + managed config directory, or an explicit path. + strict_schema: Run the jsonschema pass first, so *all* structural problems are + reported together instead of only the first Pydantic failure. + + Returns: + The validated registry. A single-device file yields a one-entry registry. + + Raises: + ConfigError: The file cannot be read, or the content fails validation. + """ + document, wrapped = normalize_document(read_document(src)) + name = Path(src).name + + if strict_schema: + errors = validate_registry(document) + if wrapped: + errors = [_unwrap_error(err) for err in errors] + if errors: + joined = "\n - ".join(errors) + raise ConfigError(f"{name} failed validation:\n - {joined}") + + try: + return DeviceRegistry.model_validate(document) + except ValidationError as exc: + raise ConfigError(f"{name}: {exc}") from exc + + +def load_device( + src: str | Path, + key: Optional[str] = None, + *, + expect: Optional[str] = None, + strict_schema: bool = True, +) -> DeviceModel: + """Load exactly one device, from a single-device file or out of a registry. + + Args: + src: Bare config name or an explicit path. + key: ``device_id``/``camera_id`` or name of the wanted device. May be omitted + when the file holds exactly one device. + expect: Device type the caller requires, e.g. ``"camera"``. + strict_schema: Passed through to `load_config`. + + Returns: + The matching device model. + + Raises: + ConfigError: The file holds several devices and no `key` was given, the `key` + is unknown, or the device is not of the expected type. + """ + registry = load_config(src, strict_schema=strict_schema) + name = Path(src).name + available = [device_key(dev) for dev in registry.devices] + + if key is None: + if len(registry.devices) != 1: + raise ConfigError( + f"{name} holds {len(registry.devices)} devices, pass one of: " + f"{', '.join(available) or ''}" + ) + device = registry.devices[0] + else: + try: + device = registry.get(key) + except KeyError as exc: + raise ConfigError( + f"{name}: no device {key!r}; available: {', '.join(available) or ''}" + ) from exc + + if expect is not None: + if expect not in DEVICE_MODELS: + raise ConfigError( + f"unknown device type {expect!r} (known: {', '.join(sorted(DEVICE_MODELS))})" + ) + if device.type != expect: + raise ConfigError( + f"{name}: {device_key(device)!r} is a {device.type}, expected a {expect}" + ) + + return device + + +def dump_config(obj: Union[DeviceRegistry, BaseModel], dest: str | Path) -> Path: + """Store a registry or a single device as .json or .yaml. + + Args: + obj: A `DeviceRegistry`, or a single device model. + dest: Bare name (written into the config dir) or an explicit path. The suffix + picks the format. + + Returns: + The path written to. + + Raises: + ConfigError: Unsupported file type, or `obj` is not a storable model. + """ + if isinstance(obj, DeviceRegistry): + return dump_devices(obj, dest) + if isinstance(obj, CameraSchema): + return dump_camera(obj, dest) + if isinstance(obj, BaseModel) and getattr(obj, "type", None) in DEVICE_MODELS: + # Non-camera devices have no single-device writer of their own; a one-entry + # registry keeps the file loadable by load_config(). + return dump_devices(DeviceRegistry(devices=[obj]), dest) # type: ignore[list-item] + raise ConfigError(f"cannot store {type(obj).__name__}: not a device or registry") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 4bf3903..ead4f17 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -27,6 +27,9 @@ dependencies = [ "uvicorn>=0.40.0", "zarrs>=0.2.2", "zstandard>=0.25.0", + "jsonschema>=4.18", + "platformdirs>=4", + "pydantic-settings>=2", ] [project.optional-dependencies] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8f26bb0..fcd704a 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,18 +1,24 @@ """Pytest configuration and shared fixtures for newswitch tests. This module provides common fixtures used across multiple test modules, -including the virtual microscope FastAPI application. +including the virtual microscope FastAPI application and the config-file +fixtures used by the `newswitch.schemas` tests. """ import asyncio +import json +from pathlib import Path +from typing import Any, Callable, Generator import pytest +import yaml from fastapi import FastAPI from rekuest_next.contrib.fastapi import AsyncAgentTestClient from rekuest_next.contrib.fastapi.testing import BufferedEvent from newswitch.app import ImswitchConfig, create_app from newswitch.auth import AllowAllAuthenticator +from newswitch.config import get_paths # rekuest_next==2.1.1's AsyncAgentTestClient.collect_until_done()/BufferedEvent.is_done() # still check for a "DONE" event type, but the agent's wire protocol emits "COMPLETED" @@ -37,6 +43,139 @@ def virtual_microscope_app() -> FastAPI: return app +# --------------------------------------------------------------------------- +# Config fixtures for the newswitch.schemas tests. +# +# The tests build their own documents instead of reading backend/Configs, which +# is a development-only folder (see Configs/__note__.md) and will disappear once +# the deployment paths are final. Only tests/test_schema_export_drift.py looks at +# the real files, and it skips when they are gone. +# --------------------------------------------------------------------------- + +# (document, file name) -> path written to +ConfigWriter = Callable[[Any, str], Path] + + +@pytest.fixture +def config_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[Path, None, None]: + """Point `newswitch.config` at a throwaway config directory. + + All four `Paths` fields are independent settings, so each one gets its own + environment override. `get_paths()` is `lru_cache`d, so the cache is cleared on + both sides of the test to keep the override from leaking. + + Args: + tmp_path: Pytest's built-in temporary path fixture. + monkeypatch: Pytest's environment patcher. + + Yields: + Path to the temporary config directory. + """ + root = tmp_path / "configs" + root.mkdir() + monkeypatch.setenv("NEWSWITCH_CONFIG_DIR", str(root)) + monkeypatch.setenv("NEWSWITCH_SCHEMA_DIR", str(root / "schemas")) + monkeypatch.setenv("NEWSWITCH_DATA_DIR", str(root / "data")) + monkeypatch.setenv("NEWSWITCH_LOG_DIR", str(root / "logs")) + get_paths.cache_clear() + yield root + get_paths.cache_clear() + + +@pytest.fixture +def write_config(config_dir: Path) -> ConfigWriter: + """Return a helper that writes a document into the temporary config directory. + + The format follows the file name's suffix, so the same document can be written + as .yaml, .yml and .json to prove the loaders treat them identically. + + Args: + config_dir: The temporary config directory fixture. + + Returns: + A callable ``(document, name) -> Path``. + """ + + def _write(document: Any, name: str) -> Path: # noqa: ANN401 - any JSON-shaped document + path = config_dir / name + path.parent.mkdir(parents=True, exist_ok=True) + if path.suffix == ".json": + path.write_text(json.dumps(document, indent=2), encoding="utf-8") + else: + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + return path + + return _write + + +@pytest.fixture +def camera_doc() -> dict[str, Any]: + """A valid single-camera document, exercising both `Bounded` spellings. + + Returns: + The document as a plain dict. + """ + return { + "name": "TestCam-2000", + "manufacturer": "openUC2", + "camera_id": "testcam-001", + "pixelcount": {"x": 1920, "y": 1200}, + "pixelpitch_um": {"x": 10.0}, # y omitted -> derived + "channels": 1, + "exposure_time_ms": {"value": 10.0, "min": 0.024, "max": 10000.0, "increment": 0.001}, + "framerate_per_sec": 30.0, # scalar shorthand + "binning": 1, + "trigger": {"type": "hardware", "edge": "rising"}, + "tags": ["monochrome", "test"], + } + + +@pytest.fixture +def registry_doc(camera_doc: dict[str, Any]) -> dict[str, Any]: + """A valid multi-device document holding one of each device type. + + Args: + camera_doc: The single-camera document fixture. + + Returns: + The document as a plain dict. + """ + return { + "version": 1, + "setup": "test-bench", + "devices": [ + {**camera_doc, "type": "camera"}, + { + "type": "stage", + "name": "XY-Test", + "device_id": "stage-001", + "axes": [ + {"label": "x", "travel_um": 100000.0, "steps_per_um": 8.0}, + {"label": "y", "travel_um": 100000.0, "steps_per_um": 8.0}, + ], + "max_speed_um_per_sec": 5000.0, + }, + { + "type": "laser", + "name": "LD-488", + "device_id": "laser-001", + "wavelength_nm": 488.0, + "max_power_mw": 100.0, + "modulation": "digital", + }, + { + "type": "filterwheel", + "name": "FW-Test", + "device_id": "fw-001", + "slots": [ + {"position": 0, "label": "empty"}, + {"position": 1, "label": "GFP-525/50", "center_nm": 525.0}, + ], + }, + ], + } + + async def collect_until_completed( client: AsyncAgentTestClient, task_id: str, timeout: float = 5.0 ) -> list[BufferedEvent]: diff --git a/backend/tests/test_bounded.py b/backend/tests/test_bounded.py new file mode 100644 index 0000000..bf779c5 --- /dev/null +++ b/backend/tests/test_bounded.py @@ -0,0 +1,144 @@ +"""Tests for `Bounded`, the numeric parameter that carries its own device limits. + +Covers the two accepted spellings (bare scalar and full object), the cross-field +rules that JSON Schema cannot express, the clamp/snap helpers, and the custom JSON +Schema emission that keeps the shorthand valid in the jsonschema layer too. +""" + +from typing import Any + +import pytest +from pydantic import ValidationError + +from newswitch.schemas import Bounded + + +def test_scalar_shorthand_equals_explicit_value() -> None: + """A bare number is accepted as shorthand for ``{"value": }``.""" + assert Bounded.model_validate(10.0) == Bounded.model_validate({"value": 10.0}) + assert Bounded.model_validate(10).value == 10.0 + + +def test_bool_is_not_a_number() -> None: + """``True`` is an int in Python but must not become ``{"value": 1.0}``.""" + with pytest.raises(ValidationError): + Bounded.model_validate(True) + + +def test_all_fields_optional() -> None: + """Every member is optional: limits without a value are a valid capability block.""" + capability = Bounded.model_validate({"min": 0.024, "max": 10000.0}) + assert capability.value is None + assert Bounded().model_dump(exclude_none=True) == {} + + +def test_min_above_max_rejected() -> None: + """An inverted limit pair is a configuration error.""" + with pytest.raises(ValidationError, match="must not exceed max"): + Bounded(min=10.0, max=1.0) + + +@pytest.mark.parametrize( + ("payload", "message"), + [ + ({"value": 0.01, "min": 0.024}, "below min"), + ({"value": 20000.0, "max": 10000.0}, "above max"), + ({"value": 10.0005, "min": 0.0, "increment": 0.001}, "not on the"), + ], +) +def test_value_checked_against_its_own_limits(payload: dict[str, Any], message: str) -> None: + """`value` is validated against the min/max/increment of the same object. + + Args: + payload: The `Bounded` document under test. + message: Fragment expected in the error message. + """ + with pytest.raises(ValidationError, match=message): + Bounded.model_validate(payload) + + +def test_increment_must_be_positive() -> None: + """A zero or negative step size cannot describe a grid.""" + with pytest.raises(ValidationError): + Bounded(increment=0.0) + + +def test_grid_is_anchored_at_min() -> None: + """Settable values are ``min + n * increment``, not ``n * increment``.""" + assert Bounded(value=0.35, min=0.05, increment=0.1).value == 0.35 + with pytest.raises(ValidationError, match="not on the"): + Bounded(value=0.3, min=0.05, increment=0.1) + + +def test_extra_keys_rejected() -> None: + """A typo such as ``maximum`` must fail loudly instead of being ignored.""" + with pytest.raises(ValidationError): + Bounded.model_validate({"value": 1.0, "maximum": 10.0}) + + +def test_assignment_is_validated_but_not_rolled_back() -> None: + """An out-of-range assignment raises — yet the instance keeps the bad value. + + `validate_assignment` sets the field first and only then runs the model-level + validator, so when `_check_consistency` rejects the new value the object is + already holding it. Callers must therefore treat a caught `ValidationError` + as "this object is now unusable", not as "nothing happened". Pinned here so a + later switch to a rollback-capable design shows up as a failing test. + """ + exposure = Bounded(value=10.0, min=0.024, max=10000.0) + with pytest.raises(ValidationError, match="above max"): + exposure.value = 99999.0 + assert exposure.value == 99999.0 + + +@pytest.mark.parametrize( + ("requested", "expected"), + [ + (1e6, 10000.0), # clamped to max + (0.0, 0.024), # clamped to min + (10.00047, 10.0), # snapped down onto the grid + (10.0006, 10.001), # snapped up onto the grid + ], +) +def test_clamp(requested: float, expected: float) -> None: + """`clamp` limits to [min, max] and snaps onto the increment grid. + + Args: + requested: The value handed to `clamp`. + expected: The value the device would actually take. + """ + exposure = Bounded(value=10.0, min=0.024, max=10000.0, increment=0.001) + assert exposure.clamp(requested) == pytest.approx(expected) + + +def test_clamp_never_returns_a_value_above_max() -> None: + """Snapping up must not push the result past `max`; it steps back down instead.""" + coarse = Bounded(min=0.0, max=8.0, increment=3.0) + assert coarse.clamp(8.0) == pytest.approx(6.0) + assert coarse.supports(coarse.clamp(8.0)) + + +def test_supports() -> None: + """`supports` answers whether a value can be set exactly, without raising.""" + exposure = Bounded(value=10.0, min=0.024, max=10000.0, increment=0.001) + assert exposure.supports(10.001) + assert not exposure.supports(10.0005) + assert not exposure.supports(20000.0) + + +def test_json_schema_accepts_both_spellings() -> None: + """The exported schema must emit ``anyOf: [number, object]``. + + Without the `__get_pydantic_json_schema__` override the scalar shorthand would + pass Pydantic and fail the jsonschema layer, so the two layers would disagree. + """ + schema = Bounded.model_json_schema() + assert "anyOf" in schema + kinds = {branch.get("type") for branch in schema["anyOf"]} + assert "number" in kinds + assert "object" in kinds + + +def test_repr_hides_unset_members() -> None: + """The repr stays readable by dropping the members that are None.""" + assert repr(Bounded(value=10.0)) == "Bounded(value=10.0)" diff --git a/backend/tests/test_camera_io.py b/backend/tests/test_camera_io.py new file mode 100644 index 0000000..ab4783b --- /dev/null +++ b/backend/tests/test_camera_io.py @@ -0,0 +1,210 @@ +"""Tests for `camera_io`: the camera-only loader, writer and schema exporter. + +The point of this module is the two-layer split. The jsonschema pass reports every +structural problem at once; the Pydantic pass adds the cross-field rules and the +derived defaults that JSON Schema cannot express. Both layers must accept the same +documents, in JSON and in YAML alike. +""" + +import json +from pathlib import Path +from typing import Any + +import pytest + +from newswitch.schemas import ( + CameraConfigError, + CameraSchema, + ConfigError, + dump_camera, + export_camera_schema, + load_camera, + read_document, + schema_errors, +) +from tests.conftest import ConfigWriter + + +def test_yaml_and_json_produce_the_same_model( + camera_doc: dict[str, Any], write_config: ConfigWriter +) -> None: + """The format only picks the parser; the resulting model is identical. + + Args: + camera_doc: The valid camera document fixture. + write_config: Helper writing a document into the temporary config dir. + """ + as_yaml = load_camera(write_config(camera_doc, "cam.yaml")) + as_yml = load_camera(write_config(camera_doc, "cam.yml")) + as_json = load_camera(write_config(camera_doc, "cam.json")) + + assert as_yaml == as_json == as_yml + + +def test_bare_name_is_resolved_against_the_config_dir( + camera_doc: dict[str, Any], write_config: ConfigWriter +) -> None: + """A name without a directory — and even without a suffix — is looked up. + + Args: + camera_doc: The valid camera document fixture. + write_config: Helper writing a document into the temporary config dir. + """ + write_config(camera_doc, "testcam.yaml") + assert load_camera("testcam").name == "TestCam-2000" + assert load_camera("testcam.yaml").name == "TestCam-2000" + + +def test_missing_file_reports_what_it_tried() -> None: + """A name that resolves to nothing names the candidates it looked for.""" + with pytest.raises(CameraConfigError, match="tried:"): + load_camera("does-not-exist") + + +def test_unsupported_suffix_rejected(tmp_path: Path) -> None: + """Only .json/.yaml/.yml are parsed; anything else is refused up front. + + Args: + tmp_path: Pytest's built-in temporary path fixture. + """ + path = tmp_path / "camera.txt" + path.write_text("name: nope", encoding="utf-8") + with pytest.raises(CameraConfigError, match="Unsupported file type"): + load_camera(path) + + +def test_top_level_must_be_a_mapping(write_config: ConfigWriter) -> None: + """A camera file describes one camera, so a list at the top level is an error. + + Args: + write_config: Helper writing a document into the temporary config dir. + """ + with pytest.raises(CameraConfigError, match="expected a mapping"): + load_camera(write_config([{"name": "Cam"}], "list.yaml")) + + +def test_camera_errors_are_config_errors(write_config: ConfigWriter) -> None: + """`CameraConfigError` is a `ConfigError`, so callers can catch just the base. + + Args: + write_config: Helper writing a document into the temporary config dir. + """ + with pytest.raises(ConfigError): + load_camera(write_config({"name": "Incomplete"}, "bad.yaml")) + + +def test_schema_errors_lists_every_structural_problem(camera_doc: dict[str, Any]) -> None: + """The jsonschema pass reports all violations at once, not just the first. + + Args: + camera_doc: The valid camera document fixture. + """ + assert schema_errors(camera_doc) == [] + + broken = {**camera_doc, "pixelpitch_um": {"x": -1.0}, "binning": 3, "colour": "red"} + del broken["name"] + messages = schema_errors(broken) + + assert len(messages) >= 4 + joined = "\n".join(messages) + assert "'name' is a required property" in joined + assert "pixelpitch_um.x" in joined + assert "binning" in joined + assert "colour" in joined + + +def test_pydantic_layer_catches_what_jsonschema_cannot( + camera_doc: dict[str, Any], write_config: ConfigWriter +) -> None: + """An oversized ROI is structurally valid but rejected by the model. + + `minimum`/`maximum` in JSON Schema are global constants and cannot refer to + another field, so "the ROI fits inside this sensor" only exists in Pydantic. + With `strict_schema=False` the jsonschema pass is skipped entirely and the + model still has to reject the file. + + Args: + camera_doc: The valid camera document fixture. + write_config: Helper writing a document into the temporary config dir. + """ + # A list, not a tuple: jsonschema validates parsed documents, and a Python tuple + # is not a JSON array to it. Feed it what the parser produces. + camera_doc["roi"] = [0, 0, 4096, 4096] + path = write_config(camera_doc, "oversized_roi.yaml") + + assert schema_errors(read_document(path)) == [] + with pytest.raises(CameraConfigError, match="exceeds sensor pixelcount"): + load_camera(path, strict_schema=False) + + +@pytest.mark.parametrize("suffix", [".yaml", ".yml", ".json"]) +def test_round_trip(camera_doc: dict[str, Any], write_config: ConfigWriter, suffix: str) -> None: + """A loaded camera can be written back and reloaded unchanged. + + Args: + camera_doc: The valid camera document fixture. + write_config: Helper writing a document into the temporary config dir. + suffix: Output format under test. + """ + original = load_camera(write_config(camera_doc, "cam.yaml")) + written = dump_camera(original, f"round_trip{suffix}") + + assert written.exists() + assert load_camera(written) == original + + +def test_dump_fills_in_the_derived_values( + camera_doc: dict[str, Any], write_config: ConfigWriter +) -> None: + """Storing writes the resolved model, so derived fields become explicit. + + Args: + camera_doc: The valid camera document fixture. + write_config: Helper writing a document into the temporary config dir. + """ + camera = load_camera(write_config(camera_doc, "cam.yaml")) + written = json.loads(dump_camera(camera, "resolved.json").read_text(encoding="utf-8")) + + assert written["pixelpitch_um"] == {"x": 10.0, "y": 10.0} # y was omitted in the source + assert written["pixelsize_um"] == {"x": 10.0, "y": 10.0} # whole object was omitted + assert written["type"] == "camera" + + +def test_dump_rejects_unsupported_suffix( + camera_doc: dict[str, Any], write_config: ConfigWriter, tmp_path: Path +) -> None: + """Writing to an unknown extension fails instead of guessing a format. + + Args: + camera_doc: The valid camera document fixture. + write_config: Helper writing a document into the temporary config dir. + tmp_path: Pytest's built-in temporary path fixture. + """ + camera = load_camera(write_config(camera_doc, "cam.yaml")) + with pytest.raises(CameraConfigError, match="Unsupported file type"): + dump_camera(camera, tmp_path / "camera.txt") + + +def test_export_json_schema(tmp_path: Path) -> None: + """The exported schema is valid JSON and declares the draft it follows. + + Args: + tmp_path: Pytest's built-in temporary path fixture. + """ + target = export_camera_schema(tmp_path / "camera.schema.json") + schema = json.loads(target.read_text(encoding="utf-8")) + + assert schema["$schema"] == "https://json-schema.org/draft/2020-12/schema" + assert schema["properties"].keys() >= {"name", "pixelcount", "pixelpitch_um"} + assert schema["required"] == CameraSchema.model_json_schema()["required"] + + +def test_export_json_schema_defaults_to_the_schema_dir(config_dir: Path) -> None: + """Without an argument the export lands in the managed schema directory. + + Args: + config_dir: The temporary config directory fixture. + """ + target = export_camera_schema() + assert target == config_dir / "schemas" / "camera.schema.json" + assert target.is_file() diff --git a/backend/tests/test_camera_schema.py b/backend/tests/test_camera_schema.py new file mode 100644 index 0000000..1be041d --- /dev/null +++ b/backend/tests/test_camera_schema.py @@ -0,0 +1,206 @@ +"""Tests for `CameraSchema`: required fields, derived geometry and cross-field rules. + +These are the rules that live in Pydantic only — the exported JSON Schema checks +structure and types, but cannot fill in a default from a sibling field or compare an +ROI against the sensor size. +""" + +from typing import Any + +import pytest +from pydantic import ValidationError + +from newswitch.schemas import CameraSchema + + +@pytest.mark.parametrize("missing", ["name", "pixelcount", "pixelpitch_um"]) +def test_required_fields(camera_doc: dict[str, Any], missing: str) -> None: + """`name`, `pixelcount` and `pixelpitch_um` have no default and are required. + + Args: + camera_doc: The valid camera document fixture. + missing: Key removed before validation. + """ + camera_doc.pop(missing) + with pytest.raises(ValidationError, match=missing): + CameraSchema.model_validate(camera_doc) + + +def test_optional_fields_may_be_omitted() -> None: + """Everything else is optional: a model description needs no serial or exposure.""" + camera = CameraSchema.model_validate( + {"name": "Minimal", "pixelcount": {"x": 640, "y": 480}, "pixelpitch_um": {"x": 5.0}} + ) + assert camera.camera_id is None + assert camera.exposure_time_ms is None + assert camera.channels == 1 + assert camera.binning == 1 + assert camera.tags == [] + assert camera.type == "camera" + + +def test_pixelpitch_y_defaults_to_x(camera_doc: dict[str, Any]) -> None: + """Square pixels are the common case, so `y` may be omitted. + + Args: + camera_doc: The valid camera document fixture. + """ + camera = CameraSchema.model_validate(camera_doc) + assert camera.pixelpitch_um.x == 10.0 + assert camera.pixelpitch_um.y == 10.0 + + +def test_pixelsize_defaults_to_pixelpitch(camera_doc: dict[str, Any]) -> None: + """An omitted `pixelsize_um` means a fill factor of 1.0. + + Args: + camera_doc: The valid camera document fixture. + """ + camera = CameraSchema.model_validate(camera_doc) + assert camera.pixelsize_um is not None + assert (camera.pixelsize_um.x, camera.pixelsize_um.y) == (10.0, 10.0) + assert camera.fill_factor == pytest.approx(1.0) + + +def test_pixelsize_y_falls_back_to_pixelsize_x(camera_doc: dict[str, Any]) -> None: + """`pixelsize_um.y` falls back to `pixelsize_um.x`, *not* to `pixelpitch_um.y`. + + Both are `Vector2Float`, so each object fills its own `y` from its own `x`. + Worth pinning: for a non-square pitch this is the surprising half of the rule. + + Args: + camera_doc: The valid camera document fixture. + """ + camera_doc["pixelpitch_um"] = {"x": 10.0, "y": 20.0} + camera_doc["pixelsize_um"] = {"x": 8.0} + camera = CameraSchema.model_validate(camera_doc) + assert camera.pixelsize_um is not None + assert camera.pixelsize_um.y == 8.0 + assert camera.fill_factor == pytest.approx((8.0 * 8.0) / (10.0 * 20.0)) + + +def test_sensor_size_mm(camera_doc: dict[str, Any]) -> None: + """Sensor extent is pixelcount times pitch, converted to millimeters. + + Args: + camera_doc: The valid camera document fixture. + """ + camera = CameraSchema.model_validate(camera_doc) + assert camera.sensor_size_mm == pytest.approx((19.2, 12.0)) + + +def test_roi_within_sensor_accepted(camera_doc: dict[str, Any]) -> None: + """An ROI that fits inside the sensor is fine. + + Args: + camera_doc: The valid camera document fixture. + """ + camera_doc["roi"] = (0, 0, 1920, 1200) + assert CameraSchema.model_validate(camera_doc).roi == (0, 0, 1920, 1200) + + +@pytest.mark.parametrize( + ("roi", "message"), + [ + ((0, 0, 1921, 1200), "exceeds sensor pixelcount"), + ((100, 100, 1900, 100), "exceeds sensor pixelcount"), + ((-1, 0, 100, 100), "must be non-negative"), + ((0, 0, 0, 100), "must be positive"), + ], +) +def test_roi_cross_field_rules( + camera_doc: dict[str, Any], roi: tuple[int, int, int, int], message: str +) -> None: + """The ROI is checked against `pixelcount` — a rule JSON Schema cannot express. + + Args: + camera_doc: The valid camera document fixture. + roi: The region of interest under test. + message: Fragment expected in the error message. + """ + camera_doc["roi"] = roi + with pytest.raises(ValidationError, match=message): + CameraSchema.model_validate(camera_doc) + + +def test_software_trigger_rejects_edge(camera_doc: dict[str, Any]) -> None: + """`edge`/`level` are meaningless for a software trigger and treated as a mistake. + + Args: + camera_doc: The valid camera document fixture. + """ + camera_doc["trigger"] = {"type": "software", "edge": "rising"} + with pytest.raises(ValidationError, match="only meaningful for hardware"): + CameraSchema.model_validate(camera_doc) + + +def test_hardware_trigger_accepts_edge(camera_doc: dict[str, Any]) -> None: + """A hardware trigger may name the edge it fires on. + + Args: + camera_doc: The valid camera document fixture. + """ + camera = CameraSchema.model_validate(camera_doc) + assert camera.trigger is not None + assert (camera.trigger.type, camera.trigger.edge) == ("hardware", "rising") + + +def test_unknown_key_rejected(camera_doc: dict[str, Any]) -> None: + """`extra="forbid"` turns a typo into an error instead of a silently lost setting. + + Args: + camera_doc: The valid camera document fixture. + """ + camera_doc["pixelcont"] = {"x": 1, "y": 1} + with pytest.raises(ValidationError, match="pixelcont"): + CameraSchema.model_validate(camera_doc) + + +@pytest.mark.parametrize("binning", [0, 3, 8]) +def test_binning_is_a_closed_set(camera_doc: dict[str, Any], binning: int) -> None: + """Only symmetric binning factors 1, 2 and 4 exist on the supported hardware. + + Args: + camera_doc: The valid camera document fixture. + binning: The rejected binning factor. + """ + camera_doc["binning"] = binning + with pytest.raises(ValidationError): + CameraSchema.model_validate(camera_doc) + + +def test_type_discriminator_is_fixed(camera_doc: dict[str, Any]) -> None: + """A camera document cannot claim to be another device type. + + Args: + camera_doc: The valid camera document fixture. + """ + camera_doc["type"] = "stage" + with pytest.raises(ValidationError): + CameraSchema.model_validate(camera_doc) + + +def test_schema_pointer_round_trips(camera_doc: dict[str, Any]) -> None: + """The `$schema` editor pointer is read into `schema_ref` and written back as-is. + + Args: + camera_doc: The valid camera document fixture. + """ + camera_doc["$schema"] = "./schemas/camera.schema.json" + camera = CameraSchema.model_validate(camera_doc) + assert camera.schema_ref == "./schemas/camera.schema.json" + + dumped = camera.model_dump(mode="json", by_alias=True, exclude_none=True) + assert dumped["$schema"] == "./schemas/camera.schema.json" + assert "schema_ref" not in dumped + + +def test_pixelcount_must_be_positive_integers(camera_doc: dict[str, Any]) -> None: + """A sensor with no pixels is not a sensor. + + Args: + camera_doc: The valid camera document fixture. + """ + camera_doc["pixelcount"] = {"x": 0, "y": 1200} + with pytest.raises(ValidationError): + CameraSchema.model_validate(camera_doc) diff --git a/backend/tests/test_config_paths.py b/backend/tests/test_config_paths.py new file mode 100644 index 0000000..d42c4e6 --- /dev/null +++ b/backend/tests/test_config_paths.py @@ -0,0 +1,187 @@ +"""Tests for `newswitch.config`: where files live and how names are resolved. + +`Paths` is the single place that knows the folder layout. The loaders in +`newswitch.schemas` go through `config_file()` / `schema_file()`, so the resolution +rules pinned here decide what ``load_config("Devices")`` actually opens. +""" + +import os +from pathlib import Path + +import pytest + +from newswitch.config import CONFIG_SUFFIXES, APP_NAME, Paths, get_paths + + +def test_defaults_point_into_the_backend_config_folder() -> None: + """The development defaults resolve to `backend/Configs` and its subfolders.""" + paths = Paths() + + assert paths.config_dir.name == "Configs" + assert paths.schema_dir == paths.config_dir / "schemas" + assert paths.data_dir == paths.config_dir / "data" + assert paths.log_dir == paths.config_dir / "logs" + assert paths.config_dir.is_dir(), "the sample config folder should exist in a checkout" + + +def test_get_paths_is_cached() -> None: + """`get_paths` hands out one shared instance rather than re-reading the env.""" + assert get_paths() is get_paths() + + +def test_environment_overrides(config_dir: Path) -> None: + """Each directory can be redirected with its own NEWSWITCH_* variable. + + Args: + config_dir: The temporary config directory fixture. + """ + paths = get_paths() + + assert paths.config_dir == config_dir + assert paths.schema_dir == config_dir / "schemas" + assert paths.data_dir == config_dir / "data" + + +def test_app_name_and_suffixes() -> None: + """The supported extensions are tried in this order for a name without one.""" + assert APP_NAME == "newswitch" + assert CONFIG_SUFFIXES == (".yaml", ".yml", ".json") + + +def test_device_settings_file(config_dir: Path) -> None: + """The device settings file is named relative to the config directory. + + Args: + config_dir: The temporary config directory fixture. + """ + assert get_paths().device_settings_file == config_dir / "uc2DevSettings.json" + + +def test_config_file_with_suffix(config_dir: Path) -> None: + """A bare name with a known suffix is looked up in the config directory. + + Args: + config_dir: The temporary config directory fixture. + """ + (config_dir / "Devices.yml").write_text("devices: []", encoding="utf-8") + assert get_paths().config_file("Devices.yml") == config_dir / "Devices.yml" + + +@pytest.mark.parametrize("suffix", [".yaml", ".yml", ".json"]) +def test_config_file_without_suffix(config_dir: Path, suffix: str) -> None: + """A name without a suffix finds whichever supported extension exists. + + Args: + config_dir: The temporary config directory fixture. + suffix: The extension the file is written with. + """ + (config_dir / f"Devices{suffix}").write_text("devices: []", encoding="utf-8") + assert get_paths().config_file("Devices").suffix == suffix + + +def test_config_file_prefers_yaml_over_json(config_dir: Path) -> None: + """With several candidates present, `CONFIG_SUFFIXES` decides the winner. + + Args: + config_dir: The temporary config directory fixture. + """ + for suffix in (".yaml", ".yml", ".json"): + (config_dir / f"Devices{suffix}").write_text("devices: []", encoding="utf-8") + assert get_paths().config_file("Devices").suffix == ".yaml" + + +def test_config_file_passes_explicit_paths_through(config_dir: Path, tmp_path: Path) -> None: + """A path with a directory part is never rewritten into the managed folder. + + Args: + config_dir: The temporary config directory fixture. + tmp_path: Pytest's built-in temporary path fixture. + """ + outside = tmp_path / "outside.yaml" + outside.write_text("devices: []", encoding="utf-8") + + assert get_paths().config_file(outside) == outside + assert get_paths().config_file(Path("./missing/file.yaml"), must_exist=False) == Path( + "./missing/file.yaml" + ) + + +def test_config_file_missing_lists_candidates(config_dir: Path) -> None: + """The error names every candidate, which is what makes a typo obvious. + + Args: + config_dir: The temporary config directory fixture. + """ + with pytest.raises(FileNotFoundError) as excinfo: + get_paths().config_file("Devices") + + message = str(excinfo.value) + assert "Devices.yaml" in message and "Devices.yml" in message and "Devices.json" in message + + +def test_config_file_for_writing(config_dir: Path) -> None: + """`must_exist=False` yields the canonical target for a file yet to be written. + + Args: + config_dir: The temporary config directory fixture. + """ + target = get_paths().config_file("new.yml", must_exist=False) + assert target == config_dir / "new.yml" + assert not target.exists() + + # Without a suffix the first supported extension is the canonical choice. + assert get_paths().config_file("new", must_exist=False) == config_dir / "new.yaml" + + +def test_schema_file(config_dir: Path) -> None: + """Schema names resolve against `schema_dir` and default to write mode. + + Args: + config_dir: The temporary config directory fixture. + """ + schemas = config_dir / "schemas" + schemas.mkdir() + + assert get_paths().schema_file("camera.schema.json") == schemas / "camera.schema.json" + + with pytest.raises(FileNotFoundError): + get_paths().schema_file("camera.schema.json", must_exist=True) + + +def test_ensure_writable_dirs_creates_them(config_dir: Path) -> None: + """Data, log and schema directories are created on demand. + + Args: + config_dir: The temporary config directory fixture. + """ + paths = get_paths() + assert not paths.data_dir.exists() + + paths.ensure_writable_dirs() + + assert paths.data_dir.is_dir() + assert paths.log_dir.is_dir() + assert paths.schema_dir.is_dir() + + +@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores directory permissions") +def test_ensure_writable_dirs_reports_a_read_only_directory( + config_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A directory that exists but cannot be written to is a deployment error. + + Args: + config_dir: The temporary config directory fixture. + monkeypatch: Pytest's environment patcher. + """ + read_only = config_dir / "read_only" + read_only.mkdir() + read_only.chmod(0o500) + monkeypatch.setenv("NEWSWITCH_DATA_DIR", str(read_only / "data")) + get_paths.cache_clear() + + try: + with pytest.raises(RuntimeError, match="Cannot create"): + get_paths().ensure_writable_dirs() + finally: + read_only.chmod(0o700) diff --git a/backend/tests/test_device_io.py b/backend/tests/test_device_io.py new file mode 100644 index 0000000..0ddafe9 --- /dev/null +++ b/backend/tests/test_device_io.py @@ -0,0 +1,223 @@ +"""Tests for `device_io`: per-type validation, loading, writing and describing. + +The module exists to avoid `oneOf` noise: a mixed device list validated against one +JSON Schema reports every branch it tried. `validate_registry` reads the `type` field +first and checks the entry against that sub-schema only, so the messages point at the +actual field. These tests pin that error shape, since it is the user-facing part. +""" + +import json +from pathlib import Path +from typing import Any + +import pytest + +from newswitch.schemas import ( + ConfigError, + DeviceConfigError, + describe, + dump_devices, + export_devices_schema, + load_devices, + validate_registry, +) +from tests.conftest import ConfigWriter + + +@pytest.fixture +def broken_doc() -> dict[str, Any]: + """A registry document with one problem of every kind. + + Returns: + The document as a plain dict. + """ + return { + "version": 1, + "setup": "broken-demo", + "setups": "unknown root key", + "devices": [ + { + "type": "camera", + "name": "BadCam", + "pixelcount": {"x": -5}, # y missing, x out of range + "pixelpitch_um": {"x": 3.45}, + "binning": 3, # not in [1, 2, 4] + "colour": "red", # unknown key + }, + {"type": "stage", "name": "NoAxes", "axes": []}, # min_length=1 + {"type": "spectrometer", "name": "Ghost"}, # unknown device type + {"name": "NoType"}, # missing discriminator + "not-an-object", + ], + } + + +def test_valid_document_has_no_errors(registry_doc: dict[str, Any]) -> None: + """A good document produces an empty error list. + + Args: + registry_doc: The valid multi-device document fixture. + """ + assert validate_registry(registry_doc) == [] + + +def test_every_problem_is_reported_once_and_precisely(broken_doc: dict[str, Any]) -> None: + """Each entry is checked against its own sub-schema, so locations stay exact. + + Args: + broken_doc: The deliberately broken document fixture. + """ + errors = validate_registry(broken_doc) + joined = "\n".join(errors) + + assert ": unknown key 'setups'" in errors + assert "devices[0].pixelcount: 'y' is a required property" in errors + assert "devices[0].pixelcount.x: -5 is less than or equal to the minimum of 0" in errors + assert "devices[0].binning: 3 is not one of [1, 2, 4]" in errors + assert "colour" in joined + assert "devices[1].axes: [] should be non-empty" in errors + assert ( + "devices[2]: unknown type 'spectrometer' (known: camera, filterwheel, laser, stage)" + in errors + ) + assert "devices[3]: missing 'type' discriminator" in errors + assert "devices[4]: expected an object, got str" in errors + + # The union noise this module exists to avoid must not appear. + assert "is not valid under any of the given schemas" not in joined + + +def test_single_device_file_gets_a_hint(camera_doc: dict[str, Any]) -> None: + """Pointing the registry loader at a single-device file explains what to do. + + Args: + camera_doc: The valid camera document fixture. + """ + errors = validate_registry(camera_doc) + assert len(errors) == 1 + assert "'devices' is a required property" in errors[0] + assert "single-device file" in errors[0] + + +@pytest.mark.parametrize( + ("document", "message"), + [ + (["a", "list"], "expected a mapping"), + ({"version": 1, "devices": "nope"}, "expected a list"), + ], +) +def test_malformed_top_level(document: Any, message: str) -> None: # noqa: ANN401 - by design + """A document of the wrong shape fails before any device is looked at. + + Args: + document: The malformed document. + message: Fragment expected in the error message. + """ + assert any(message in err for err in validate_registry(document)) + + +def test_load_devices_raises_with_all_errors( + broken_doc: dict[str, Any], write_config: ConfigWriter +) -> None: + """The loader collects the messages into one readable exception. + + Args: + broken_doc: The deliberately broken document fixture. + write_config: Helper writing a document into the temporary config dir. + """ + path = write_config(broken_doc, "Devices_broken.yml") + with pytest.raises(DeviceConfigError) as excinfo: + load_devices(path) + + message = str(excinfo.value) + assert message.startswith("Devices_broken.yml failed validation:") + assert message.count("\n - ") >= 8 + + +def test_device_errors_are_config_errors(write_config: ConfigWriter) -> None: + """`DeviceConfigError` is a `ConfigError`, like the camera one. + + Args: + write_config: Helper writing a document into the temporary config dir. + """ + with pytest.raises(ConfigError): + load_devices(write_config({"devices": [{"type": "stage"}]}, "bad.yaml")) + + +def test_unsupported_suffix_rejected(tmp_path: Path) -> None: + """Only .json/.yaml/.yml are parsed. + + Args: + tmp_path: Pytest's built-in temporary path fixture. + """ + path = tmp_path / "devices.txt" + path.write_text("devices: []", encoding="utf-8") + with pytest.raises(DeviceConfigError, match="Unsupported file type"): + load_devices(path) + + +@pytest.mark.parametrize("suffix", [".yaml", ".yml", ".json"]) +def test_round_trip(registry_doc: dict[str, Any], write_config: ConfigWriter, suffix: str) -> None: + """A registry survives being written out and read back in any format. + + Args: + registry_doc: The valid multi-device document fixture. + write_config: Helper writing a document into the temporary config dir. + suffix: Output format under test. + """ + original = load_devices(write_config(registry_doc, "Devices.yml")) + written = dump_devices(original, f"round_trip{suffix}") + + assert load_devices(written) == original + + +def test_dump_keeps_the_discriminator( + registry_doc: dict[str, Any], write_config: ConfigWriter +) -> None: + """Every written entry keeps its `type`, or the file could not be read back. + + Args: + registry_doc: The valid multi-device document fixture. + write_config: Helper writing a document into the temporary config dir. + """ + registry = load_devices(write_config(registry_doc, "Devices.yml")) + written = json.loads(dump_devices(registry, "out.json").read_text(encoding="utf-8")) + + assert [entry["type"] for entry in written["devices"]] == [ + "camera", + "stage", + "laser", + "filterwheel", + ] + + +def test_describe(registry_doc: dict[str, Any], write_config: ConfigWriter) -> None: + """`describe` prints the setup header plus one line per device. + + Args: + registry_doc: The valid multi-device document fixture. + write_config: Helper writing a document into the temporary config dir. + """ + registry = load_devices(write_config(registry_doc, "Devices.yml")) + lines = describe(registry).splitlines() + + assert lines[0] == "setup: test-bench (v1)" + assert len(lines) == 5 + assert "1920x1200 px" in lines[1] + assert "axes x/y" in lines[2] + assert "488 nm, 100 mW" in lines[3] + assert "2 slots" in lines[4] + + +def test_export_json_schema_defaults_to_the_schema_dir(config_dir: Path) -> None: + """Without an argument the export lands in the managed schema directory. + + Args: + config_dir: The temporary config directory fixture. + """ + target = export_devices_schema() + schema = json.loads(target.read_text(encoding="utf-8")) + + assert target == config_dir / "schemas" / "devices.schema.json" + assert schema["$schema"] == "https://json-schema.org/draft/2020-12/schema" + assert "devices" in schema["properties"] diff --git a/backend/tests/test_device_schema.py b/backend/tests/test_device_schema.py new file mode 100644 index 0000000..a7195bf --- /dev/null +++ b/backend/tests/test_device_schema.py @@ -0,0 +1,181 @@ +"""Tests for the device models and the `DeviceRegistry` that holds them. + +Focus: the discriminated union dispatches each entry to exactly one model, and the +uniqueness rules (device identifiers, axis labels, filter slots) that keep a setup +description unambiguous. +""" + +from typing import Any + +import pytest +from pydantic import ValidationError + +from newswitch.schemas import ( + CameraSchema, + DeviceRegistry, + FilterWheelDevice, + LaserDevice, + StageDevice, + device_key, +) + + +def test_registry_dispatches_by_type(registry_doc: dict[str, Any]) -> None: + """Each entry becomes the model its `type` names, not a generic base object. + + Args: + registry_doc: The valid multi-device document fixture. + """ + registry = DeviceRegistry.model_validate(registry_doc) + assert [type(dev) for dev in registry.devices] == [ + CameraSchema, + StageDevice, + LaserDevice, + FilterWheelDevice, + ] + assert registry.setup == "test-bench" + assert registry.version == 1 + + +def test_empty_registry_is_valid() -> None: + """A setup with no devices yet is a legitimate starting point.""" + assert DeviceRegistry().devices == [] + + +def test_discriminator_error_names_the_type(registry_doc: dict[str, Any]) -> None: + """An unknown `type` is reported as such instead of as a union failure. + + Args: + registry_doc: The valid multi-device document fixture. + """ + registry_doc["devices"][0]["type"] = "spectrometer" + with pytest.raises(ValidationError, match="spectrometer"): + DeviceRegistry.model_validate(registry_doc) + + +def test_error_path_points_at_the_field(registry_doc: dict[str, Any]) -> None: + """The discriminator keeps error locations precise inside the offending entry. + + Args: + registry_doc: The valid multi-device document fixture. + """ + registry_doc["devices"][1]["axes"][0]["travel_um"] = -1.0 + with pytest.raises(ValidationError) as excinfo: + DeviceRegistry.model_validate(registry_doc) + + location = excinfo.value.errors()[0]["loc"] + assert "travel_um" in location + assert "stage" in str(location) + + +def test_by_type(registry_doc: dict[str, Any]) -> None: + """`by_type` filters the registry without the caller writing a comprehension. + + Args: + registry_doc: The valid multi-device document fixture. + """ + registry = DeviceRegistry.model_validate(registry_doc) + assert [dev.name for dev in registry.by_type("camera")] == ["TestCam-2000"] + assert registry.by_type("spectrometer") == [] + + +def test_get_by_id_and_by_name(registry_doc: dict[str, Any]) -> None: + """Lookup accepts the identifier of the unit as well as the model name. + + Args: + registry_doc: The valid multi-device document fixture. + """ + registry = DeviceRegistry.model_validate(registry_doc) + assert registry.get("laser-001").name == "LD-488" + assert registry.get("LD-488").name == "LD-488" + assert registry.get("testcam-001").name == "TestCam-2000" # camera_id, not device_id + + with pytest.raises(KeyError, match="nope"): + registry.get("nope") + + +def test_device_key_precedence() -> None: + """`device_key` bridges `device_id`, the camera's `camera_id`, and the name.""" + stage = StageDevice( + type="stage", + name="XY", + device_id="stage-007", + axes=[{"label": "x", "travel_um": 1.0, "steps_per_um": 1.0}], # type: ignore[list-item] + ) + camera = CameraSchema( + name="Cam", + camera_id="cam-007", + pixelcount={"x": 1, "y": 1}, # type: ignore[arg-type] + pixelpitch_um={"x": 1.0}, # type: ignore[arg-type] + ) + anonymous = LaserDevice(type="laser", name="LD-405", wavelength_nm=405.0, max_power_mw=10.0) + + assert device_key(stage) == "stage-007" + assert device_key(camera) == "cam-007" + assert device_key(anonymous) == "LD-405" + + +def test_duplicate_identifiers_rejected(registry_doc: dict[str, Any]) -> None: + """Two devices with the same key would make `get()` ambiguous. + + Args: + registry_doc: The valid multi-device document fixture. + """ + registry_doc["devices"][2]["device_id"] = "stage-001" + with pytest.raises(ValidationError, match="duplicate identifier 'stage-001'"): + DeviceRegistry.model_validate(registry_doc) + + +def test_duplicate_names_rejected_when_ids_are_absent(registry_doc: dict[str, Any]) -> None: + """Without identifiers the name is the key, so it has to be unique too. + + Args: + registry_doc: The valid multi-device document fixture. + """ + for device in registry_doc["devices"][1:3]: + device.pop("device_id") + device["name"] = "Twin" + with pytest.raises(ValidationError, match="duplicate identifier 'Twin'"): + DeviceRegistry.model_validate(registry_doc) + + +def test_stage_axis_rules(registry_doc: dict[str, Any]) -> None: + """A stage needs at least one axis and cannot list the same label twice. + + Args: + registry_doc: The valid multi-device document fixture. + """ + stage = registry_doc["devices"][1] + + stage["axes"] = [] + with pytest.raises(ValidationError, match="at least 1 item"): + DeviceRegistry.model_validate(registry_doc) + + stage["axes"] = [ + {"label": "x", "travel_um": 1000.0, "steps_per_um": 1.0}, + {"label": "x", "travel_um": 1000.0, "steps_per_um": 1.0}, + ] + with pytest.raises(ValidationError, match="duplicate axis labels"): + DeviceRegistry.model_validate(registry_doc) + + +def test_filterwheel_slot_rules(registry_doc: dict[str, Any]) -> None: + """Two filters cannot sit in the same slot of the wheel. + + Args: + registry_doc: The valid multi-device document fixture. + """ + registry_doc["devices"][3]["slots"][1]["position"] = 0 + with pytest.raises(ValidationError, match="duplicate slot positions"): + DeviceRegistry.model_validate(registry_doc) + + +def test_unknown_root_key_rejected(registry_doc: dict[str, Any]) -> None: + """`extra="forbid"` applies to the registry itself, not just to the devices. + + Args: + registry_doc: The valid multi-device document fixture. + """ + registry_doc["setups"] = "typo" + with pytest.raises(ValidationError, match="setups"): + DeviceRegistry.model_validate(registry_doc) diff --git a/backend/tests/test_schema_export_drift.py b/backend/tests/test_schema_export_drift.py new file mode 100644 index 0000000..3a73659 --- /dev/null +++ b/backend/tests/test_schema_export_drift.py @@ -0,0 +1,97 @@ +"""Guards against drift between the models and the files that ship next to them. + +Two kinds of drift are possible once schemas are exported to disk: + +1. `Configs/schemas/*.json` no longer matches the model it was generated from — + editors and non-Python consumers then validate against a stale contract. +2. A sample config in `Configs/` no longer loads — the documentation would be + describing something that does not work. + +Both checks read the development-only `Configs/` folder (see `Configs/__note__.md`), +so every test here skips when that folder is gone. Regenerate with +``uv run python example_schemas.py`` or `export_camera_schema()` / +`export_devices_schema()`. +""" + +import json +from pathlib import Path + +import pytest + +from newswitch.config import Paths +from newswitch.schemas import CameraSchema, DeviceRegistry, load_config, load_device + +DRAFT = "https://json-schema.org/draft/2020-12/schema" + +CONFIG_DIR = Paths().config_dir +SCHEMA_DIR = Paths().schema_dir + +pytestmark = pytest.mark.skipif( + not CONFIG_DIR.is_dir(), reason=f"development config folder {CONFIG_DIR} is not present" +) + + +def _requires(path: Path) -> Path: + """Skip the test when a sample file is not part of this checkout. + + Args: + path: The file the test needs. + + Returns: + The same path, when it exists. + """ + if not path.is_file(): + pytest.skip(f"{path.name} is not present") + return path + + +@pytest.mark.parametrize( + ("model", "file_name"), + [(CameraSchema, "camera.schema.json"), (DeviceRegistry, "devices.schema.json")], +) +def test_exported_schema_matches_the_model(model: type, file_name: str) -> None: + """The checked-in JSON Schema is exactly what the model generates today. + + Args: + model: The Pydantic model the schema was exported from. + file_name: Name of the exported schema in the schema directory. + """ + on_disk = json.loads(_requires(SCHEMA_DIR / file_name).read_text(encoding="utf-8")) + + expected = model.model_json_schema() + expected["$schema"] = DRAFT + + assert on_disk == expected, ( + f"{file_name} is stale; regenerate it with uv run python example_schemas.py" + ) + + +def test_sample_registry_still_loads() -> None: + """`Devices.yml` is the file the README walks through — it has to work.""" + registry = load_config(_requires(CONFIG_DIR / "Devices.yml")) + + assert registry.setup == "openUC2-bench-01" + assert len(registry.by_type("camera")) == 2 + assert registry.get("laser-488-001").name == "LD-488-100" + + +@pytest.mark.parametrize("file_name", ["hik_mv_ca023_10um.yaml", "hik_mv_ca023_10um.json"]) +def test_sample_camera_still_loads(file_name: str) -> None: + """The single-device sample loads in both formats, with `type` omitted. + + Args: + file_name: The sample file under test. + """ + camera = load_device(_requires(CONFIG_DIR / file_name), expect="camera") + + assert camera.name == "MV-CA023-10UM" + assert camera.pixelpitch_um.y == 10.0 # derived from x + assert camera.sensor_size_mm == pytest.approx((19.2, 12.0)) + + +def test_broken_sample_is_still_broken() -> None: + """`Devices_broken.yml` exists to demonstrate the error output.""" + from newswitch.schemas import ConfigError + + with pytest.raises(ConfigError, match="failed validation"): + load_config(_requires(CONFIG_DIR / "Devices_broken.yml")) diff --git a/backend/tests/test_schema_loader.py b/backend/tests/test_schema_loader.py new file mode 100644 index 0000000..42d4c8e --- /dev/null +++ b/backend/tests/test_schema_loader.py @@ -0,0 +1,337 @@ +"""Tests for `loader`: one entry point for every config layout. + +`load_config` has to turn four document shapes — registry mapping, bare list, single +device with a `type`, single device without one — into the same `DeviceRegistry`, in +JSON and YAML alike, whether it is handed a path or a bare name. +""" + +from pathlib import Path +from typing import Any + +import pytest + +from newswitch.schemas import ( + CameraSchema, + ConfigError, + DeviceRegistry, + LaserDevice, + StageDevice, + dump_config, + load_config, + load_device, +) +from tests.conftest import ConfigWriter + + +def test_registry_file(registry_doc: dict[str, Any], write_config: ConfigWriter) -> None: + """A multi-device file loads as the registry it describes. + + Args: + registry_doc: The valid multi-device document fixture. + write_config: Helper writing a document into the temporary config dir. + """ + registry = load_config(write_config(registry_doc, "Devices.yml")) + + assert isinstance(registry, DeviceRegistry) + assert len(registry.devices) == 4 + assert registry.setup == "test-bench" + + +def test_single_device_file_is_wrapped( + camera_doc: dict[str, Any], write_config: ConfigWriter +) -> None: + """A single-device file yields a one-entry registry, not a special case. + + The camera fixture has no `type` key at all — a bare device document is read as + a camera, mirroring the default of `CameraSchema.type`. + + Args: + camera_doc: The valid camera document fixture. + write_config: Helper writing a document into the temporary config dir. + """ + registry = load_config(write_config(camera_doc, "cam.yaml")) + + assert len(registry.devices) == 1 + assert isinstance(registry.devices[0], CameraSchema) + assert registry.version == 1 + assert registry.setup is None + + +def test_single_non_camera_device_needs_its_type(write_config: ConfigWriter) -> None: + """Any device other than a camera has to spell out `type:`. + + Args: + write_config: Helper writing a document into the temporary config dir. + """ + stage_doc = { + "type": "stage", + "name": "XY-Test", + "device_id": "stage-001", + "axes": [{"label": "x", "travel_um": 1000.0, "steps_per_um": 8.0}], + } + registry = load_config(write_config(stage_doc, "stage.yaml")) + assert isinstance(registry.devices[0], StageDevice) + + # Without the discriminator the same document is read as a camera and fails. + del stage_doc["type"] + with pytest.raises(ConfigError, match="pixelcount"): + load_config(write_config(stage_doc, "typeless.yaml")) + + +def test_bare_device_list(registry_doc: dict[str, Any], write_config: ConfigWriter) -> None: + """A file holding just the list of devices, without the wrapper, is accepted. + + Args: + registry_doc: The valid multi-device document fixture. + write_config: Helper writing a document into the temporary config dir. + """ + registry = load_config(write_config(registry_doc["devices"], "bare_list.yaml")) + assert len(registry.devices) == 4 + + +def test_devices_key_wins_over_device_fields( + registry_doc: dict[str, Any], write_config: ConfigWriter +) -> None: + """A document with both `devices` and `name` is a registry, not a device. + + Args: + registry_doc: The valid multi-device document fixture. + write_config: Helper writing a document into the temporary config dir. + """ + registry_doc["setup"] = "named-setup" + registry = load_config(write_config(registry_doc, "both.yaml")) + assert len(registry.devices) == 4 + + +@pytest.mark.parametrize("name", ["Devices.yml", "Devices", "Devices.json"]) +def test_name_resolution( + registry_doc: dict[str, Any], write_config: ConfigWriter, name: str +) -> None: + """Bare names resolve against the config dir, with or without a suffix. + + Args: + registry_doc: The valid multi-device document fixture. + write_config: Helper writing a document into the temporary config dir. + name: The name handed to the loader. + """ + write_config(registry_doc, "Devices.yml") + write_config(registry_doc, "Devices.json") + assert len(load_config(name).devices) == 4 + + +def test_explicit_path_bypasses_the_config_dir( + registry_doc: dict[str, Any], write_config: ConfigWriter, tmp_path: Path +) -> None: + """A path with a directory part is used as given, outside the managed folder. + + Args: + registry_doc: The valid multi-device document fixture. + write_config: Helper writing a document into the temporary config dir. + tmp_path: Pytest's built-in temporary path fixture. + """ + write_config(registry_doc, "Devices.yml") # decoy in the config dir + + elsewhere = tmp_path / "elsewhere" / "Devices.yml" + elsewhere.parent.mkdir() + elsewhere.write_text("devices: []\nsetup: other\n", encoding="utf-8") + + assert load_config(elsewhere).setup == "other" + + +def test_missing_file_lists_the_candidates() -> None: + """A name that resolves to nothing reports every extension it tried.""" + with pytest.raises(ConfigError) as excinfo: + load_config("no_such_setup") + + message = str(excinfo.value) + assert ".yaml" in message and ".yml" in message and ".json" in message + + +def test_single_device_errors_drop_the_index_prefix(write_config: ConfigWriter) -> None: + """Errors of a wrapped file read the way the file looks, without `devices[0]`. + + Args: + write_config: Helper writing a document into the temporary config dir. + """ + path = write_config( + {"name": "BadCam", "pixelcount": {"x": -5}, "pixelpitch_um": {"x": 3.45}, "colour": "red"}, + "badcam.yaml", + ) + with pytest.raises(ConfigError) as excinfo: + load_config(path) + + message = str(excinfo.value) + assert "devices[0]" not in message + assert "pixelcount.x: -5 is less than or equal to the minimum of 0" in message + assert ": Additional properties are not allowed ('colour' was unexpected)" in message + + +def test_registry_errors_keep_the_index_prefix( + registry_doc: dict[str, Any], write_config: ConfigWriter +) -> None: + """Multi-device files still say which entry is at fault. + + Args: + registry_doc: The valid multi-device document fixture. + write_config: Helper writing a document into the temporary config dir. + """ + registry_doc["devices"][1]["axes"] = [] + with pytest.raises(ConfigError, match=r"devices\[1\].axes"): + load_config(write_config(registry_doc, "Devices.yml")) + + +def test_non_mapping_document_rejected(write_config: ConfigWriter) -> None: + """A scalar at the top level is neither a device nor a registry. + + Args: + write_config: Helper writing a document into the temporary config dir. + """ + path = write_config("just a string", "scalar.yaml") + with pytest.raises(ConfigError, match="expected a mapping or a list"): + load_config(path) + + +def test_load_device_from_a_single_device_file( + camera_doc: dict[str, Any], write_config: ConfigWriter +) -> None: + """With exactly one device in the file, no key is needed. + + Args: + camera_doc: The valid camera document fixture. + write_config: Helper writing a document into the temporary config dir. + """ + write_config(camera_doc, "testcam.yaml") + camera = load_device("testcam") + + assert isinstance(camera, CameraSchema) + assert camera.sensor_size_mm == pytest.approx((19.2, 12.0)) + + +def test_load_device_by_key(registry_doc: dict[str, Any], write_config: ConfigWriter) -> None: + """Out of a registry, the key selects the device — by id or by name. + + Args: + registry_doc: The valid multi-device document fixture. + write_config: Helper writing a document into the temporary config dir. + """ + write_config(registry_doc, "Devices.yml") + + assert isinstance(load_device("Devices.yml", "laser-001"), LaserDevice) + assert isinstance(load_device("Devices.yml", "LD-488"), LaserDevice) + assert load_device("Devices.yml", "testcam-001").name == "TestCam-2000" + + +def test_load_device_without_key_is_ambiguous( + registry_doc: dict[str, Any], write_config: ConfigWriter +) -> None: + """Several devices and no key is an error that lists the available keys. + + Args: + registry_doc: The valid multi-device document fixture. + write_config: Helper writing a document into the temporary config dir. + """ + write_config(registry_doc, "Devices.yml") + with pytest.raises(ConfigError) as excinfo: + load_device("Devices.yml") + + message = str(excinfo.value) + assert "holds 4 devices" in message + assert "laser-001" in message + + +def test_load_device_unknown_key(registry_doc: dict[str, Any], write_config: ConfigWriter) -> None: + """An unknown key is reported together with what is actually in the file. + + Args: + registry_doc: The valid multi-device document fixture. + write_config: Helper writing a document into the temporary config dir. + """ + write_config(registry_doc, "Devices.yml") + with pytest.raises(ConfigError, match="no device 'nope'"): + load_device("Devices.yml", "nope") + + +def test_load_device_expect_type(registry_doc: dict[str, Any], write_config: ConfigWriter) -> None: + """`expect` turns a wrong device type into an error at the load site. + + Args: + registry_doc: The valid multi-device document fixture. + write_config: Helper writing a document into the temporary config dir. + """ + write_config(registry_doc, "Devices.yml") + + assert load_device("Devices.yml", "testcam-001", expect="camera").type == "camera" + + with pytest.raises(ConfigError, match="is a laser, expected a camera"): + load_device("Devices.yml", "laser-001", expect="camera") + + with pytest.raises(ConfigError, match="unknown device type 'spectrometer'"): + load_device("Devices.yml", "laser-001", expect="spectrometer") + + +@pytest.mark.parametrize("suffix", [".yaml", ".yml", ".json"]) +def test_dump_config_round_trips_a_registry( + registry_doc: dict[str, Any], write_config: ConfigWriter, suffix: str +) -> None: + """A registry can be stored in any supported format and read back unchanged. + + Args: + registry_doc: The valid multi-device document fixture. + write_config: Helper writing a document into the temporary config dir. + suffix: Output format under test. + """ + registry = load_config(write_config(registry_doc, "Devices.yml")) + written = dump_config(registry, f"copy{suffix}") + + assert load_config(written) == registry + + +def test_dump_config_stores_a_bare_name_in_the_config_dir( + registry_doc: dict[str, Any], write_config: ConfigWriter, config_dir: Path +) -> None: + """Writing follows the same folder rules as reading. + + Args: + registry_doc: The valid multi-device document fixture. + write_config: Helper writing a document into the temporary config dir. + config_dir: The temporary config directory fixture. + """ + registry = load_config(write_config(registry_doc, "Devices.yml")) + assert dump_config(registry, "backup.yml") == config_dir / "backup.yml" + + +def test_dump_config_stores_a_single_device( + camera_doc: dict[str, Any], write_config: ConfigWriter +) -> None: + """A camera is stored as a single-device file and reloads as one. + + Args: + camera_doc: The valid camera document fixture. + write_config: Helper writing a document into the temporary config dir. + """ + camera = load_device(write_config(camera_doc, "cam.yaml")) + written = dump_config(camera, "camera_copy.yaml") + + assert load_device(written) == camera + + +def test_dump_config_wraps_a_non_camera_device( + registry_doc: dict[str, Any], write_config: ConfigWriter +) -> None: + """Other device types have no single-device writer, so they get a registry file. + + Args: + registry_doc: The valid multi-device document fixture. + write_config: Helper writing a document into the temporary config dir. + """ + write_config(registry_doc, "Devices.yml") + stage = load_device("Devices.yml", "stage-001") + written = dump_config(stage, "stage_only.yaml") + + assert load_device(written) == stage + + +def test_dump_config_rejects_other_objects() -> None: + """Handing `dump_config` something that is not a device fails clearly.""" + with pytest.raises(ConfigError, match="cannot store"): + dump_config(CameraSchema.model_json_schema(), "nope.yaml") # type: ignore[arg-type] diff --git a/backend/uv.lock b/backend/uv.lock index 01786c6..48084ab 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1023,6 +1023,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "kabinet" version = "1.0.3" @@ -1254,13 +1281,16 @@ source = { editable = "." } dependencies = [ { name = "aiosqlite" }, { name = "fastapi" }, + { name = "jsonschema" }, { name = "kaffine" }, { name = "lz4" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "pillow" }, { name = "pint" }, + { name = "platformdirs" }, { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "pyserial-asyncio" }, { name = "pystackreg" }, { name = "pyyaml" }, @@ -1305,12 +1335,15 @@ requires-dist = [ { name = "aiosqlite", specifier = ">=0.22.1" }, { name = "annotated-types", marker = "extra == 'types'", specifier = ">=0.5.0" }, { name = "fastapi", specifier = ">=0.128.0" }, + { name = "jsonschema", specifier = ">=4.18" }, { name = "kaffine", specifier = "==1.1.0" }, { name = "lz4", specifier = ">=4.4.5" }, { name = "numpy", specifier = ">=2.4.1" }, { name = "pillow", specifier = ">=12.1.0" }, { name = "pint", specifier = ">=0.25.2" }, + { name = "platformdirs", specifier = ">=4" }, { name = "pydantic", specifier = ">=2.11.4" }, + { name = "pydantic-settings", specifier = ">=2" }, { name = "pyserial-asyncio", specifier = ">=0.6" }, { name = "pystackreg", specifier = ">=0.2.8" }, { name = "pyyaml", specifier = ">=6" }, @@ -2272,6 +2305,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3d/ca/36133653e00939922dd1416f4c56177361289172a30563fcb9552c9ccde4/readchar-4.2.2-py3-none-any.whl", hash = "sha256:92daf7e42c52b0787e6c75d01ecfb9a94f4ceff3764958b570c1dddedd47b200", size = 9401, upload-time = "2026-04-06T19:45:52.993Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "rekuest-next" version = "2.2.1" @@ -2334,6 +2381,129 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/97/a87901aef6b7e7e4a34c6dd6cc17dca8594a592ef9d9dd765fca2b7facf7/rich_click-1.9.8-py3-none-any.whl", hash = "sha256:12873865396e6927835d4eabb1cc3996edcd65b7ac9b2391a29eca4f335a2f93", size = 72189, upload-time = "2026-05-28T19:54:57.867Z" }, ] +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + [[package]] name = "ruff" version = "0.15.21" From cfff15c3791006060a2fc7ce238d0755148453c8 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 27 Aug 2026 19:41:01 +0200 Subject: [PATCH 02/24] fix(config.py): fixed _REPO_ROOT for correctly accessing env --- backend/newswitch/config.py | 25 ++++++++++++-- backend/tests/test_config_paths.py | 53 ++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/backend/newswitch/config.py b/backend/newswitch/config.py index f32cf5d..8555c1c 100644 --- a/backend/newswitch/config.py +++ b/backend/newswitch/config.py @@ -21,6 +21,11 @@ # name is given without one. CONFIG_SUFFIXES = (".yaml", ".yml", ".json") +# Repository root, derived from this module's location rather than from the working +# directory, so the same .env is read no matter where the process was started. +# Outside a checkout the files simply do not exist and are skipped. +_REPO_ROOT = Path(__file__).parent.parent.parent + def _systemd_dir(env_var: str) -> Path | None: # systemd may pass a colon-separated list; the first entry is ours. @@ -71,11 +76,27 @@ class Paths(BaseSettings): Every field can be overridden with its own environment variable (``NEWSWITCH_CONFIG_DIR``, ``NEWSWITCH_SCHEMA_DIR``, ...). The four are independent, so redirecting `config_dir` alone does not move the others. + + Note the ``extra="ignore"``: this class shares the repository's .env with the + rest of the stack, so an unknown NEWSWITCH_* variable is silently ignored + rather than reported. A typo like NEWSWITCH_CONFIGG_DIR therefore has no + effect instead of raising. """ - # this is same like model_config = {"env_prefix": "NEWSWITCH_", "env_file": ".env"}, but with type checking and autocompletion + # this is same like model_config = {"env_prefix": "NEWSWITCH_", "env_file": ...}, but with type checking and autocompletion # extends BaseSettings to allow for environment variable overrides and .env file loading - model_config = SettingsConfigDict(env_prefix="NEWSWITCH_", env_file=".env") + # + # env_file as an absolute path: a relative ".env" is resolved against the + # *current working directory*, so a debugger started in the repo root would read + # a different file than `uv run` started in backend/. + # extra="ignore": that shared .env also carries BACKEND_PORT & co., and + # pydantic-settings passes every foreign entry straight through when + # extra="forbid" (its BaseSettings default), which rejects them as extra inputs. + model_config = SettingsConfigDict( + env_prefix="NEWSWITCH_", + env_file=(_REPO_ROOT / ".env", _REPO_ROOT / "backend" / ".env"), + extra="ignore", + ) # config_dir: Path = _systemd_dir("CONFIGURATION_DIRECTORY") or user_config_path(APP_NAME) # data_dir: Path = _systemd_dir("STATE_DIRECTORY") or user_data_path(APP_NAME) diff --git a/backend/tests/test_config_paths.py b/backend/tests/test_config_paths.py index d42c4e6..f3c0e08 100644 --- a/backend/tests/test_config_paths.py +++ b/backend/tests/test_config_paths.py @@ -29,6 +29,59 @@ def test_get_paths_is_cached() -> None: assert get_paths() is get_paths() +FOREIGN_ENV = "BACKEND_HOST=0.0.0.0\nBACKEND_PORT=8069\nFRONTEND_PORT=5473\n" + + +def test_working_directory_does_not_matter(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """`Paths()` must not depend on where the process was started. + + The repository root holds a shared `.env` with the stack's ports. A relative + ``env_file`` would be resolved against the working directory, so a debugger + launched from the repo root used to read that file and crash on its entries, + while `uv run` from `backend/` found no `.env` at all. + + Args: + tmp_path: Pytest's built-in temporary path fixture. + monkeypatch: Pytest's environment patcher. + """ + (tmp_path / ".env").write_text(FOREIGN_ENV, encoding="utf-8") + monkeypatch.chdir(tmp_path) + + assert Paths().config_dir.name == "Configs" + + +def test_foreign_env_file_entries_are_ignored(tmp_path: Path) -> None: + """Variables of the shared `.env` that are not settings must be ignored. + + With `extra="forbid"` — the `BaseSettings` default — pydantic-settings hands every + entry of the dotenv file to the model unchanged, prefix or not, and the model then + rejects them as extra inputs. + + Args: + tmp_path: Pytest's built-in temporary path fixture. + """ + env_file = tmp_path / ".env" + env_file.write_text(FOREIGN_ENV, encoding="utf-8") + + paths = Paths(_env_file=env_file) # type: ignore[call-arg] + + assert paths.config_dir.name == "Configs" + + +def test_env_file_can_still_set_a_field(tmp_path: Path) -> None: + """Ignoring foreign keys must not stop a NEWSWITCH_* entry from being read. + + Args: + tmp_path: Pytest's built-in temporary path fixture. + """ + env_file = tmp_path / ".env" + env_file.write_text(f"{FOREIGN_ENV}NEWSWITCH_CONFIG_DIR={tmp_path / 'own'}\n", encoding="utf-8") + + paths = Paths(_env_file=env_file) # type: ignore[call-arg] + + assert paths.config_dir == tmp_path / "own" + + def test_environment_overrides(config_dir: Path) -> None: """Each directory can be redirected with its own NEWSWITCH_* variable. From 33118e298ac9a68470990845006b9b467fd894f9 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 27 Aug 2026 22:37:14 +0200 Subject: [PATCH 03/24] docs(note): docs ammended a bit --- backend/Configs/__note__.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/Configs/__note__.md b/backend/Configs/__note__.md index a1af684..75b31e6 100644 --- a/backend/Configs/__note__.md +++ b/backend/Configs/__note__.md @@ -2,3 +2,5 @@ KC 24.08.26: This is only temporarily! Once the deployment is set, the configs.py should be adapted in order to access the final file paths! +Infos w.r.t. the Schemas (Design decissions): + - If the Device.yml contains parameters, that are not in the Schema, the list is rejected. The reason is not to have quite acceptance in terms of typos etc. From 57c440398c1a2c3b0622dc019c4efa17717f51d6 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 28 Aug 2026 01:29:59 +0200 Subject: [PATCH 04/24] refactor(schema): major refactoring w.r.t camera device --- backend/Configs/Devices.yml | 4 +- backend/Configs/data/Devices.modified.json | 45 +- backend/Configs/data/Devices.modified.yml | 34 +- backend/Configs/data/hik_mv_ca023_10um.json | 46 ++ .../data/hik_mv_ca023_10um.modified.yaml | 70 ++- backend/Configs/hik_mv_ca023_10um.json | 32 -- backend/Configs/hik_mv_ca023_10um.yaml | 7 +- backend/Configs/schemas/camera.schema.json | 376 --------------- backend/Configs/schemas/devices.schema.json | 165 +++++-- backend/example_schemas.py | 66 +-- backend/newswitch/schemas/__init__.py | 70 +-- backend/newswitch/schemas/bounded.py | 130 ----- backend/newswitch/schemas/camera_io.py | 148 ------ backend/newswitch/schemas/camera_schema.py | 168 ------- backend/newswitch/schemas/device_io.py | 92 ++-- backend/newswitch/schemas/device_schema.py | 445 +++++++++++++++--- backend/newswitch/schemas/document.py | 8 +- backend/newswitch/schemas/errors.py | 11 +- backend/newswitch/schemas/loader.py | 118 ++++- backend/newswitch/schemas/schema_io.py | 155 ++++++ backend/tests/conftest.py | 9 +- backend/tests/test_camera_io.py | 210 --------- backend/tests/test_camera_schema.py | 206 -------- backend/tests/test_device_io.py | 6 +- backend/tests/test_device_schema.py | 26 +- backend/tests/test_schema_export_drift.py | 22 +- backend/tests/test_schema_loader.py | 60 ++- 27 files changed, 1105 insertions(+), 1624 deletions(-) create mode 100644 backend/Configs/data/hik_mv_ca023_10um.json delete mode 100644 backend/Configs/hik_mv_ca023_10um.json delete mode 100644 backend/Configs/schemas/camera.schema.json delete mode 100644 backend/newswitch/schemas/bounded.py delete mode 100644 backend/newswitch/schemas/camera_io.py delete mode 100644 backend/newswitch/schemas/camera_schema.py create mode 100644 backend/newswitch/schemas/schema_io.py delete mode 100644 backend/tests/test_camera_io.py delete mode 100644 backend/tests/test_camera_schema.py diff --git a/backend/Configs/Devices.yml b/backend/Configs/Devices.yml index 38db1c6..109e7eb 100644 --- a/backend/Configs/Devices.yml +++ b/backend/Configs/Devices.yml @@ -7,7 +7,7 @@ devices: - type: camera name: MV-CA023-10UM manufacturer: HIK ROBOTICS - camera_id: hik-mv-ca023-001 + device_id: hik-mv-ca023-001 pixelcount: x: 1920 y: 1200 @@ -37,7 +37,7 @@ devices: - type: camera name: U3-3800CP-C-HQ manufacturer: IDS Imaging - camera_id: ids-u3-3800-002 + device_id: ids-u3-3800-002 pixelcount: x: 3088 y: 2076 diff --git a/backend/Configs/data/Devices.modified.json b/backend/Configs/data/Devices.modified.json index a20f564..79b960d 100644 --- a/backend/Configs/data/Devices.modified.json +++ b/backend/Configs/data/Devices.modified.json @@ -3,21 +3,21 @@ "setup": "openUC2-bench-01", "devices": [ { - "type": "camera", "name": "MV-CA023-10UM", "manufacturer": "HIK ROBOTICS", - "camera_id": "hik-mv-ca023-001", + "device_id": "hik-mv-ca023-001", + "tags": [ + "monochrome", + "global-shutter", + "gige" + ], + "type": "camera", "pixelcount": { "x": 1920, "y": 1200 }, "pixelpitch_um": { - "x": 10.0, - "y": 10.0 - }, - "pixelsize_um": { - "x": 10.0, - "y": 10.0 + "x": 10.0 }, "channels": 1, "exposure_time_ms": { @@ -41,29 +41,27 @@ "trigger": { "type": "hardware", "edge": "rising" - }, - "tags": [ - "monochrome", - "global-shutter", - "gige" - ] + } }, { - "type": "camera", "name": "U3-3800CP-C-HQ", "manufacturer": "IDS Imaging", - "camera_id": "ids-u3-3800-002", + "device_id": "ids-u3-3800-002", + "tags": [ + "color", + "rolling-shutter", + "usb3" + ], + "type": "camera", "pixelcount": { "x": 3088, "y": 2076 }, "pixelpitch_um": { - "x": 2.4, - "y": 2.4 + "x": 2.4 }, "pixelsize_um": { - "x": 2.2, - "y": 2.2 + "x": 2.2 }, "channels": 3, "exposure_time_ms": { @@ -72,12 +70,7 @@ "framerate_per_sec": { "value": 30.0 }, - "binning": 1, - "tags": [ - "color", - "rolling-shutter", - "usb3" - ] + "binning": 1 }, { "name": "XY-Linear-100", diff --git a/backend/Configs/data/Devices.modified.yml b/backend/Configs/data/Devices.modified.yml index 666c29e..bd16753 100644 --- a/backend/Configs/data/Devices.modified.yml +++ b/backend/Configs/data/Devices.modified.yml @@ -1,19 +1,19 @@ version: 1 setup: openUC2-bench-01 devices: -- type: camera - name: MV-CA023-10UM +- name: MV-CA023-10UM manufacturer: HIK ROBOTICS - camera_id: hik-mv-ca023-001 + device_id: hik-mv-ca023-001 + tags: + - monochrome + - global-shutter + - gige + type: camera pixelcount: x: 1920 y: 1200 pixelpitch_um: x: 10.0 - y: 10.0 - pixelsize_um: - x: 10.0 - y: 10.0 channels: 1 exposure_time_ms: value: 25.0 @@ -33,33 +33,27 @@ devices: trigger: type: hardware edge: rising - tags: - - monochrome - - global-shutter - - gige -- type: camera - name: U3-3800CP-C-HQ +- name: U3-3800CP-C-HQ manufacturer: IDS Imaging - camera_id: ids-u3-3800-002 + device_id: ids-u3-3800-002 + tags: + - color + - rolling-shutter + - usb3 + type: camera pixelcount: x: 3088 y: 2076 pixelpitch_um: x: 2.4 - y: 2.4 pixelsize_um: x: 2.2 - y: 2.2 channels: 3 exposure_time_ms: value: 20.0 framerate_per_sec: value: 30.0 binning: 1 - tags: - - color - - rolling-shutter - - usb3 - name: XY-Linear-100 manufacturer: openUC2 device_id: stage-xy-001 diff --git a/backend/Configs/data/hik_mv_ca023_10um.json b/backend/Configs/data/hik_mv_ca023_10um.json new file mode 100644 index 0000000..39618d9 --- /dev/null +++ b/backend/Configs/data/hik_mv_ca023_10um.json @@ -0,0 +1,46 @@ +{ + "version": 1, + "devices": [ + { + "name": "MV-CA023-10UM", + "manufacturer": "HIK ROBOTICS", + "device_id": "hik-mv-ca023-001", + "tags": [ + "monochrome", + "global-shutter", + "gige" + ], + "type": "camera", + "pixelcount": { + "x": 1920, + "y": 1200 + }, + "pixelpitch_um": { + "x": 10.0 + }, + "channels": 1, + "exposure_time_ms": { + "value": 10.0, + "min": 0.024, + "max": 10000.0, + "increment": 0.001 + }, + "framerate_per_sec": { + "value": 163.0, + "min": 0.5, + "max": 163.0 + }, + "gain_db": { + "value": 0.0, + "min": 0.0, + "max": 23.98, + "increment": 0.01 + }, + "binning": 1, + "trigger": { + "type": "hardware", + "edge": "rising" + } + } + ] +} diff --git a/backend/Configs/data/hik_mv_ca023_10um.modified.yaml b/backend/Configs/data/hik_mv_ca023_10um.modified.yaml index 27f3e00..d5ace1d 100644 --- a/backend/Configs/data/hik_mv_ca023_10um.modified.yaml +++ b/backend/Configs/data/hik_mv_ca023_10um.modified.yaml @@ -1,36 +1,34 @@ -type: camera -name: MV-CA023-10UM -manufacturer: HIK ROBOTICS -camera_id: hik-mv-ca023-001 -pixelcount: - x: 1920 - y: 1200 -pixelpitch_um: - x: 10.0 - y: 10.0 -pixelsize_um: - x: 10.0 - y: 10.0 -channels: 1 -exposure_time_ms: - value: 25.0 - min: 0.024 - max: 10000.0 - increment: 0.001 -framerate_per_sec: - value: 163.0 - min: 0.5 - max: 163.0 -gain_db: - value: 0.0 - min: 0.0 - max: 23.98 - increment: 0.01 -binning: 1 -trigger: - type: hardware - edge: rising -tags: -- monochrome -- global-shutter -- gige +version: 1 +devices: +- name: MV-CA023-10UM + manufacturer: HIK ROBOTICS + device_id: hik-mv-ca023-001 + tags: + - monochrome + - global-shutter + - gige + type: camera + pixelcount: + x: 1920 + y: 1200 + pixelpitch_um: + x: 10.0 + channels: 1 + exposure_time_ms: + value: 25.0 + min: 0.024 + max: 10000.0 + increment: 0.001 + framerate_per_sec: + value: 163.0 + min: 0.5 + max: 163.0 + gain_db: + value: 0.0 + min: 0.0 + max: 23.98 + increment: 0.01 + binning: 1 + trigger: + type: hardware + edge: rising diff --git a/backend/Configs/hik_mv_ca023_10um.json b/backend/Configs/hik_mv_ca023_10um.json deleted file mode 100644 index aced8e3..0000000 --- a/backend/Configs/hik_mv_ca023_10um.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "$schema": "./camera.schema.json", - "name": "MV-CA023-10UM", - "manufacturer": "HIK ROBOTICS", - "camera_id": "hik-mv-ca023-001", - "pixelcount": { - "x": 1920, - "y": 1200 - }, - "pixelpitch_um": { - "x": 10.0 - }, - "channels": 1, - "exposure_time_ms": { - "value": 10.0, - "min": 0.024, - "max": 10000.0, - "increment": 0.001 - }, - "framerate_per_sec": { - "value": 163.0, - "min": 0.5, - "max": 163.0 - }, - "gain_db": 0.0, - "binning": 1, - "trigger": { - "type": "hardware", - "edge": "rising" - }, - "tags": ["monochrome", "global-shutter", "gige"] -} diff --git a/backend/Configs/hik_mv_ca023_10um.yaml b/backend/Configs/hik_mv_ca023_10um.yaml index cbdf8d8..1948e45 100644 --- a/backend/Configs/hik_mv_ca023_10um.yaml +++ b/backend/Configs/hik_mv_ca023_10um.yaml @@ -1,7 +1,10 @@ -# HIKROBOT MV-CA023-10UM - same content as the JSON variant +# HIKROBOT MV-CA023-10UM - a single-device file. +# `type` is required: no device model defaults it, so a document without it is +# read as an opaque UnknownDevice rather than guessed at. +type: camera name: MV-CA023-10UM manufacturer: HIK ROBOTICS -camera_id: hik-mv-ca023-001 +device_id: hik-mv-ca023-001 pixelcount: x: 1920 diff --git a/backend/Configs/schemas/camera.schema.json b/backend/Configs/schemas/camera.schema.json deleted file mode 100644 index 4aa618c..0000000 --- a/backend/Configs/schemas/camera.schema.json +++ /dev/null @@ -1,376 +0,0 @@ -{ - "$defs": { - "Bounded": { - "anyOf": [ - { - "description": "Shorthand for {value: }", - "type": "number" - }, - { - "additionalProperties": false, - "description": "A numeric parameter together with the device limits that apply to it.\n\nAll four members are optional, which covers every practical case:\n\n* ``{value: 10}`` -- setting, limits unknown\n* ``{min: 0.024, max: 10000}`` -- capability only, no default set\n* ``{value: 10, min: 0.024, max: 1e4}`` -- both\n* ``null`` / omitted -- parameter not applicable", - "properties": { - "value": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Current or default value", - "title": "Value" - }, - "min": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Smallest settable value", - "title": "Min" - }, - "max": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Largest settable value", - "title": "Max" - }, - "increment": { - "anyOf": [ - { - "exclusiveMinimum": 0, - "type": "number" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Step size; settable values are min + n*increment", - "title": "Increment" - }, - "unit": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Override for the unit implied by the field name", - "title": "Unit" - } - }, - "title": "Bounded", - "type": "object" - } - ], - "description": "A numeric parameter together with the device limits that apply to it." - }, - "Trigger": { - "additionalProperties": false, - "description": "Trigger configuration. Omit the whole object for free-running mode.", - "properties": { - "type": { - "enum": [ - "software", - "hardware" - ], - "title": "Type", - "type": "string" - }, - "edge": { - "anyOf": [ - { - "enum": [ - "rising", - "falling" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Edge" - }, - "level": { - "anyOf": [ - { - "enum": [ - "high", - "low" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Level" - } - }, - "required": [ - "type" - ], - "title": "Trigger", - "type": "object" - }, - "Vector2Float": { - "additionalProperties": false, - "description": "Float pair where y is optional and falls back to x (square geometry).", - "properties": { - "x": { - "description": "Value along x in micrometers", - "exclusiveMinimum": 0, - "title": "X", - "type": "number" - }, - "y": { - "anyOf": [ - { - "exclusiveMinimum": 0, - "type": "number" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Value along y; defaults to x when omitted", - "title": "Y" - } - }, - "required": [ - "x" - ], - "title": "Vector2Float", - "type": "object" - }, - "Vector2Int": { - "additionalProperties": false, - "description": "Integer pair, both components required (e.g. pixel counts).", - "properties": { - "x": { - "description": "Extent along x in pixels", - "exclusiveMinimum": 0, - "title": "X", - "type": "integer" - }, - "y": { - "description": "Extent along y in pixels", - "exclusiveMinimum": 0, - "title": "Y", - "type": "integer" - } - }, - "required": [ - "x", - "y" - ], - "title": "Vector2Int", - "type": "object" - } - }, - "additionalProperties": false, - "description": "Full camera description.", - "properties": { - "$schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional pointer to the schema file, for editor autocomplete", - "title": "$Schema" - }, - "type": { - "const": "camera", - "default": "camera", - "description": "Discriminator used in multi-device files", - "title": "Type", - "type": "string" - }, - "name": { - "description": "Model designation, e.g. MV-CA023-10UM", - "title": "Name", - "type": "string" - }, - "manufacturer": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Vendor name", - "title": "Manufacturer" - }, - "camera_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Unique identifier of the physical unit", - "title": "Camera Id" - }, - "pixelcount": { - "$ref": "#/$defs/Vector2Int", - "description": "Sensor resolution in pixels" - }, - "pixelpitch_um": { - "$ref": "#/$defs/Vector2Float", - "description": "Center-to-center pixel distance; y defaults to x" - }, - "pixelsize_um": { - "anyOf": [ - { - "$ref": "#/$defs/Vector2Float" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Photosensitive area per pixel; defaults to pixelpitch_um" - }, - "channels": { - "default": 1, - "description": "Color/spectral channels", - "minimum": 1, - "title": "Channels", - "type": "integer" - }, - "exposure_time_ms": { - "anyOf": [ - { - "$ref": "#/$defs/Bounded" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Integration time in ms, with device limits" - }, - "framerate_per_sec": { - "anyOf": [ - { - "$ref": "#/$defs/Bounded" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Frame rate in Hz, with device limits" - }, - "gain_db": { - "anyOf": [ - { - "$ref": "#/$defs/Bounded" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Analog gain in dB, with device limits" - }, - "binning": { - "default": 1, - "enum": [ - 1, - 2, - 4 - ], - "title": "Binning", - "type": "integer" - }, - "roi": { - "anyOf": [ - { - "maxItems": 4, - "minItems": 4, - "prefixItems": [ - { - "type": "integer" - }, - { - "type": "integer" - }, - { - "type": "integer" - }, - { - "type": "integer" - } - ], - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Region of interest as (x, y, width, height)", - "title": "Roi" - }, - "trigger": { - "anyOf": [ - { - "$ref": "#/$defs/Trigger" - }, - { - "type": "null" - } - ], - "default": null - }, - "tags": { - "items": { - "type": "string" - }, - "title": "Tags", - "type": "array" - } - }, - "required": [ - "name", - "pixelcount", - "pixelpitch_um" - ], - "title": "CameraSchema", - "type": "object", - "$schema": "https://json-schema.org/draft/2020-12/schema" -} diff --git a/backend/Configs/schemas/devices.schema.json b/backend/Configs/schemas/devices.schema.json index dde721d..ba654a5 100644 --- a/backend/Configs/schemas/devices.schema.json +++ b/backend/Configs/schemas/devices.schema.json @@ -1,4 +1,5 @@ { + "$schema": "https://json-schema.org/draft/2020-12/schema", "$defs": { "Bounded": { "anyOf": [ @@ -8,7 +9,7 @@ }, { "additionalProperties": false, - "description": "A numeric parameter together with the device limits that apply to it.\n\nAll four members are optional, which covers every practical case:\n\n* ``{value: 10}`` -- setting, limits unknown\n* ``{min: 0.024, max: 10000}`` -- capability only, no default set\n* ``{value: 10, min: 0.024, max: 1e4}`` -- both\n* ``null`` / omitted -- parameter not applicable", + "description": "A numeric parameter together with the device limits that apply to it.\n\nAccepts a bare number as shorthand for ``{\"value\": }``, so both of\nthese are valid and equivalent::\n\n exposure_time_ms: 10.0\n exposure_time_ms: {value: 10.0}\n\nAll members are optional, which covers every practical case:\n\n* ``{value: 10}`` -- setting, limits unknown\n* ``{min: 0.024, max: 10000}`` -- capability only, no value chosen\n* ``{value: 10, min: 0.024, max: 1e4}`` -- both\n* ``null`` / omitted -- parameter not applicable", "properties": { "value": { "anyOf": [ @@ -81,32 +82,12 @@ "type": "object" } ], - "description": "A numeric parameter together with the device limits that apply to it." + "description": "Numeric parameter with its device limits" }, - "CameraSchema": { + "CameraDevice": { "additionalProperties": false, - "description": "Full camera description.", + "description": "Imaging sensor.", "properties": { - "$schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Optional pointer to the schema file, for editor autocomplete", - "title": "$Schema" - }, - "type": { - "const": "camera", - "default": "camera", - "description": "Discriminator used in multi-device files", - "title": "Type", - "type": "string" - }, "name": { "description": "Model designation, e.g. MV-CA023-10UM", "title": "Name", @@ -125,7 +106,7 @@ "description": "Vendor name", "title": "Manufacturer" }, - "camera_id": { + "device_id": { "anyOf": [ { "type": "string" @@ -135,8 +116,20 @@ } ], "default": null, - "description": "Unique identifier of the physical unit", - "title": "Camera Id" + "description": "Identifier of the individual physical unit", + "title": "Device Id" + }, + "tags": { + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "type": { + "const": "camera", + "title": "Type", + "type": "string" }, "pixelcount": { "$ref": "#/$defs/Vector2Int", @@ -156,7 +149,7 @@ } ], "default": null, - "description": "Photosensitive area per pixel; defaults to pixelpitch_um" + "description": "Photosensitive area per pixel; omit when the datasheet gives none, then a fill factor of 1.0 is assumed" }, "channels": { "default": 1, @@ -250,25 +243,20 @@ } ], "default": null - }, - "tags": { - "items": { - "type": "string" - }, - "title": "Tags", - "type": "array" } }, "required": [ "name", + "type", "pixelcount", "pixelpitch_um" ], - "title": "CameraSchema", + "title": "CameraDevice", "type": "object" }, "FilterSlot": { "additionalProperties": false, + "description": "One slot of a filter wheel.", "properties": { "position": { "description": "Slot index on the wheel", @@ -320,7 +308,7 @@ "description": "Motorized filter wheel.", "properties": { "name": { - "description": "Model designation", + "description": "Model designation, e.g. MV-CA023-10UM", "title": "Name", "type": "string" }, @@ -397,7 +385,7 @@ "description": "Single-wavelength illumination source.", "properties": { "name": { - "description": "Model designation", + "description": "Model designation, e.g. MV-CA023-10UM", "title": "Name", "type": "string" }, @@ -478,6 +466,7 @@ }, "StageAxis": { "additionalProperties": false, + "description": "One motorized axis of a stage.", "properties": { "label": { "enum": [ @@ -523,7 +512,7 @@ "description": "Motorized positioning stage.", "properties": { "name": { - "description": "Model designation", + "description": "Model designation, e.g. MV-CA023-10UM", "title": "Name", "type": "string" }, @@ -651,9 +640,77 @@ "title": "Trigger", "type": "object" }, + "UnknownDevice": { + "additionalProperties": true, + "description": "Placeholder for a device this version of the schema does not model.\n\nUnlike every other type it uses ``extra=\"allow\"``, so whatever fields the\nentry carries are kept instead of rejected. They land in ``model_extra``\nand survive a load/dump round-trip, which means an older build can read,\nhold and rewrite a config containing devices it knows nothing about\nwithout destroying them.\n\nThe trade-off: nothing inside such an entry is validated. Treat it as\nopaque payload, not as configuration you can act on.\n\nNote this only catches entries that literally say ``type: unknown``. An\nentry with ``type: spectrometer`` is still rejected by the discriminator --\ndeliberately, because that error is what catches a typo like ``stagee``.\nSee DeviceRegistry for how to opt into remapping instead.", + "properties": { + "name": { + "description": "Model designation, e.g. MV-CA023-10UM", + "title": "Name", + "type": "string" + }, + "manufacturer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Vendor name", + "title": "Manufacturer" + }, + "device_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Identifier of the individual physical unit", + "title": "Device Id" + }, + "tags": { + "items": { + "type": "string" + }, + "title": "Tags", + "type": "array" + }, + "type": { + "const": "unknown", + "title": "Type", + "type": "string" + }, + "kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "What the device actually is, if known, e.g. 'spectrometer'", + "title": "Kind" + } + }, + "required": [ + "name", + "type" + ], + "title": "UnknownDevice", + "type": "object" + }, "Vector2Float": { "additionalProperties": false, - "description": "Float pair where y is optional and falls back to x (square geometry).", + "description": "Float pair where y may be omitted, meaning \"same as x\" (square geometry).\n\n``y`` stays optional so the model says the same thing the document does:\n``None`` means \"not stated\", which survives a load/dump round-trip\nunchanged. Read `effective_y` instead of `y` wherever the resolved value is\nwanted -- it is non-optional, so arithmetic type-checks without casts.", "properties": { "x": { "description": "Value along x in micrometers", @@ -672,7 +729,7 @@ } ], "default": null, - "description": "Value along y; defaults to x when omitted", + "description": "Value along y in micrometers; omit for square geometry", "title": "Y" } }, @@ -710,6 +767,19 @@ "additionalProperties": false, "description": "Top-level object of a Devices.yml file.", "properties": { + "$schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional pointer to the schema file, for editor autocomplete", + "title": "$Schema" + }, "version": { "default": 1, "description": "Registry format version", @@ -734,16 +804,17 @@ "items": { "discriminator": { "mapping": { - "camera": "#/$defs/CameraSchema", + "camera": "#/$defs/CameraDevice", "filterwheel": "#/$defs/FilterWheelDevice", "laser": "#/$defs/LaserDevice", - "stage": "#/$defs/StageDevice" + "stage": "#/$defs/StageDevice", + "unknown": "#/$defs/UnknownDevice" }, "propertyName": "type" }, "oneOf": [ { - "$ref": "#/$defs/CameraSchema" + "$ref": "#/$defs/CameraDevice" }, { "$ref": "#/$defs/StageDevice" @@ -753,6 +824,9 @@ }, { "$ref": "#/$defs/FilterWheelDevice" + }, + { + "$ref": "#/$defs/UnknownDevice" } ] }, @@ -761,6 +835,5 @@ } }, "title": "DeviceRegistry", - "type": "object", - "$schema": "https://json-schema.org/draft/2020-12/schema" + "type": "object" } diff --git a/backend/example_schemas.py b/backend/example_schemas.py index 0426739..01b8ce1 100644 --- a/backend/example_schemas.py +++ b/backend/example_schemas.py @@ -13,13 +13,11 @@ from newswitch.config import get_paths from newswitch.schemas import ( - CameraSchema, + CameraDevice, ConfigError, describe, dump_config, - export_camera_schema, export_devices_schema, - load_camera, load_config, load_device, read_document, @@ -46,23 +44,27 @@ def show_folders() -> None: def load_same_camera_twice() -> None: - """2. The same camera from YAML and from JSON, plus the derived values.""" + """2. The same camera from YAML and from JSON, plus the resolved values.""" section("2. one camera, two formats") - from_yaml = load_camera("hik_mv_ca023_10um.yaml") - from_json = load_camera("hik_mv_ca023_10um.json") - - # Same model class, same fields, one code path -- the parser is picked by suffix. - # The two sample files are not byte-equivalent though: the JSON one adds a - # `$schema` pointer for editor autocomplete and writes gain as the bare-number - # shorthand instead of the full {value, min, max, increment} block. - yaml_fields, json_fields = from_yaml.model_dump(), from_json.model_dump() - differing = sorted(k for k in yaml_fields if yaml_fields[k] != json_fields[k]) - print(f"fields that differ between the two files: {differing}") - print(f"gain_db yaml: {from_yaml.gain_db}") - print(f"gain_db json: {from_json.gain_db} <- scalar shorthand, same model") + # `expect="camera"` both refuses anything else at runtime and narrows the return + # type to CameraDevice, so the camera-only fields below need no isinstance check. + from_yaml = load_device("hik_mv_ca023_10um.yaml", expect="camera") + + # The format is a property of the file, not of the model: write the very same + # camera out as JSON and read it back. One code path, parser picked by suffix. + as_json = dump_config(from_yaml, get_paths().data_dir / "hik_mv_ca023_10um.json") + from_json = load_device(as_json, expect="camera") + print(f"wrote {as_json}") + print(f"YAML and JSON give the same model: {from_yaml == from_json}") + + print(f"gain_db: {from_yaml.gain_db}") print(f"name: {from_yaml.name} ({from_yaml.manufacturer})") - print(f"pixelpitch y: {from_yaml.pixelpitch_um.y} <- derived from x") - print(f"pixelsize: {from_yaml.pixelsize_um} <- defaults to the pitch") + # An omitted optional field is not filled in -- the fallback is a property, so + # the document survives a round-trip exactly as written. + print(f"pixelpitch y: {from_yaml.pixelpitch_um.y} <- omitted in the file") + print(f" effective_y: {from_yaml.pixelpitch_um.effective_y} <- falls back to x") + print(f"pixelsize: {from_yaml.pixelsize_um} <- none stated") + print(f" effective: {from_yaml.effective_pixelsize_um} <- falls back to the pitch") print(f"sensor_size_mm: {from_yaml.sensor_size_mm}") print(f"fill_factor: {from_yaml.fill_factor:.2f}") @@ -79,16 +81,19 @@ def load_whole_setup() -> None: def one_call_for_both_layouts() -> None: """4./5. `load_device` reads single-device files and registries alike.""" section("4. single-device file, name without extension") - # "hik_mv_ca023_10um" -> Configs/hik_mv_ca023_10um.yaml, and the file has no - # `type:` key at all -- a bare device document is read as a camera. + # "hik_mv_ca023_10um" -> Configs/hik_mv_ca023_10um.yaml. The file declares its + # own `type: camera`; a document that omits the key is read as an opaque + # UnknownDevice, so `expect=` would refuse it here. camera = load_device("hik_mv_ca023_10um", expect="camera") print(f"{type(camera).__name__}: {camera.name}") section("5. one device out of a registry") - laser = load_device("Devices.yml", "laser-488-001") + # Same narrowing as above: without `expect=` these would be the device union and + # `wavelength_nm` / `axes` would not be reachable. + laser = load_device("Devices.yml", "laser-488-001", expect="laser") print(f"{type(laser).__name__}: {laser.name} @ {laser.wavelength_nm:.0f} nm") - stage = load_device("Devices.yml", "stage-xy-001") + stage = load_device("Devices.yml", "stage-xy-001", expect="stage") print(f"{type(stage).__name__}: axes {'/'.join(a.label for a in stage.axes)}") # Asking for the wrong type, or for a device that is not there, is an error @@ -103,9 +108,8 @@ def one_call_for_both_layouts() -> None: def limits_of_a_parameter() -> None: """6. `Bounded` carries the device's own limits, not just a value.""" section("6. exposure limits (Bounded)") - # load_camera() is the camera-only entry point, so the result is a CameraSchema - # rather than the device union -- no narrowing needed. - exposure = load_camera("hik_mv_ca023_10um").exposure_time_ms + camera = load_device("hik_mv_ca023_10um", expect="camera") + exposure = camera.exposure_time_ms assert exposure is not None, "the sample file defines exposure limits" print(f"exposure: {exposure}") print(f"clamp(1e6): {exposure.clamp(1e6)} <- clamped to max") @@ -128,7 +132,7 @@ def store_and_reload() -> None: # `devices` is a discriminated union, so narrow before touching camera fields -- # a type checker then knows exactly which attributes exist. - camera = next(dev for dev in registry.devices if isinstance(dev, CameraSchema)) + camera = next(dev for dev in registry.devices if isinstance(dev, CameraDevice)) assert camera.exposure_time_ms is not None, "the sample file defines exposure limits" # validate_assignment=True, so this is checked against the device's own limits. @@ -142,7 +146,9 @@ def store_and_reload() -> None: print(f"wrote {target} -> reload matches: {same}") single = dump_config(camera, out_dir / "hik_mv_ca023_10um.modified.yaml") - print(f"wrote {single} -> exposure {load_camera(single).exposure_time_ms.value}") + reloaded_camera = load_device(single, expect="camera") + assert reloaded_camera.exposure_time_ms is not None + print(f"wrote {single} -> exposure {reloaded_camera.exposure_time_ms.value}") # Out-of-range assignments are refused. Note the caveat: Pydantic assigns first # and validates after, so the object keeps the rejected value and has to be @@ -155,9 +161,9 @@ def store_and_reload() -> None: def refresh_exported_schemas() -> None: - """9. Regenerate the JSON Schemas other tools and editors consume.""" - section("9. exported JSON Schemas") - print(f"wrote {export_camera_schema()}") + """9. Regenerate the JSON Schema other tools and editors consume.""" + section("9. exported JSON Schema") + # No argument -> the managed schema directory, not the working directory. print(f"wrote {export_devices_schema()}") diff --git a/backend/newswitch/schemas/__init__.py b/backend/newswitch/schemas/__init__.py index 689f8a5..fe280e5 100644 --- a/backend/newswitch/schemas/__init__.py +++ b/backend/newswitch/schemas/__init__.py @@ -2,13 +2,14 @@ Layout ------ -* `bounded` -- `Bounded`: a numeric parameter carrying its own min/max/increment -* `camera_schema` -- `CameraSchema` and its sub-models -* `device_schema` -- the other device types, the discriminated union, `DeviceRegistry` -* `document` -- file parsing/writing and path resolution via `newswitch.config` -* `camera_io` -- camera-only loader, writer and schema exporter -* `device_io` -- registry loader, per-type validator, writer, schema exporter -* `loader` -- `load_config` / `load_device`: one entry point for every layout +* `device_schema` -- `Bounded` and the other building blocks, every device model, + the discriminated `Device` union, `DEVICE_MODELS`, `DeviceRegistry` +* `document` -- config file parsing/writing and path resolution via + `newswitch.config` +* `device_io` -- registry loader, per-type validator, writer, schema exporter +* `schema_io` -- JSON Schema read/write in either format, Pydantic export, and a + validator whose ``$ref`` resolution understands YAML +* `loader` -- `load_config` / `load_device`: one entry point for every layout Typical use:: @@ -20,19 +21,11 @@ dump_config(registry, "data/Devices.backup.yml") Bare names are resolved against the managed config directory (see -`newswitch.config.Paths`), explicit paths are used as given. Every loader raises -`ConfigError`; `CameraConfigError` and `DeviceConfigError` are subclasses of it. +`newswitch.config.Paths`), explicit paths are used as given. `load_config`, +`load_device` and `dump_config` raise `ConfigError`; the lower-level `load_devices` +and `dump_devices` raise `DeviceConfigError`. """ -from .bounded import Bounded -from .camera_io import ( - CameraConfigError, - dump_camera, - export_json_schema as export_camera_schema, - load_camera, - schema_errors, -) -from .camera_schema import CameraSchema, Trigger, Vector2Float, Vector2Int from .device_io import ( DeviceConfigError, describe, @@ -43,45 +36,56 @@ ) from .device_schema import ( DEVICE_MODELS, + Bounded, + CameraDevice, Device, DeviceBase, - DeviceModel, DeviceRegistry, + FilterSlot, FilterWheelDevice, LaserDevice, StageAxis, StageDevice, - device_key, + Trigger, + UnknownDevice, + Vector2Float, + Vector2Int, ) from .document import read_document, write_document from .errors import ConfigError from .loader import dump_config, load_config, load_device +from .schema_io import ( + SchemaFormatError, + build_validator, + convert, + dump_any, + export_schema, + load_any, +) __all__ = [ - # models + # building blocks "Bounded", - "CameraSchema", "Trigger", "Vector2Int", "Vector2Float", - "DeviceBase", "StageAxis", + "FilterSlot", + # device models + "DeviceBase", + "CameraDevice", "StageDevice", "LaserDevice", "FilterWheelDevice", + "UnknownDevice", "Device", - "DeviceModel", "DeviceRegistry", "DEVICE_MODELS", - "device_key", # one entry point for any layout "load_config", "load_device", "dump_config", - # type-specific entry points - "load_camera", - "dump_camera", - "schema_errors", + # registry-specific entry points "load_devices", "dump_devices", "validate_registry", @@ -89,10 +93,14 @@ # documents and schema export "read_document", "write_document", - "export_camera_schema", "export_devices_schema", + "export_schema", + "load_any", + "dump_any", + "convert", + "build_validator", # errors "ConfigError", - "CameraConfigError", "DeviceConfigError", + "SchemaFormatError", ] diff --git a/backend/newswitch/schemas/bounded.py b/backend/newswitch/schemas/bounded.py deleted file mode 100644 index 14fcd43..0000000 --- a/backend/newswitch/schemas/bounded.py +++ /dev/null @@ -1,130 +0,0 @@ -"""A parameter that carries its own limits. - -Design decision ---------------- -Limits are modelled as a **nested object**, not as sibling tags: - - exposure_time_ms: - value: 10.0 - min: 0.024 - max: 10000.0 - increment: 0.001 - -rather than:: - - exposure_time_ms: 10.0 - exposure_time_ms_min: 0.024 - exposure_time_ms_max: 10000.0 - -The nested form is one reusable model instead of three flat fields per -parameter, so the "value must lie between min and max" rule is written once -and applies everywhere. - -To keep the common case short, a **bare scalar is accepted as shorthand** for -``{"value": }``. Both of these are valid and equivalent: - - exposure_time_ms: 10.0 - exposure_time_ms: {value: 10.0} - -Note what JSON Schema can and cannot do here. ``minimum``/``maximum`` in the -schema are *global* constants — they cannot refer to another field of the same -document. "value within this device's own min/max" is therefore a cross-field -rule and lives in Pydantic only. -""" - -from __future__ import annotations - -import math -from typing import Any, Optional - -from pydantic import BaseModel, ConfigDict, Field, model_validator - - -class Bounded(BaseModel): - """A numeric parameter together with the device limits that apply to it. - - All four members are optional, which covers every practical case: - - * ``{value: 10}`` -- setting, limits unknown - * ``{min: 0.024, max: 10000}`` -- capability only, no default set - * ``{value: 10, min: 0.024, max: 1e4}`` -- both - * ``null`` / omitted -- parameter not applicable - """ - - model_config = ConfigDict(extra="forbid", validate_assignment=True) - - value: Optional[float] = Field(default=None, description="Current or default value") - min: Optional[float] = Field(default=None, description="Smallest settable value") - max: Optional[float] = Field(default=None, description="Largest settable value") - increment: Optional[float] = Field( - default=None, gt=0, description="Step size; settable values are min + n*increment" - ) - unit: Optional[str] = Field( - default=None, description="Override for the unit implied by the field name" - ) - - @model_validator(mode="before") - @classmethod - def _accept_scalar(cls, data: Any) -> Any: - """Allow ``exposure_time_ms: 10.0`` as shorthand for ``{value: 10.0}``.""" - if isinstance(data, (int, float)) and not isinstance(data, bool): - return {"value": float(data)} - return data - - @model_validator(mode="after") - def _check_consistency(self) -> "Bounded": - if self.min is not None and self.max is not None and self.min > self.max: - raise ValueError(f"min ({self.min}) must not exceed max ({self.max})") - - if self.value is not None: - if self.min is not None and self.value < self.min: - raise ValueError(f"value {self.value} below min {self.min}") - if self.max is not None and self.value > self.max: - raise ValueError(f"value {self.value} above max {self.max}") - - if self.increment is not None: - base = self.min if self.min is not None else 0.0 - steps = (self.value - base) / self.increment - if not math.isclose(steps, round(steps), abs_tol=1e-9): - raise ValueError( - f"value {self.value} is not on the {self.increment} grid starting at {base}" - ) - return self - - @classmethod - def __get_pydantic_json_schema__(cls, core_schema, handler): # noqa: ANN001 - """Emit ``anyOf: [number, object]`` so the shorthand validates too.""" - schema = handler(core_schema) - schema = handler.resolve_ref_schema(schema) - return { - "anyOf": [ - {"type": "number", "description": "Shorthand for {value: }"}, - dict(schema), - ], - "description": cls.__doc__.splitlines()[0] if cls.__doc__ else None, - } - - def clamp(self, value: float) -> float: - """Clamp to [min, max] and snap onto the increment grid.""" - base = self.min if self.min is not None else 0.0 - if self.min is not None: - value = max(value, self.min) - if self.max is not None: - value = min(value, self.max) - if self.increment is not None: - value = base + round((value - base) / self.increment) * self.increment - if self.max is not None and value > self.max: - value -= self.increment - return value - - def supports(self, value: float) -> bool: - """True if `value` can be set exactly.""" - try: - Bounded(value=value, min=self.min, max=self.max, increment=self.increment) - except ValueError: - return False - return True - - def __repr__(self) -> str: - parts = [f"{k}={v}" for k, v in self.model_dump(exclude_none=True).items()] - return f"Bounded({', '.join(parts)})" diff --git a/backend/newswitch/schemas/camera_io.py b/backend/newswitch/schemas/camera_io.py deleted file mode 100644 index 0213842..0000000 --- a/backend/newswitch/schemas/camera_io.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Load and validate camera descriptions from JSON or YAML. - -Two validation layers are available: - -1. ``jsonschema`` against the exported Draft 2020-12 schema -> reports *all* - structural errors at once, without constructing a model. -2. ``pydantic`` -> coerces types, applies defaults and runs the derivation - rules (pixelpitch y -> x, pixelsize -> pixelpitch). - -YAML and JSON share the same code path: both parsers produce plain Python -dicts, and validation happens on the dict. Parsing and path resolution live in -`document.py`, so a bare name such as ``"hik_mv_ca023_10um"`` is looked up in the -managed config directory while an explicit path is used as given. - -This module is the *camera-only* entry point and rejects anything else. Use -`loader.load_config` when the file may hold a whole device registry. -""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any, Optional - -from jsonschema import Draft202012Validator -from pydantic import ValidationError - -from ..config import get_paths -from .camera_schema import CameraSchema -from .document import read_document, write_document -from .errors import ConfigError - -CAMERA_VALIDATOR = Draft202012Validator(CameraSchema.model_json_schema()) - - -class CameraConfigError(ConfigError): - """Raised when a camera description does not satisfy the schema.""" - - -def load_raw(path: str | Path) -> dict[str, Any]: - """Parse a .json, .yaml or .yml file into a plain dict. - - Args: - path: Bare config name or an explicit path. - - Returns: - The parsed document. - - Raises: - CameraConfigError: File missing/unparseable, or the top level is not a mapping. - """ - try: - data = read_document(path) - except ConfigError as exc: - raise CameraConfigError(str(exc)) from exc - - if not isinstance(data, dict): - raise CameraConfigError(f"{Path(path).name}: expected a mapping at the top level") - return data - - -def schema_errors(data: dict[str, Any]) -> list[str]: - """Return all JSON Schema violations as readable strings (empty = valid).""" - return [ - f"{'.'.join(str(p) for p in err.absolute_path) or ''}: {err.message}" - for err in sorted(CAMERA_VALIDATOR.iter_errors(data), key=str) - ] - - -def load_camera(path: str | Path, *, strict_schema: bool = True) -> CameraSchema: - """Load a camera description and return a validated model. - - Parameters - ---------- - path - Path to a .json, .yaml or .yml file. - strict_schema - Run the jsonschema pass first, so that *all* structural problems are - reported together instead of only the first Pydantic failure. - """ - data = load_raw(path) - - if strict_schema: - errors = schema_errors(data) - if errors: - joined = "\n - ".join(errors) - raise CameraConfigError(f"{Path(path).name} failed validation:\n - {joined}") - - try: - return CameraSchema.model_validate(data) - except ValidationError as exc: - raise CameraConfigError(f"{Path(path).name}: {exc}") from exc - - -def dump_camera(camera: CameraSchema, path: str | Path) -> Path: - """Write a validated camera back to .json or .yaml. - - Args: - camera: The validated model to store. - path: Bare name (stored in the config dir) or an explicit path. - - Returns: - The path written to. - - Raises: - CameraConfigError: Unsupported file type. - """ - payload = camera.model_dump(mode="json", by_alias=True, exclude_none=True) - try: - return write_document(payload, path) - except ConfigError as exc: - raise CameraConfigError(str(exc)) from exc - - -def export_json_schema(path: Optional[str | Path] = None) -> Path: - """Write the JSON Schema itself to disk (for editors, docs, other langs). - - Args: - path: Target file. Defaults to ``camera.schema.json`` in the managed - schema directory (see `newswitch.config.Paths.schema_dir`). - - Returns: - The path written to. - """ - target = get_paths().schema_file("camera.schema.json") if path is None else Path(path) - target.parent.mkdir(parents=True, exist_ok=True) - schema = CameraSchema.model_json_schema() - schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" - target.write_text(json.dumps(schema, indent=2) + "\n", encoding="utf-8") - return target - - -if __name__ == "__main__": - import sys - - for arg in sys.argv[1:] or ["hik_mv_ca023_10um.json", "hik_mv_ca023_10um.yaml"]: - try: - cam = load_camera(arg) - except CameraConfigError as exc: - print(f"[FAIL] {exc}") - continue - w, h = cam.sensor_size_mm - print( - f"[ OK ] {arg}: {cam.manufacturer} {cam.name} | " - f"{cam.pixelcount.x}x{cam.pixelcount.y} px | " - f"pitch {cam.pixelpitch_um.x}/{cam.pixelpitch_um.y} um | " - f"sensor {w:.2f}x{h:.2f} mm | fill {cam.fill_factor:.2f}" - ) diff --git a/backend/newswitch/schemas/camera_schema.py b/backend/newswitch/schemas/camera_schema.py deleted file mode 100644 index 7e4ff1f..0000000 --- a/backend/newswitch/schemas/camera_schema.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Camera description schema. - -Defines the geometry, identity and acquisition parameters of a camera as a -Pydantic model. `CameraSchema.model_json_schema()` exports a Draft 2020-12 -JSON Schema that can validate raw dicts loaded from JSON or YAML. - -Field conventions ------------------ -* A field WITHOUT a default is required. -* A field WITH a default (including ``None``) is optional. -* Some optional fields are *derived* by model validators when omitted - (pixelpitch y -> x, pixelsize -> pixelpitch). Derivation happens in - Pydantic, not in the exported JSON Schema. -""" - -from __future__ import annotations - -from typing import Literal, Optional - -from pydantic import BaseModel, ConfigDict, Field, model_validator - -from .bounded import Bounded - -# import numpy as np # noqa: ERA001 -- needed for quantum_efficiency, see below - - -class Vector2Int(BaseModel): - """Integer pair, both components required (e.g. pixel counts).""" - - model_config = ConfigDict(extra="forbid") - - x: int = Field(gt=0, description="Extent along x in pixels") - y: int = Field(gt=0, description="Extent along y in pixels") - - -class Vector2Float(BaseModel): - """Float pair where y is optional and falls back to x (square geometry).""" - - model_config = ConfigDict(extra="forbid") - - x: float = Field(gt=0, description="Value along x in micrometers") - y: Optional[float] = Field( - default=None, gt=0, description="Value along y; defaults to x when omitted" - ) - - @model_validator(mode="after") - def _fill_y(self) -> "Vector2Float": - if self.y is None: - self.y = self.x - return self - - -class Trigger(BaseModel): - """Trigger configuration. Omit the whole object for free-running mode.""" - - model_config = ConfigDict(extra="forbid") - - type: Literal["software", "hardware"] - edge: Optional[Literal["rising", "falling"]] = None - level: Optional[Literal["high", "low"]] = None - - @model_validator(mode="after") - def _check_software(self) -> "Trigger": - if self.type == "software" and (self.edge or self.level): - raise ValueError("edge/level are only meaningful for hardware triggers") - return self - - -class CameraSchema(BaseModel): - """Full camera description.""" - - model_config = ConfigDict(extra="forbid", validate_assignment=True, populate_by_name=True) - - # --- tooling -------------------------------------------------------- - schema_ref: Optional[str] = Field( - default=None, - alias="$schema", - serialization_alias="$schema", - description="Optional pointer to the schema file, for editor autocomplete", - ) - - # --- identity ------------------------------------------------------- - type: Literal["camera"] = Field( - default="camera", description="Discriminator used in multi-device files" - ) - name: str = Field(description="Model designation, e.g. MV-CA023-10UM") - manufacturer: Optional[str] = Field(default=None, description="Vendor name") - camera_id: Optional[str] = Field( - default=None, description="Unique identifier of the physical unit" - ) - - # --- sensor geometry ------------------------------------------------ - pixelcount: Vector2Int = Field(description="Sensor resolution in pixels") - pixelpitch_um: Vector2Float = Field( - description="Center-to-center pixel distance; y defaults to x" - ) - pixelsize_um: Optional[Vector2Float] = Field( - default=None, - description="Photosensitive area per pixel; defaults to pixelpitch_um", - ) - channels: int = Field(default=1, ge=1, description="Color/spectral channels") - - # --- acquisition ---------------------------------------------------- - # Bounded = value + the device's own min/max/increment. A bare number is - # accepted as shorthand for {"value": }; see bounded.py. - exposure_time_ms: Optional[Bounded] = Field( - default=None, description="Integration time in ms, with device limits" - ) - framerate_per_sec: Optional[Bounded] = Field( - default=None, description="Frame rate in Hz, with device limits" - ) - gain_db: Optional[Bounded] = Field( - default=None, description="Analog gain in dB, with device limits" - ) - binning: Literal[1, 2, 4] = 1 - roi: Optional[tuple[int, int, int, int]] = Field( - default=None, description="Region of interest as (x, y, width, height)" - ) - trigger: Optional[Trigger] = None - tags: list[str] = Field(default_factory=list) - - # --- spectral response ---------------------------------------------- - # quantum_efficiency: Optional[np.ndarray] = None - # - # Enabling this requires ConfigDict(arbitrary_types_allowed=True) and a - # custom serializer, and the field cannot be expressed in JSON Schema. - # Preferred JSON-native alternative: - # qe_wavelength_nm: Optional[list[float]] = None - # qe_values: Optional[list[float]] = None # 0.0 .. 1.0, same length - - @model_validator(mode="after") - def _derive_pixelsize(self) -> "CameraSchema": - if self.pixelsize_um is None: - self.pixelsize_um = Vector2Float(x=self.pixelpitch_um.x, y=self.pixelpitch_um.y) - return self - - @model_validator(mode="after") - def _check_roi(self) -> "CameraSchema": - if self.roi is not None: - x, y, w, h = self.roi - if w <= 0 or h <= 0: - raise ValueError("roi width and height must be positive") - if x < 0 or y < 0: - raise ValueError("roi origin must be non-negative") - if x + w > self.pixelcount.x or y + h > self.pixelcount.y: - raise ValueError("roi exceeds sensor pixelcount") - return self - - # --- convenience ---------------------------------------------------- - @property - def sensor_size_mm(self) -> tuple[float, float]: - """Physical sensor extent (width, height) in millimeters.""" - return ( - self.pixelcount.x * self.pixelpitch_um.x / 1000.0, - self.pixelcount.y * self.pixelpitch_um.y / 1000.0, - ) - - @property - def fill_factor(self) -> float: - """Photosensitive area fraction per pixel (0.0 .. 1.0).""" - ps, pp = self.pixelsize_um, self.pixelpitch_um - return (ps.x * ps.y) / (pp.x * pp.y) - - -if __name__ == "__main__": - import json - - print(json.dumps(CameraSchema.model_json_schema(), indent=2)) diff --git a/backend/newswitch/schemas/device_io.py b/backend/newswitch/schemas/device_io.py index 458605c..b098fd5 100644 --- a/backend/newswitch/schemas/device_io.py +++ b/backend/newswitch/schemas/device_io.py @@ -9,11 +9,14 @@ `validate_registry()` avoids that by reading the ``type`` field first and validating each entry against **only** the matching sub-schema. Errors come back as `devices[2].pixelcount.x: -5 is less than or equal to 0`. + +Parsing and path resolution live in `document.py`, so a bare name such as +``"Devices"`` is looked up in the managed config directory while an explicit path +is used as given. This module only ever sees plain dicts and lists. """ from __future__ import annotations -import json from pathlib import Path from typing import Any, Optional @@ -21,9 +24,10 @@ from pydantic import ValidationError from ..config import get_paths -from .device_schema import DEVICE_MODELS, DeviceRegistry, device_key +from .device_schema import DEVICE_MODELS, DeviceRegistry from .document import read_document, write_document from .errors import ConfigError +from .schema_io import export_schema class DeviceConfigError(ConfigError): @@ -35,23 +39,11 @@ class DeviceConfigError(ConfigError): name: Draft202012Validator(model.model_json_schema()) for name, model in DEVICE_MODELS.items() } - -def load_raw(path: str | Path) -> Any: # noqa: ANN401 - a parsed document is Any - """Parse .json, .yaml or .yml into plain Python objects. - - Args: - path: Bare config name or an explicit path. - - Returns: - The parsed document. - - Raises: - DeviceConfigError: File missing, unsupported suffix, or unparseable. - """ - try: - return read_document(path) - except ConfigError as exc: - raise DeviceConfigError(str(exc)) from exc +# Both spellings of every registry key: `schema_ref` is written out as `$schema`, +# so a document this module produced has to pass its own unknown-key check. +REGISTRY_KEYS: set[str] = set(DeviceRegistry.model_fields) | { + field.alias for field in DeviceRegistry.model_fields.values() if field.alias +} def validate_registry(data: Any) -> list[str]: @@ -69,11 +61,11 @@ def validate_registry(data: Any) -> list[str]: if "name" in data or "type" in data: hint = ( " -- this looks like a single-device file; wrap it as " - "'devices: [ ... ]' or load it with camera_io.load_camera()" + "'devices: [ ... ]', there is no bare device document format" ) return [f": 'devices' is a required property{hint}"] - unknown = set(data) - set(DeviceRegistry.model_fields) + unknown = set(data) - REGISTRY_KEYS for key in sorted(unknown): errors.append(f": unknown key {key!r}") @@ -94,7 +86,10 @@ def validate_registry(data: Any) -> list[str]: continue if type_ not in TYPE_VALIDATORS: known = ", ".join(sorted(TYPE_VALIDATORS)) - errors.append(f"{where}: unknown type {type_!r} (known: {known})") + errors.append( + f"{where}: unknown type {type_!r} (known: {known}); if this is a " + f"device from a newer version, see DeviceRegistry.demote_unknown_types" + ) continue for err in sorted(TYPE_VALIDATORS[type_].iter_errors(entry), key=str): @@ -104,9 +99,36 @@ def validate_registry(data: Any) -> list[str]: return errors -def load_devices(path: str | Path, *, strict_schema: bool = True) -> DeviceRegistry: - """Load a device file and return a validated registry.""" - data = load_raw(path) +def load_devices( + path: str | Path, + *, + strict_schema: bool = True, + allow_unknown_types: bool = False, +) -> DeviceRegistry: + """Load a device file and return a validated registry. + + Args: + path: Bare config name (``"Devices"``, ``"Devices.yml"``) resolved against the + managed config directory, or an explicit path to a .json/.yaml/.yml file. + strict_schema: Run the jsonschema pass first, so that *all* structural + problems are reported together instead of only the first Pydantic + failure. + allow_unknown_types: Accept entries whose ``type`` this version does not + know by demoting them to `UnknownDevice` instead of failing. Off by + default: the remapping cannot tell a genuinely newer device type from + a typo, so ``type: stagee`` would be quietly accepted as opaque + payload. + + Raises: + DeviceConfigError: The file cannot be read, or the content fails validation. + """ + try: + data = read_document(path) + except ConfigError as exc: + raise DeviceConfigError(str(exc)) from exc + + if allow_unknown_types: + data = DeviceRegistry.demote_unknown_types(data) if strict_schema: errors = validate_registry(data) @@ -125,7 +147,8 @@ def dump_devices(registry: DeviceRegistry, path: str | Path) -> Path: Args: registry: The validated registry to store. - path: Bare name (stored in the config dir) or an explicit path. + path: Bare name (written into the config dir) or an explicit path. The + suffix picks the format. Returns: The path written to. @@ -141,21 +164,20 @@ def dump_devices(registry: DeviceRegistry, path: str | Path) -> Path: def export_json_schema(path: Optional[str | Path] = None) -> Path: - """Write the registry JSON Schema to disk (editors, docs, other languages). + """Write the registry schema to disk. The extension picks JSON or YAML. Args: - path: Target file. Defaults to ``devices.schema.json`` in the managed - schema directory (see `newswitch.config.Paths.schema_dir`). + path: Target file. Defaults to ``devices.schema.json`` in the managed schema + directory (see `newswitch.config.Paths.schema_dir`). Returns: The path written to. """ target = get_paths().schema_file("devices.schema.json") if path is None else Path(path) target.parent.mkdir(parents=True, exist_ok=True) - schema = DeviceRegistry.model_json_schema() - schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" - target.write_text(json.dumps(schema, indent=2) + "\n", encoding="utf-8") - return target + return export_schema( + DeviceRegistry, target, header="JSON Schema (Draft 2020-12) for a device registry." + ) def describe(registry: DeviceRegistry) -> str: @@ -172,7 +194,9 @@ def describe(registry: DeviceRegistry) -> str: detail = f"{dev.wavelength_nm:.0f} nm, {dev.max_power_mw:.0f} mW" elif dev.type == "filterwheel": detail = f"{len(dev.slots)} slots" - lines.append(f" [{dev.type:<11}] {device_key(dev):<22} {detail}") + elif dev.type == "unknown": + detail = f"unmodelled{f' ({dev.kind})' if dev.kind else ''}" + lines.append(f" [{dev.type:<11}] {dev.key:<22} {detail}") return "\n".join(lines) diff --git a/backend/newswitch/schemas/device_schema.py b/backend/newswitch/schemas/device_schema.py index 553a799..529aa74 100644 --- a/backend/newswitch/schemas/device_schema.py +++ b/backend/newswitch/schemas/device_schema.py @@ -1,42 +1,205 @@ -"""Multi-device registry: one file, many device types. +"""Device registry schema: one file, many device types. -The `type` field acts as a **discriminator**. Pydantic uses it to dispatch each -list entry to exactly one model instead of trying all of them, which keeps -error messages precise: +All device types -- camera included -- are ordinary subclasses of `DeviceBase` +and are dispatched by their `type` field. Adding a device type takes four +steps: - devices.1.stage.axes.0.travel_um -> Input should be greater than 0 - -instead of the usual union noise ("not valid under any of the given schemas"). - -Adding a device type takes three steps: 1. subclass DeviceBase with a new `type: Literal[...]` - 2. add it to the `Device` union below - 3. nothing else -- registry, loader and JSON Schema pick it up automatically + 2. add it to the `Device` union and to DEVICE_MODELS + 3. add a matching `@overload` to `loader.load_device`, so that + `expect=""` narrows to the new class instead of the union + 4. nothing else -- registry, validation and JSON Schema pick it up + automatically + +Step 3 is the only one a type checker will not remind you about: forget it and +the code still runs, `expect=` just stops narrowing for that one type. + +Field conventions +----------------- +* A field WITHOUT a default is required. +* A field WITH a default (including ``None``) is optional. +* An omitted optional field is *not* filled in during validation. Where a + fallback exists (pixelpitch y -> x, pixelsize -> pixelpitch) it is exposed as + a read-only property -- `Vector2Float.effective_y`, + `CameraDevice.effective_pixelsize_um` -- and the field itself stays ``None``, + so a load/dump round-trip returns the document unchanged. Read the property + wherever the resolved value is wanted. +* Cross-field rules (roi inside pixelcount, value inside its own min/max, + unique identifiers) live in Pydantic only. JSON Schema constraints are + constants and cannot refer to sibling fields. """ from __future__ import annotations -from typing import Annotated, Literal, Optional, Union +import math +from typing import Annotated, Any, Literal, Optional, Union from pydantic import BaseModel, ConfigDict, Field, model_validator -from .camera_schema import CameraSchema +# import numpy as np # noqa: ERA001 -- needed for quantum_efficiency, see below -class DeviceBase(BaseModel): - """Fields shared by every device type.""" +# --------------------------------------------------------------------------- +# Building blocks +# --------------------------------------------------------------------------- + + +class Bounded(BaseModel): + """A numeric parameter together with the device limits that apply to it. + + Accepts a bare number as shorthand for ``{"value": }``, so both of + these are valid and equivalent:: + + exposure_time_ms: 10.0 + exposure_time_ms: {value: 10.0} + + All members are optional, which covers every practical case: + + * ``{value: 10}`` -- setting, limits unknown + * ``{min: 0.024, max: 10000}`` -- capability only, no value chosen + * ``{value: 10, min: 0.024, max: 1e4}`` -- both + * ``null`` / omitted -- parameter not applicable + """ model_config = ConfigDict(extra="forbid", validate_assignment=True) - name: str = Field(description="Model designation") - manufacturer: Optional[str] = Field(default=None, description="Vendor name") - device_id: Optional[str] = Field( - default=None, description="Identifier of the individual physical unit" + value: Optional[float] = Field(default=None, description="Current or default value") + min: Optional[float] = Field(default=None, description="Smallest settable value") + max: Optional[float] = Field(default=None, description="Largest settable value") + increment: Optional[float] = Field( + default=None, + gt=0, + description="Step size; settable values are min + n*increment", ) - tags: list[str] = Field(default_factory=list) + unit: Optional[str] = Field( + default=None, description="Override for the unit implied by the field name" + ) + + @model_validator(mode="before") + @classmethod + def _accept_scalar(cls, data: Any) -> Any: + """Allow ``exposure_time_ms: 10.0`` as shorthand for ``{value: 10.0}``.""" + if isinstance(data, (int, float)) and not isinstance(data, bool): + return {"value": float(data)} + return data + + @model_validator(mode="after") + def _check_consistency(self) -> "Bounded": + if self.min is not None and self.max is not None and self.min > self.max: + raise ValueError(f"min ({self.min}) must not exceed max ({self.max})") + + if self.value is not None: + if self.min is not None and self.value < self.min: + raise ValueError(f"value {self.value} below min {self.min}") + if self.max is not None and self.value > self.max: + raise ValueError(f"value {self.value} above max {self.max}") + + if self.increment is not None: + base = self.min if self.min is not None else 0.0 + steps = (self.value - base) / self.increment + if not math.isclose(steps, round(steps), abs_tol=1e-9): + raise ValueError( + f"value {self.value} is not on the {self.increment} grid starting at {base}" + ) + return self + + @classmethod + def __get_pydantic_json_schema__(cls, core_schema, handler): # noqa: ANN001 + """Emit ``anyOf: [number, object]`` so the shorthand validates too.""" + schema = handler(core_schema) + schema = handler.resolve_ref_schema(schema) + return { + "anyOf": [ + {"type": "number", "description": "Shorthand for {value: }"}, + dict(schema), + ], + "description": "Numeric parameter with its device limits", + } + + def clamp(self, value: float) -> float: + """Clamp to [min, max] and snap onto the increment grid.""" + base = self.min if self.min is not None else 0.0 + if self.min is not None: + value = max(value, self.min) + if self.max is not None: + value = min(value, self.max) + if self.increment is not None: + value = base + round((value - base) / self.increment) * self.increment + if self.max is not None and value > self.max: + value -= self.increment + return value + + def supports(self, value: float) -> bool: + """True if `value` can be set exactly.""" + try: + Bounded(value=value, min=self.min, max=self.max, increment=self.increment) + except ValueError: + return False + return True + + def __repr__(self) -> str: + """Show only the members that are set, so the repr stays readable.""" + parts = [f"{k}={v}" for k, v in self.model_dump(exclude_none=True).items()] + return f"Bounded({', '.join(parts)})" + + +class Vector2Int(BaseModel): + """Integer pair, both components required (e.g. pixel counts).""" + + model_config = ConfigDict(extra="forbid") + + x: int = Field(gt=0, description="Extent along x in pixels") + y: int = Field(gt=0, description="Extent along y in pixels") + + +class Vector2Float(BaseModel): + """Float pair where y may be omitted, meaning "same as x" (square geometry). + + ``y`` stays optional so the model says the same thing the document does: + ``None`` means "not stated", which survives a load/dump round-trip + unchanged. Read `effective_y` instead of `y` wherever the resolved value is + wanted -- it is non-optional, so arithmetic type-checks without casts. + """ + + model_config = ConfigDict(extra="forbid") + + x: float = Field(gt=0, description="Value along x in micrometers") + y: Optional[float] = Field( + default=None, + gt=0, + description="Value along y in micrometers; omit for square geometry", + ) + + @property + def effective_y(self) -> float: + """Stated y, or x when the document left y out.""" + return self.y if self.y is not None else self.x + + @property + def as_tuple(self) -> tuple[float, float]: + """(x, effective_y) -- both components resolved.""" + return (self.x, self.effective_y) + + +class Trigger(BaseModel): + """Trigger configuration. Omit the whole object for free-running mode.""" + + model_config = ConfigDict(extra="forbid") + + type: Literal["software", "hardware"] + edge: Optional[Literal["rising", "falling"]] = None + level: Optional[Literal["high", "low"]] = None + + @model_validator(mode="after") + def _check_software(self) -> "Trigger": + if self.type == "software" and (self.edge or self.level): + raise ValueError("edge/level are only meaningful for hardware triggers") + return self class StageAxis(BaseModel): + """One motorized axis of a stage.""" + model_config = ConfigDict(extra="forbid") label: Literal["x", "y", "z", "a", "b", "c"] @@ -45,6 +208,124 @@ class StageAxis(BaseModel): inverted: bool = Field(default=False, description="Flip the positive direction") +class FilterSlot(BaseModel): + """One slot of a filter wheel.""" + + model_config = ConfigDict(extra="forbid") + + position: int = Field(ge=0, description="Slot index on the wheel") + label: str = Field(description="Filter designation, e.g. GFP-525/50") + center_nm: Optional[float] = Field(default=None, gt=0) + bandwidth_nm: Optional[float] = Field(default=None, gt=0) + + +# --------------------------------------------------------------------------- +# Devices +# --------------------------------------------------------------------------- + + +class DeviceBase(BaseModel): + """Fields shared by every device type.""" + + model_config = ConfigDict(extra="forbid", validate_assignment=True) + + name: str = Field(description="Model designation, e.g. MV-CA023-10UM") + manufacturer: Optional[str] = Field(default=None, description="Vendor name") + device_id: Optional[str] = Field( + default=None, description="Identifier of the individual physical unit" + ) + tags: list[str] = Field(default_factory=list) + + @property + def key(self) -> str: + """Stable identifier: device_id when present, otherwise the name.""" + return self.device_id or self.name + + +class CameraDevice(DeviceBase): + """Imaging sensor.""" + + type: Literal["camera"] + + # --- sensor geometry ------------------------------------------------ + pixelcount: Vector2Int = Field(description="Sensor resolution in pixels") + pixelpitch_um: Vector2Float = Field( + description="Center-to-center pixel distance; y defaults to x" + ) + pixelsize_um: Optional[Vector2Float] = Field( + default=None, + description="Photosensitive area per pixel; omit when the datasheet " + "gives none, then a fill factor of 1.0 is assumed", + ) + channels: int = Field(default=1, ge=1, description="Color/spectral channels") + + # --- acquisition ---------------------------------------------------- + exposure_time_ms: Optional[Bounded] = Field( + default=None, description="Integration time in ms, with device limits" + ) + framerate_per_sec: Optional[Bounded] = Field( + default=None, description="Frame rate in Hz, with device limits" + ) + gain_db: Optional[Bounded] = Field( + default=None, description="Analog gain in dB, with device limits" + ) + binning: Literal[1, 2, 4] = 1 + roi: Optional[tuple[int, int, int, int]] = Field( + default=None, description="Region of interest as (x, y, width, height)" + ) + trigger: Optional[Trigger] = None + + # --- spectral response ---------------------------------------------- + # quantum_efficiency: Optional[np.ndarray] = None + # + # Enabling this requires ConfigDict(arbitrary_types_allowed=True) and a + # custom serializer, and the field cannot be expressed in JSON Schema at + # all -- which would break the jsonschema validation path and every + # non-Python consumer. JSON-native alternative: + # qe_wavelength_nm: Optional[list[float]] = None + # qe_values: Optional[list[float]] = None # 0.0 .. 1.0, same length + + @property + def effective_pixelsize_um(self) -> Vector2Float: + """Stated photosensitive area, or pixelpitch_um when none was given. + + The fallback is an *assumption* (fill factor 1.0), not a datasheet + value -- which is exactly why `pixelsize_um` stays None in the model + rather than being filled in during validation. + """ + return self.pixelsize_um if self.pixelsize_um is not None else self.pixelpitch_um + + @model_validator(mode="after") + def _check_roi(self) -> "CameraDevice": + if self.roi is not None: + x, y, w, h = self.roi + if w <= 0 or h <= 0: + raise ValueError("roi width and height must be positive") + if x < 0 or y < 0: + raise ValueError("roi origin must be non-negative") + if x + w > self.pixelcount.x or y + h > self.pixelcount.y: + raise ValueError("roi exceeds sensor pixelcount") + return self + + @property + def sensor_size_mm(self) -> tuple[float, float]: + """Physical sensor extent (width, height) in millimeters.""" + return ( + self.pixelcount.x * self.pixelpitch_um.x / 1000.0, + self.pixelcount.y * self.pixelpitch_um.effective_y / 1000.0, + ) + + @property + def fill_factor(self) -> float: + """Photosensitive area fraction per pixel (0.0 .. 1.0). + + Returns 1.0 when `pixelsize_um` was not stated -- see + `effective_pixelsize_um`. + """ + ps, pp = self.effective_pixelsize_um, self.pixelpitch_um + return (ps.x * ps.effective_y) / (pp.x * pp.effective_y) + + class StageDevice(DeviceBase): """Motorized positioning stage.""" @@ -71,15 +352,6 @@ class LaserDevice(DeviceBase): shutter: bool = Field(default=False, description="Has a mechanical shutter") -class FilterSlot(BaseModel): - model_config = ConfigDict(extra="forbid") - - position: int = Field(ge=0, description="Slot index on the wheel") - label: str = Field(description="Filter designation, e.g. GFP-525/50") - center_nm: Optional[float] = Field(default=None, gt=0) - bandwidth_nm: Optional[float] = Field(default=None, gt=0) - - class FilterWheelDevice(DeviceBase): """Motorized filter wheel.""" @@ -95,62 +367,125 @@ def _unique_positions(self) -> "FilterWheelDevice": return self -# The discriminated union. CameraSchema already carries type="camera". -# `DeviceModel` is the bare union, usable as a return annotation; `Device` adds the -# discriminator and is what the registry field uses. -DeviceModel = Union[CameraSchema, StageDevice, LaserDevice, FilterWheelDevice] -Device = Annotated[DeviceModel, Field(discriminator="type")] +class UnknownDevice(DeviceBase): + """Placeholder for a device this version of the schema does not model. + + Unlike every other type it uses ``extra="allow"``, so whatever fields the + entry carries are kept instead of rejected. They land in ``model_extra`` + and survive a load/dump round-trip, which means an older build can read, + hold and rewrite a config containing devices it knows nothing about + without destroying them. + + The trade-off: nothing inside such an entry is validated. Treat it as + opaque payload, not as configuration you can act on. + + Note this only catches entries that literally say ``type: unknown``. An + entry with ``type: spectrometer`` is still rejected by the discriminator -- + deliberately, because that error is what catches a typo like ``stagee``. + See DeviceRegistry for how to opt into remapping instead. + """ + + model_config = ConfigDict(extra="allow") + + type: Literal["unknown"] + kind: Optional[str] = Field( + default=None, + description="What the device actually is, if known, e.g. 'spectrometer'", + ) + + @property + def payload(self) -> dict[str, Any]: + """The unrecognized fields, as they appeared in the document.""" + return dict(self.model_extra or {}) + + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + +# The discriminated union. Pydantic uses `type` to dispatch each list entry to +# exactly one model, which keeps error paths precise: +# devices.1.stage.axes.0.travel_um -> Input should be greater than 0 +# instead of the usual union noise ("not valid under any of the given schemas"). +Device = Annotated[ + Union[CameraDevice, StageDevice, LaserDevice, FilterWheelDevice, UnknownDevice], + Field(discriminator="type"), +] -DEVICE_MODELS: dict[str, type[BaseModel]] = { - "camera": CameraSchema, +DEVICE_MODELS: dict[str, type[DeviceBase]] = { + "camera": CameraDevice, "stage": StageDevice, "laser": LaserDevice, "filterwheel": FilterWheelDevice, + "unknown": UnknownDevice, } -def device_key(dev: DeviceModel) -> str: - """Stable identifier of a device. - - CameraSchema predates DeviceBase and spells the field ``camera_id``; the - other types use ``device_id``. Both are optional, so the name is the - fallback. - """ - return getattr(dev, "device_id", None) or getattr(dev, "camera_id", None) or dev.name - - class DeviceRegistry(BaseModel): """Top-level object of a Devices.yml file.""" - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", validate_assignment=True, populate_by_name=True) + schema_ref: Optional[str] = Field( + default=None, + alias="$schema", + serialization_alias="$schema", + description="Optional pointer to the schema file, for editor autocomplete", + ) version: int = Field(default=1, ge=1, description="Registry format version") setup: Optional[str] = Field(default=None, description="Name of the setup") devices: list[Device] = Field(default_factory=list) @model_validator(mode="after") - def _unique_names(self) -> "DeviceRegistry": + def _unique_keys(self) -> "DeviceRegistry": seen: dict[str, int] = {} for i, dev in enumerate(self.devices): - key = device_key(dev) - if key in seen: + if dev.key in seen: raise ValueError( - f"devices[{i}]: duplicate identifier {key!r} (also at devices[{seen[key]}])" + f"devices[{i}]: duplicate identifier {dev.key!r} " + f"(also at devices[{seen[dev.key]}])" ) - seen[key] = i + seen[dev.key] = i return self - def by_type(self, type_: str) -> list[DeviceModel]: + def by_type(self, type_: str) -> list[Device]: """All devices of one type, e.g. registry.by_type('camera').""" return [d for d in self.devices if d.type == type_] - def get(self, key: str) -> DeviceModel: + def get(self, key: str) -> Device: """Look up one device by device_id, falling back to name.""" for dev in self.devices: - if device_key(dev) == key or dev.name == key: + if dev.key == key or dev.name == key: return dev raise KeyError(f"no device with id or name {key!r}") + @staticmethod + def demote_unknown_types(data: Any) -> Any: + """Rewrite entries with an unrecognized ``type`` to ``type: unknown``. + + NOT applied automatically, and that is deliberate: it cannot tell an + intentionally newer device type from a typo, so ``type: stagee`` would + be silently accepted as an opaque placeholder instead of failing. Call + it explicitly on the parsed dict when reading configs written by a + newer version is worth that price:: + + data = DeviceRegistry.demote_unknown_types(yaml.safe_load(text)) + registry = DeviceRegistry.model_validate(data) + """ + if not isinstance(data, dict) or not isinstance(data.get("devices"), list): + return data + + devices = [] + for entry in data["devices"]: + if ( + isinstance(entry, dict) + and entry.get("type") is not None + and entry["type"] not in DEVICE_MODELS + ): + entry = {**entry, "type": "unknown", "kind": entry["type"]} + devices.append(entry) + return {**data, "devices": devices} + if __name__ == "__main__": import json diff --git a/backend/newswitch/schemas/document.py b/backend/newswitch/schemas/document.py index 289fab5..e22b11a 100644 --- a/backend/newswitch/schemas/document.py +++ b/backend/newswitch/schemas/document.py @@ -1,8 +1,8 @@ """Reading and writing config documents, independent of any schema. This is the one place that knows about file formats and about `config.py`'s folder -layout. Everything above it (`camera_io`, `device_io`, `loader`) works on plain -Python objects and never touches a parser itself. +layout. Everything above it (`device_io`, `loader`) works on plain Python objects and +never touches a parser itself. YAML and JSON share the code path: both parsers produce plain dicts and lists, and validation happens on those. `yaml.safe_load` would in fact read .json too — YAML 1.2 @@ -12,8 +12,8 @@ Paths are resolved through `newswitch.config.get_paths()`, so a bare name like ``"Devices.yml"`` finds the file in the managed config directory while an explicit path is used as given. This module sits below `errors.ConfigError` and imports -nothing else from the package, which keeps `loader` importable from `camera_io` and -`device_io` without a cycle. +nothing else from the package, so `device_io` and `loader` can both build on it +without a cycle. """ from __future__ import annotations diff --git a/backend/newswitch/schemas/errors.py b/backend/newswitch/schemas/errors.py index 1fed0d6..772bee4 100644 --- a/backend/newswitch/schemas/errors.py +++ b/backend/newswitch/schemas/errors.py @@ -1,15 +1,20 @@ """Common error base for every config loader in this package. -`CameraConfigError` and `DeviceConfigError` both derive from `ConfigError`, so a -caller that does not care which loader it went through can write:: +`DeviceConfigError` derives from `ConfigError`, so a caller that does not care which +loader it went through can write:: try: registry = load_config(path) except ConfigError as exc: ... +`load_config`, `load_device` and `dump_config` raise `ConfigError` itself; the +lower-level `load_devices` and `dump_devices` raise `DeviceConfigError`. Note that +`schema_io.SchemaFormatError` is deliberately *not* part of this hierarchy -- it +reports an unusable *schema* file, not a bad configuration. + The base lives in its own module rather than in one of the io modules, because both -of them plus `loader` need it and importing it from either would create a cycle. +`device_io` and `loader` need it and importing it from either would create a cycle. `ConfigError` derives from `ValueError`, which is what the loaders raised before. """ diff --git a/backend/newswitch/schemas/loader.py b/backend/newswitch/schemas/loader.py index 5fd3e8a..b541862 100644 --- a/backend/newswitch/schemas/loader.py +++ b/backend/newswitch/schemas/loader.py @@ -1,8 +1,8 @@ """One entry point for every config layout. -`camera_io` and `device_io` each expect one specific document shape. In practice a -caller has a file and wants the devices in it, without caring whether that file -describes a single device (``hik_mv_ca023_10um.yaml``) or a whole setup +`device_io` expects one specific document shape: a registry with a ``devices`` key. +In practice a caller has a file and wants the devices in it, without caring whether +that file describes a single device (``hik_mv_ca023_10um.yaml``) or a whole setup (``Devices.yml``), and without caring whether it is JSON or YAML. `load_config()` normalizes all of those to the same result — a `DeviceRegistry`: @@ -16,10 +16,13 @@ anything else ConfigError ================================= ================================================== -A single-device document without a ``type`` key is read as a camera, mirroring the -default of `CameraSchema.type`; that is what lets ``hik_mv_ca023_10um.yaml`` load -unchanged. Every other device type has to spell out its ``type:``. A document that -carries both ``devices`` and ``name`` counts as a registry. +Every device type spells out its own ``type:``, cameras included -- `CameraDevice.type` +is a required discriminator with no default. A single-device document that omits the +key is wrapped as `DEFAULT_DEVICE_TYPE`, i.e. an opaque `UnknownDevice`: its contents +are preserved but nothing in it is validated. Note the asymmetry that follows from +this -- a *misspelled* ``type: stagee`` is rejected by the discriminator, while an +*omitted* ``type`` is silently accepted. A document that carries both ``devices`` and +``name`` counts as a registry. Validation is unchanged and still runs in two layers: the per-type jsonschema pass from `device_io` for structure, then Pydantic for the cross-field rules. Errors of a @@ -31,19 +34,27 @@ import re from pathlib import Path -from typing import Any, Optional, Union +from typing import Any, Literal, Optional, Union, overload from pydantic import BaseModel, ValidationError -from .camera_io import dump_camera -from .camera_schema import CameraSchema from .device_io import dump_devices, validate_registry -from .device_schema import DEVICE_MODELS, DeviceModel, DeviceRegistry, device_key +from .device_schema import ( + DEVICE_MODELS, + CameraDevice, + Device, + DeviceRegistry, + FilterWheelDevice, + LaserDevice, + StageDevice, + UnknownDevice, +) from .document import read_document from .errors import ConfigError -# A single-device file may omit `type`; CameraSchema defaults to "camera" too. -DEFAULT_DEVICE_TYPE = "camera" +# No device model defaults its `type`, so a single-device file that omits the key +# cannot be dispatched. It becomes an opaque UnknownDevice rather than a guess. +DEFAULT_DEVICE_TYPE = "unknown" _INDEX_PREFIX = re.compile(r"^devices\[0\]\.?") @@ -122,20 +133,87 @@ def load_config(src: str | Path, *, strict_schema: bool = True) -> DeviceRegistr raise ConfigError(f"{name}: {exc}") from exc +# `expect` already names the type the caller requires, so it may as well carry that +# type statically instead of only raising at runtime. One overload per entry of +# DEVICE_MODELS, then a fallback for everything else: a variable of type `str`, or an +# unknown name such as `expect="spectrometer"`, matches none of the literals and keeps +# the union as its return type. +@overload +def load_device( + src: str | Path, + key: Optional[str] = None, + *, + expect: Literal["camera"], + strict_schema: bool = True, +) -> CameraDevice: ... + + +@overload +def load_device( + src: str | Path, + key: Optional[str] = None, + *, + expect: Literal["stage"], + strict_schema: bool = True, +) -> StageDevice: ... + + +@overload +def load_device( + src: str | Path, + key: Optional[str] = None, + *, + expect: Literal["laser"], + strict_schema: bool = True, +) -> LaserDevice: ... + + +@overload +def load_device( + src: str | Path, + key: Optional[str] = None, + *, + expect: Literal["filterwheel"], + strict_schema: bool = True, +) -> FilterWheelDevice: ... + + +@overload +def load_device( + src: str | Path, + key: Optional[str] = None, + *, + expect: Literal["unknown"], + strict_schema: bool = True, +) -> UnknownDevice: ... + + +@overload def load_device( src: str | Path, key: Optional[str] = None, *, expect: Optional[str] = None, strict_schema: bool = True, -) -> DeviceModel: +) -> Device: ... + + +def load_device( + src: str | Path, + key: Optional[str] = None, + *, + expect: Optional[str] = None, + strict_schema: bool = True, +) -> Device: """Load exactly one device, from a single-device file or out of a registry. Args: src: Bare config name or an explicit path. - key: ``device_id``/``camera_id`` or name of the wanted device. May be omitted + key: ``device_id`` or name of the wanted device. May be omitted when the file holds exactly one device. - expect: Device type the caller requires, e.g. ``"camera"``. + expect: Device type the caller requires, e.g. ``"camera"``. Passed as a + literal it also narrows the return type, so the type-specific fields + are reachable without an `isinstance` check. strict_schema: Passed through to `load_config`. Returns: @@ -147,7 +225,7 @@ def load_device( """ registry = load_config(src, strict_schema=strict_schema) name = Path(src).name - available = [device_key(dev) for dev in registry.devices] + available = [dev.key for dev in registry.devices] if key is None: if len(registry.devices) != 1: @@ -170,9 +248,7 @@ def load_device( f"unknown device type {expect!r} (known: {', '.join(sorted(DEVICE_MODELS))})" ) if device.type != expect: - raise ConfigError( - f"{name}: {device_key(device)!r} is a {device.type}, expected a {expect}" - ) + raise ConfigError(f"{name}: {device.key!r} is a {device.type}, expected a {expect}") return device @@ -193,8 +269,6 @@ def dump_config(obj: Union[DeviceRegistry, BaseModel], dest: str | Path) -> Path """ if isinstance(obj, DeviceRegistry): return dump_devices(obj, dest) - if isinstance(obj, CameraSchema): - return dump_camera(obj, dest) if isinstance(obj, BaseModel) and getattr(obj, "type", None) in DEVICE_MODELS: # Non-camera devices have no single-device writer of their own; a one-entry # registry keeps the file loadable by load_config(). diff --git a/backend/newswitch/schemas/schema_io.py b/backend/newswitch/schemas/schema_io.py new file mode 100644 index 0000000..2257c0e --- /dev/null +++ b/backend/newswitch/schemas/schema_io.py @@ -0,0 +1,155 @@ +"""Read and write JSON Schema in either JSON or YAML. + +Why this works at all +--------------------- +JSON Schema is a **data model**, not a file format. The specification defines a +vocabulary over objects, arrays, strings and numbers -- it says nothing about +how those bytes reach you. YAML 1.2 is a superset of JSON and produces exactly +the same Python objects, so a schema written in YAML is the same schema. + +The `jsonschema` library takes a **dict**, never a filename: + + Draft202012Validator(yaml.safe_load(open("camera.schema.yaml"))) + +That is the whole trick. Everything else in this module is convenience and one +genuine gap: cross-file ``$ref``. + +The $ref gap +------------ +`$ref` to another *file* is the one place the format leaks. The default +retriever fetches the URI and parses it as JSON, so a `$ref` pointing at a +`.yaml` document fails. `build_validator()` installs a `referencing.Registry` +with a YAML-aware retrieve callable to close that. + +Internal refs (`#/$defs/Bounded`) are unaffected -- they are JSON Pointers into +the already-parsed dict and never touch the filesystem. Pydantic only ever +emits internal refs, so the exported single-file schemas work without any of +this. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Optional + +import yaml +from jsonschema import Draft202012Validator +from pydantic import BaseModel +from referencing import Registry, Resource +from referencing.jsonschema import DRAFT202012 + +DIALECT = "https://json-schema.org/draft/2020-12/schema" +JSON_SUFFIXES = {".json"} +YAML_SUFFIXES = {".yaml", ".yml"} + + +class SchemaFormatError(ValueError): + """Raised for unsupported file extensions.""" + + +def load_any(path: str | Path) -> Any: + """Parse .json, .yaml or .yml into plain Python objects. + + `yaml.safe_load` would in fact read both, since YAML 1.2 is a superset of + JSON. The split is kept because the JSON parser is stricter and faster, and + a `.json` file that only parses as YAML is a file worth rejecting. + """ + path = Path(path) + text = path.read_text(encoding="utf-8") + if path.suffix.lower() in JSON_SUFFIXES: + return json.loads(text) + if path.suffix.lower() in YAML_SUFFIXES: + return yaml.safe_load(text) + raise SchemaFormatError(f"Unsupported file type: {path.suffix}") + + +def dump_any(obj: Any, path: str | Path) -> Path: + """Serialize to .json or .yaml, chosen by the file extension.""" + path = Path(path) + if path.suffix.lower() in JSON_SUFFIXES: + path.write_text(json.dumps(obj, indent=2) + "\n", encoding="utf-8") + elif path.suffix.lower() in YAML_SUFFIXES: + path.write_text( + yaml.safe_dump(obj, sort_keys=False, allow_unicode=True, width=100), + encoding="utf-8", + ) + else: + raise SchemaFormatError(f"Unsupported file type: {path.suffix}") + return path + + +def export_schema( + model: type[BaseModel], path: str | Path, *, header: Optional[str] = None +) -> Path: + """Write a Pydantic model's JSON Schema to .json or .yaml. + + The model stays the single source of truth; both files are generated + artifacts. `header` is written as a YAML comment (JSON has no comments, so + it is dropped there). + """ + schema = model.model_json_schema() + schema = {"$schema": DIALECT, **schema} + path = dump_any(schema, path) + + if header and path.suffix.lower() in YAML_SUFFIXES: + comment = "\n".join(f"# {line}" for line in header.splitlines()) + path.write_text( + f"{comment}\n# Generated from {model.__name__} -- do not edit by hand.\n\n" + + path.read_text(encoding="utf-8"), + encoding="utf-8", + ) + return path + + +def build_validator( + schema: dict[str, Any] | str | Path, base_dir: str | Path | None = None +) -> Draft202012Validator: + """Build a validator whose ``$ref`` resolution understands YAML files. + + Parameters + ---------- + schema + A schema dict, or a path to a .json/.yaml schema file. + base_dir + Directory that relative ``$ref`` targets are resolved against. + Defaults to the schema file's own directory. + """ + if isinstance(schema, (str, Path)): + base_dir = base_dir or Path(schema).parent + loaded = load_any(schema) + if not isinstance(loaded, dict): + raise SchemaFormatError( + f"{schema}: expected a mapping at the top level, got {type(loaded).__name__}" + ) + document: dict[str, Any] = loaded + else: + document = schema + root = Path(base_dir or ".").resolve() + + def retrieve(uri: str) -> Resource: + """Load a referenced schema file, JSON or YAML alike.""" + target = (root / uri).resolve() + if not target.is_file(): + raise SchemaFormatError(f"Cannot resolve $ref {uri!r} below {root}") + return Resource.from_contents(load_any(target), default_specification=DRAFT202012) + + return Draft202012Validator(document, registry=Registry(retrieve=retrieve)) + + +def convert(src: str | Path, dst: str | Path) -> Path: + """Convert any schema or data file between JSON and YAML.""" + return dump_any(load_any(src), dst) + + +if __name__ == "__main__": + import sys + + # Converting between the two formats is what this module uniquely offers. + # Exporting a model's schema belongs to whoever owns the model: use + # `device_io.export_json_schema()`, which knows the managed schema directory. + if len(sys.argv) != 3: + print(f"usage: python -m {__spec__.name} ") + raise SystemExit(2) + + print(convert(sys.argv[1], sys.argv[2])) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index fcd704a..adf271e 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -112,15 +112,20 @@ def _write(document: Any, name: str) -> Path: # noqa: ANN401 - any JSON-shaped def camera_doc() -> dict[str, Any]: """A valid single-camera document, exercising both `Bounded` spellings. + Carries its own ``type``: no device model defaults the discriminator, so a + document without it loads as an opaque `UnknownDevice`. The few tests that + exercise that path drop the key themselves. + Returns: The document as a plain dict. """ return { + "type": "camera", "name": "TestCam-2000", "manufacturer": "openUC2", - "camera_id": "testcam-001", + "device_id": "testcam-001", "pixelcount": {"x": 1920, "y": 1200}, - "pixelpitch_um": {"x": 10.0}, # y omitted -> derived + "pixelpitch_um": {"x": 10.0}, # y omitted -> see Vector2Float.effective_y "channels": 1, "exposure_time_ms": {"value": 10.0, "min": 0.024, "max": 10000.0, "increment": 0.001}, "framerate_per_sec": 30.0, # scalar shorthand diff --git a/backend/tests/test_camera_io.py b/backend/tests/test_camera_io.py deleted file mode 100644 index ab4783b..0000000 --- a/backend/tests/test_camera_io.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Tests for `camera_io`: the camera-only loader, writer and schema exporter. - -The point of this module is the two-layer split. The jsonschema pass reports every -structural problem at once; the Pydantic pass adds the cross-field rules and the -derived defaults that JSON Schema cannot express. Both layers must accept the same -documents, in JSON and in YAML alike. -""" - -import json -from pathlib import Path -from typing import Any - -import pytest - -from newswitch.schemas import ( - CameraConfigError, - CameraSchema, - ConfigError, - dump_camera, - export_camera_schema, - load_camera, - read_document, - schema_errors, -) -from tests.conftest import ConfigWriter - - -def test_yaml_and_json_produce_the_same_model( - camera_doc: dict[str, Any], write_config: ConfigWriter -) -> None: - """The format only picks the parser; the resulting model is identical. - - Args: - camera_doc: The valid camera document fixture. - write_config: Helper writing a document into the temporary config dir. - """ - as_yaml = load_camera(write_config(camera_doc, "cam.yaml")) - as_yml = load_camera(write_config(camera_doc, "cam.yml")) - as_json = load_camera(write_config(camera_doc, "cam.json")) - - assert as_yaml == as_json == as_yml - - -def test_bare_name_is_resolved_against_the_config_dir( - camera_doc: dict[str, Any], write_config: ConfigWriter -) -> None: - """A name without a directory — and even without a suffix — is looked up. - - Args: - camera_doc: The valid camera document fixture. - write_config: Helper writing a document into the temporary config dir. - """ - write_config(camera_doc, "testcam.yaml") - assert load_camera("testcam").name == "TestCam-2000" - assert load_camera("testcam.yaml").name == "TestCam-2000" - - -def test_missing_file_reports_what_it_tried() -> None: - """A name that resolves to nothing names the candidates it looked for.""" - with pytest.raises(CameraConfigError, match="tried:"): - load_camera("does-not-exist") - - -def test_unsupported_suffix_rejected(tmp_path: Path) -> None: - """Only .json/.yaml/.yml are parsed; anything else is refused up front. - - Args: - tmp_path: Pytest's built-in temporary path fixture. - """ - path = tmp_path / "camera.txt" - path.write_text("name: nope", encoding="utf-8") - with pytest.raises(CameraConfigError, match="Unsupported file type"): - load_camera(path) - - -def test_top_level_must_be_a_mapping(write_config: ConfigWriter) -> None: - """A camera file describes one camera, so a list at the top level is an error. - - Args: - write_config: Helper writing a document into the temporary config dir. - """ - with pytest.raises(CameraConfigError, match="expected a mapping"): - load_camera(write_config([{"name": "Cam"}], "list.yaml")) - - -def test_camera_errors_are_config_errors(write_config: ConfigWriter) -> None: - """`CameraConfigError` is a `ConfigError`, so callers can catch just the base. - - Args: - write_config: Helper writing a document into the temporary config dir. - """ - with pytest.raises(ConfigError): - load_camera(write_config({"name": "Incomplete"}, "bad.yaml")) - - -def test_schema_errors_lists_every_structural_problem(camera_doc: dict[str, Any]) -> None: - """The jsonschema pass reports all violations at once, not just the first. - - Args: - camera_doc: The valid camera document fixture. - """ - assert schema_errors(camera_doc) == [] - - broken = {**camera_doc, "pixelpitch_um": {"x": -1.0}, "binning": 3, "colour": "red"} - del broken["name"] - messages = schema_errors(broken) - - assert len(messages) >= 4 - joined = "\n".join(messages) - assert "'name' is a required property" in joined - assert "pixelpitch_um.x" in joined - assert "binning" in joined - assert "colour" in joined - - -def test_pydantic_layer_catches_what_jsonschema_cannot( - camera_doc: dict[str, Any], write_config: ConfigWriter -) -> None: - """An oversized ROI is structurally valid but rejected by the model. - - `minimum`/`maximum` in JSON Schema are global constants and cannot refer to - another field, so "the ROI fits inside this sensor" only exists in Pydantic. - With `strict_schema=False` the jsonschema pass is skipped entirely and the - model still has to reject the file. - - Args: - camera_doc: The valid camera document fixture. - write_config: Helper writing a document into the temporary config dir. - """ - # A list, not a tuple: jsonschema validates parsed documents, and a Python tuple - # is not a JSON array to it. Feed it what the parser produces. - camera_doc["roi"] = [0, 0, 4096, 4096] - path = write_config(camera_doc, "oversized_roi.yaml") - - assert schema_errors(read_document(path)) == [] - with pytest.raises(CameraConfigError, match="exceeds sensor pixelcount"): - load_camera(path, strict_schema=False) - - -@pytest.mark.parametrize("suffix", [".yaml", ".yml", ".json"]) -def test_round_trip(camera_doc: dict[str, Any], write_config: ConfigWriter, suffix: str) -> None: - """A loaded camera can be written back and reloaded unchanged. - - Args: - camera_doc: The valid camera document fixture. - write_config: Helper writing a document into the temporary config dir. - suffix: Output format under test. - """ - original = load_camera(write_config(camera_doc, "cam.yaml")) - written = dump_camera(original, f"round_trip{suffix}") - - assert written.exists() - assert load_camera(written) == original - - -def test_dump_fills_in_the_derived_values( - camera_doc: dict[str, Any], write_config: ConfigWriter -) -> None: - """Storing writes the resolved model, so derived fields become explicit. - - Args: - camera_doc: The valid camera document fixture. - write_config: Helper writing a document into the temporary config dir. - """ - camera = load_camera(write_config(camera_doc, "cam.yaml")) - written = json.loads(dump_camera(camera, "resolved.json").read_text(encoding="utf-8")) - - assert written["pixelpitch_um"] == {"x": 10.0, "y": 10.0} # y was omitted in the source - assert written["pixelsize_um"] == {"x": 10.0, "y": 10.0} # whole object was omitted - assert written["type"] == "camera" - - -def test_dump_rejects_unsupported_suffix( - camera_doc: dict[str, Any], write_config: ConfigWriter, tmp_path: Path -) -> None: - """Writing to an unknown extension fails instead of guessing a format. - - Args: - camera_doc: The valid camera document fixture. - write_config: Helper writing a document into the temporary config dir. - tmp_path: Pytest's built-in temporary path fixture. - """ - camera = load_camera(write_config(camera_doc, "cam.yaml")) - with pytest.raises(CameraConfigError, match="Unsupported file type"): - dump_camera(camera, tmp_path / "camera.txt") - - -def test_export_json_schema(tmp_path: Path) -> None: - """The exported schema is valid JSON and declares the draft it follows. - - Args: - tmp_path: Pytest's built-in temporary path fixture. - """ - target = export_camera_schema(tmp_path / "camera.schema.json") - schema = json.loads(target.read_text(encoding="utf-8")) - - assert schema["$schema"] == "https://json-schema.org/draft/2020-12/schema" - assert schema["properties"].keys() >= {"name", "pixelcount", "pixelpitch_um"} - assert schema["required"] == CameraSchema.model_json_schema()["required"] - - -def test_export_json_schema_defaults_to_the_schema_dir(config_dir: Path) -> None: - """Without an argument the export lands in the managed schema directory. - - Args: - config_dir: The temporary config directory fixture. - """ - target = export_camera_schema() - assert target == config_dir / "schemas" / "camera.schema.json" - assert target.is_file() diff --git a/backend/tests/test_camera_schema.py b/backend/tests/test_camera_schema.py deleted file mode 100644 index 1be041d..0000000 --- a/backend/tests/test_camera_schema.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Tests for `CameraSchema`: required fields, derived geometry and cross-field rules. - -These are the rules that live in Pydantic only — the exported JSON Schema checks -structure and types, but cannot fill in a default from a sibling field or compare an -ROI against the sensor size. -""" - -from typing import Any - -import pytest -from pydantic import ValidationError - -from newswitch.schemas import CameraSchema - - -@pytest.mark.parametrize("missing", ["name", "pixelcount", "pixelpitch_um"]) -def test_required_fields(camera_doc: dict[str, Any], missing: str) -> None: - """`name`, `pixelcount` and `pixelpitch_um` have no default and are required. - - Args: - camera_doc: The valid camera document fixture. - missing: Key removed before validation. - """ - camera_doc.pop(missing) - with pytest.raises(ValidationError, match=missing): - CameraSchema.model_validate(camera_doc) - - -def test_optional_fields_may_be_omitted() -> None: - """Everything else is optional: a model description needs no serial or exposure.""" - camera = CameraSchema.model_validate( - {"name": "Minimal", "pixelcount": {"x": 640, "y": 480}, "pixelpitch_um": {"x": 5.0}} - ) - assert camera.camera_id is None - assert camera.exposure_time_ms is None - assert camera.channels == 1 - assert camera.binning == 1 - assert camera.tags == [] - assert camera.type == "camera" - - -def test_pixelpitch_y_defaults_to_x(camera_doc: dict[str, Any]) -> None: - """Square pixels are the common case, so `y` may be omitted. - - Args: - camera_doc: The valid camera document fixture. - """ - camera = CameraSchema.model_validate(camera_doc) - assert camera.pixelpitch_um.x == 10.0 - assert camera.pixelpitch_um.y == 10.0 - - -def test_pixelsize_defaults_to_pixelpitch(camera_doc: dict[str, Any]) -> None: - """An omitted `pixelsize_um` means a fill factor of 1.0. - - Args: - camera_doc: The valid camera document fixture. - """ - camera = CameraSchema.model_validate(camera_doc) - assert camera.pixelsize_um is not None - assert (camera.pixelsize_um.x, camera.pixelsize_um.y) == (10.0, 10.0) - assert camera.fill_factor == pytest.approx(1.0) - - -def test_pixelsize_y_falls_back_to_pixelsize_x(camera_doc: dict[str, Any]) -> None: - """`pixelsize_um.y` falls back to `pixelsize_um.x`, *not* to `pixelpitch_um.y`. - - Both are `Vector2Float`, so each object fills its own `y` from its own `x`. - Worth pinning: for a non-square pitch this is the surprising half of the rule. - - Args: - camera_doc: The valid camera document fixture. - """ - camera_doc["pixelpitch_um"] = {"x": 10.0, "y": 20.0} - camera_doc["pixelsize_um"] = {"x": 8.0} - camera = CameraSchema.model_validate(camera_doc) - assert camera.pixelsize_um is not None - assert camera.pixelsize_um.y == 8.0 - assert camera.fill_factor == pytest.approx((8.0 * 8.0) / (10.0 * 20.0)) - - -def test_sensor_size_mm(camera_doc: dict[str, Any]) -> None: - """Sensor extent is pixelcount times pitch, converted to millimeters. - - Args: - camera_doc: The valid camera document fixture. - """ - camera = CameraSchema.model_validate(camera_doc) - assert camera.sensor_size_mm == pytest.approx((19.2, 12.0)) - - -def test_roi_within_sensor_accepted(camera_doc: dict[str, Any]) -> None: - """An ROI that fits inside the sensor is fine. - - Args: - camera_doc: The valid camera document fixture. - """ - camera_doc["roi"] = (0, 0, 1920, 1200) - assert CameraSchema.model_validate(camera_doc).roi == (0, 0, 1920, 1200) - - -@pytest.mark.parametrize( - ("roi", "message"), - [ - ((0, 0, 1921, 1200), "exceeds sensor pixelcount"), - ((100, 100, 1900, 100), "exceeds sensor pixelcount"), - ((-1, 0, 100, 100), "must be non-negative"), - ((0, 0, 0, 100), "must be positive"), - ], -) -def test_roi_cross_field_rules( - camera_doc: dict[str, Any], roi: tuple[int, int, int, int], message: str -) -> None: - """The ROI is checked against `pixelcount` — a rule JSON Schema cannot express. - - Args: - camera_doc: The valid camera document fixture. - roi: The region of interest under test. - message: Fragment expected in the error message. - """ - camera_doc["roi"] = roi - with pytest.raises(ValidationError, match=message): - CameraSchema.model_validate(camera_doc) - - -def test_software_trigger_rejects_edge(camera_doc: dict[str, Any]) -> None: - """`edge`/`level` are meaningless for a software trigger and treated as a mistake. - - Args: - camera_doc: The valid camera document fixture. - """ - camera_doc["trigger"] = {"type": "software", "edge": "rising"} - with pytest.raises(ValidationError, match="only meaningful for hardware"): - CameraSchema.model_validate(camera_doc) - - -def test_hardware_trigger_accepts_edge(camera_doc: dict[str, Any]) -> None: - """A hardware trigger may name the edge it fires on. - - Args: - camera_doc: The valid camera document fixture. - """ - camera = CameraSchema.model_validate(camera_doc) - assert camera.trigger is not None - assert (camera.trigger.type, camera.trigger.edge) == ("hardware", "rising") - - -def test_unknown_key_rejected(camera_doc: dict[str, Any]) -> None: - """`extra="forbid"` turns a typo into an error instead of a silently lost setting. - - Args: - camera_doc: The valid camera document fixture. - """ - camera_doc["pixelcont"] = {"x": 1, "y": 1} - with pytest.raises(ValidationError, match="pixelcont"): - CameraSchema.model_validate(camera_doc) - - -@pytest.mark.parametrize("binning", [0, 3, 8]) -def test_binning_is_a_closed_set(camera_doc: dict[str, Any], binning: int) -> None: - """Only symmetric binning factors 1, 2 and 4 exist on the supported hardware. - - Args: - camera_doc: The valid camera document fixture. - binning: The rejected binning factor. - """ - camera_doc["binning"] = binning - with pytest.raises(ValidationError): - CameraSchema.model_validate(camera_doc) - - -def test_type_discriminator_is_fixed(camera_doc: dict[str, Any]) -> None: - """A camera document cannot claim to be another device type. - - Args: - camera_doc: The valid camera document fixture. - """ - camera_doc["type"] = "stage" - with pytest.raises(ValidationError): - CameraSchema.model_validate(camera_doc) - - -def test_schema_pointer_round_trips(camera_doc: dict[str, Any]) -> None: - """The `$schema` editor pointer is read into `schema_ref` and written back as-is. - - Args: - camera_doc: The valid camera document fixture. - """ - camera_doc["$schema"] = "./schemas/camera.schema.json" - camera = CameraSchema.model_validate(camera_doc) - assert camera.schema_ref == "./schemas/camera.schema.json" - - dumped = camera.model_dump(mode="json", by_alias=True, exclude_none=True) - assert dumped["$schema"] == "./schemas/camera.schema.json" - assert "schema_ref" not in dumped - - -def test_pixelcount_must_be_positive_integers(camera_doc: dict[str, Any]) -> None: - """A sensor with no pixels is not a sensor. - - Args: - camera_doc: The valid camera document fixture. - """ - camera_doc["pixelcount"] = {"x": 0, "y": 1200} - with pytest.raises(ValidationError): - CameraSchema.model_validate(camera_doc) diff --git a/backend/tests/test_device_io.py b/backend/tests/test_device_io.py index 0ddafe9..413758e 100644 --- a/backend/tests/test_device_io.py +++ b/backend/tests/test_device_io.py @@ -76,10 +76,8 @@ def test_every_problem_is_reported_once_and_precisely(broken_doc: dict[str, Any] assert "devices[0].binning: 3 is not one of [1, 2, 4]" in errors assert "colour" in joined assert "devices[1].axes: [] should be non-empty" in errors - assert ( - "devices[2]: unknown type 'spectrometer' (known: camera, filterwheel, laser, stage)" - in errors - ) + assert "devices[2]: unknown type 'spectrometer'" in joined + assert "known: camera, filterwheel, laser, stage, unknown" in joined assert "devices[3]: missing 'type' discriminator" in errors assert "devices[4]: expected an object, got str" in errors diff --git a/backend/tests/test_device_schema.py b/backend/tests/test_device_schema.py index a7195bf..902f65a 100644 --- a/backend/tests/test_device_schema.py +++ b/backend/tests/test_device_schema.py @@ -11,12 +11,11 @@ from pydantic import ValidationError from newswitch.schemas import ( - CameraSchema, + CameraDevice, DeviceRegistry, FilterWheelDevice, LaserDevice, StageDevice, - device_key, ) @@ -28,7 +27,7 @@ def test_registry_dispatches_by_type(registry_doc: dict[str, Any]) -> None: """ registry = DeviceRegistry.model_validate(registry_doc) assert [type(dev) for dev in registry.devices] == [ - CameraSchema, + CameraDevice, StageDevice, LaserDevice, FilterWheelDevice, @@ -88,31 +87,36 @@ def test_get_by_id_and_by_name(registry_doc: dict[str, Any]) -> None: registry = DeviceRegistry.model_validate(registry_doc) assert registry.get("laser-001").name == "LD-488" assert registry.get("LD-488").name == "LD-488" - assert registry.get("testcam-001").name == "TestCam-2000" # camera_id, not device_id + assert registry.get("testcam-001").name == "TestCam-2000" with pytest.raises(KeyError, match="nope"): registry.get("nope") -def test_device_key_precedence() -> None: - """`device_key` bridges `device_id`, the camera's `camera_id`, and the name.""" +def test_key_precedence() -> None: + """`DeviceBase.key` is `device_id` when set and the name otherwise. + + One identifier field for every device type: cameras used to carry their own + `camera_id`, which the merge into `DeviceBase` removed. + """ stage = StageDevice( type="stage", name="XY", device_id="stage-007", axes=[{"label": "x", "travel_um": 1.0, "steps_per_um": 1.0}], # type: ignore[list-item] ) - camera = CameraSchema( + camera = CameraDevice( + type="camera", name="Cam", - camera_id="cam-007", + device_id="cam-007", pixelcount={"x": 1, "y": 1}, # type: ignore[arg-type] pixelpitch_um={"x": 1.0}, # type: ignore[arg-type] ) anonymous = LaserDevice(type="laser", name="LD-405", wavelength_nm=405.0, max_power_mw=10.0) - assert device_key(stage) == "stage-007" - assert device_key(camera) == "cam-007" - assert device_key(anonymous) == "LD-405" + assert stage.key == "stage-007" + assert camera.key == "cam-007" + assert anonymous.key == "LD-405" def test_duplicate_identifiers_rejected(registry_doc: dict[str, Any]) -> None: diff --git a/backend/tests/test_schema_export_drift.py b/backend/tests/test_schema_export_drift.py index 3a73659..0a3e0cf 100644 --- a/backend/tests/test_schema_export_drift.py +++ b/backend/tests/test_schema_export_drift.py @@ -9,8 +9,7 @@ Both checks read the development-only `Configs/` folder (see `Configs/__note__.md`), so every test here skips when that folder is gone. Regenerate with -``uv run python example_schemas.py`` or `export_camera_schema()` / -`export_devices_schema()`. +``uv run python example_schemas.py`` or `export_devices_schema()`. """ import json @@ -19,7 +18,7 @@ import pytest from newswitch.config import Paths -from newswitch.schemas import CameraSchema, DeviceRegistry, load_config, load_device +from newswitch.schemas import DeviceRegistry, load_config, load_device DRAFT = "https://json-schema.org/draft/2020-12/schema" @@ -47,7 +46,7 @@ def _requires(path: Path) -> Path: @pytest.mark.parametrize( ("model", "file_name"), - [(CameraSchema, "camera.schema.json"), (DeviceRegistry, "devices.schema.json")], + [(DeviceRegistry, "devices.schema.json")], ) def test_exported_schema_matches_the_model(model: type, file_name: str) -> None: """The checked-in JSON Schema is exactly what the model generates today. @@ -75,17 +74,14 @@ def test_sample_registry_still_loads() -> None: assert registry.get("laser-488-001").name == "LD-488-100" -@pytest.mark.parametrize("file_name", ["hik_mv_ca023_10um.yaml", "hik_mv_ca023_10um.json"]) -def test_sample_camera_still_loads(file_name: str) -> None: - """The single-device sample loads in both formats, with `type` omitted. - - Args: - file_name: The sample file under test. - """ - camera = load_device(_requires(CONFIG_DIR / file_name), expect="camera") +def test_sample_camera_still_loads() -> None: + """The single-device sample loads as the camera it declares.""" + camera = load_device(_requires(CONFIG_DIR / "hik_mv_ca023_10um.yaml"), expect="camera") assert camera.name == "MV-CA023-10UM" - assert camera.pixelpitch_um.y == 10.0 # derived from x + # `y` is omitted in the file and stays None; the fallback is a property. + assert camera.pixelpitch_um.y is None + assert camera.pixelpitch_um.effective_y == 10.0 assert camera.sensor_size_mm == pytest.approx((19.2, 12.0)) diff --git a/backend/tests/test_schema_loader.py b/backend/tests/test_schema_loader.py index 42d4c8e..1c2e074 100644 --- a/backend/tests/test_schema_loader.py +++ b/backend/tests/test_schema_loader.py @@ -11,11 +11,12 @@ import pytest from newswitch.schemas import ( - CameraSchema, + CameraDevice, ConfigError, DeviceRegistry, LaserDevice, StageDevice, + UnknownDevice, dump_config, load_config, load_device, @@ -42,9 +43,6 @@ def test_single_device_file_is_wrapped( ) -> None: """A single-device file yields a one-entry registry, not a special case. - The camera fixture has no `type` key at all — a bare device document is read as - a camera, mirroring the default of `CameraSchema.type`. - Args: camera_doc: The valid camera document fixture. write_config: Helper writing a document into the temporary config dir. @@ -52,13 +50,41 @@ def test_single_device_file_is_wrapped( registry = load_config(write_config(camera_doc, "cam.yaml")) assert len(registry.devices) == 1 - assert isinstance(registry.devices[0], CameraSchema) + assert isinstance(registry.devices[0], CameraDevice) assert registry.version == 1 assert registry.setup is None -def test_single_non_camera_device_needs_its_type(write_config: ConfigWriter) -> None: - """Any device other than a camera has to spell out `type:`. +def test_typeless_single_device_file_becomes_opaque( + camera_doc: dict[str, Any], write_config: ConfigWriter +) -> None: + """Without a `type` key there is nothing to dispatch on, so nothing is validated. + + No device model defaults its discriminator, so `DEFAULT_DEVICE_TYPE` applies and + the entry is kept as an opaque `UnknownDevice`: its fields survive the round-trip + but none of them are checked. + + Args: + camera_doc: The valid camera document fixture. + write_config: Helper writing a document into the temporary config dir. + """ + typeless = {k: v for k, v in camera_doc.items() if k != "type"} + registry = load_config(write_config(typeless, "cam.yaml")) + + device = registry.devices[0] + assert isinstance(device, UnknownDevice) + assert device.payload["pixelcount"] == {"x": 1920, "y": 1200} + + +def test_a_typo_in_type_is_caught_but_an_omission_is_not( + write_config: ConfigWriter, +) -> None: + """The asymmetry that `DEFAULT_DEVICE_TYPE = "unknown"` buys, pinned deliberately. + + A misspelled discriminator is rejected — that is what catches `stagee`. An + *omitted* one cannot be told apart from a device this version does not model, so + it is accepted as opaque payload instead. Validation is lost silently, which is + the price of being able to hold a config written by a newer build. Args: write_config: Helper writing a document into the temporary config dir. @@ -72,10 +98,12 @@ def test_single_non_camera_device_needs_its_type(write_config: ConfigWriter) -> registry = load_config(write_config(stage_doc, "stage.yaml")) assert isinstance(registry.devices[0], StageDevice) - # Without the discriminator the same document is read as a camera and fails. + with pytest.raises(ConfigError, match="unknown type 'stagee'"): + load_config(write_config({**stage_doc, "type": "stagee"}, "typo.yaml")) + del stage_doc["type"] - with pytest.raises(ConfigError, match="pixelcount"): - load_config(write_config(stage_doc, "typeless.yaml")) + registry = load_config(write_config(stage_doc, "typeless.yaml")) + assert isinstance(registry.devices[0], UnknownDevice) def test_bare_device_list(registry_doc: dict[str, Any], write_config: ConfigWriter) -> None: @@ -154,7 +182,13 @@ def test_single_device_errors_drop_the_index_prefix(write_config: ConfigWriter) write_config: Helper writing a document into the temporary config dir. """ path = write_config( - {"name": "BadCam", "pixelcount": {"x": -5}, "pixelpitch_um": {"x": 3.45}, "colour": "red"}, + { + "type": "camera", + "name": "BadCam", + "pixelcount": {"x": -5}, + "pixelpitch_um": {"x": 3.45}, + "colour": "red", + }, "badcam.yaml", ) with pytest.raises(ConfigError) as excinfo: @@ -203,7 +237,7 @@ def test_load_device_from_a_single_device_file( write_config(camera_doc, "testcam.yaml") camera = load_device("testcam") - assert isinstance(camera, CameraSchema) + assert isinstance(camera, CameraDevice) assert camera.sensor_size_mm == pytest.approx((19.2, 12.0)) @@ -334,4 +368,4 @@ def test_dump_config_wraps_a_non_camera_device( def test_dump_config_rejects_other_objects() -> None: """Handing `dump_config` something that is not a device fails clearly.""" with pytest.raises(ConfigError, match="cannot store"): - dump_config(CameraSchema.model_json_schema(), "nope.yaml") # type: ignore[arg-type] + dump_config(CameraDevice.model_json_schema(), "nope.yaml") # type: ignore[arg-type] From 7784bc0de909047db3780543380d8a29084e2213 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 28 Aug 2026 01:40:59 +0200 Subject: [PATCH 05/24] fix(schema): fixed ruff errors in the schemas --- backend/newswitch/schemas/device_io.py | 2 +- backend/newswitch/schemas/device_schema.py | 12 ++++++++---- backend/newswitch/schemas/schema_io.py | 4 ++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/backend/newswitch/schemas/device_io.py b/backend/newswitch/schemas/device_io.py index b098fd5..d27208a 100644 --- a/backend/newswitch/schemas/device_io.py +++ b/backend/newswitch/schemas/device_io.py @@ -46,7 +46,7 @@ class DeviceConfigError(ConfigError): } -def validate_registry(data: Any) -> list[str]: +def validate_registry(data: Any) -> list[str]: # noqa: ANN401 """Return all schema violations as readable strings (empty list = valid). Each device is checked against the schema of its own ``type`` only. diff --git a/backend/newswitch/schemas/device_schema.py b/backend/newswitch/schemas/device_schema.py index 529aa74..d9f580d 100644 --- a/backend/newswitch/schemas/device_schema.py +++ b/backend/newswitch/schemas/device_schema.py @@ -34,7 +34,9 @@ import math from typing import Annotated, Any, Literal, Optional, Union -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator, GetJsonSchemaHandler +from pydantic.json_schema import JsonSchemaValue +from pydantic_core import CoreSchema # import numpy as np # noqa: ERA001 -- needed for quantum_efficiency, see below @@ -77,7 +79,7 @@ class Bounded(BaseModel): @model_validator(mode="before") @classmethod - def _accept_scalar(cls, data: Any) -> Any: + def _accept_scalar(cls, data: Any) -> Any: # noqa: ANN401 """Allow ``exposure_time_ms: 10.0`` as shorthand for ``{value: 10.0}``.""" if isinstance(data, (int, float)) and not isinstance(data, bool): return {"value": float(data)} @@ -104,7 +106,9 @@ def _check_consistency(self) -> "Bounded": return self @classmethod - def __get_pydantic_json_schema__(cls, core_schema, handler): # noqa: ANN001 + def __get_pydantic_json_schema__( + cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler + ) -> JsonSchemaValue: # noqa: ANN001 """Emit ``anyOf: [number, object]`` so the shorthand validates too.""" schema = handler(core_schema) schema = handler.resolve_ref_schema(schema) @@ -460,7 +464,7 @@ def get(self, key: str) -> Device: raise KeyError(f"no device with id or name {key!r}") @staticmethod - def demote_unknown_types(data: Any) -> Any: + def demote_unknown_types(data: Any) -> Any: # noqa: ANN401 """Rewrite entries with an unrecognized ``type`` to ``type: unknown``. NOT applied automatically, and that is deliberate: it cannot tell an diff --git a/backend/newswitch/schemas/schema_io.py b/backend/newswitch/schemas/schema_io.py index 2257c0e..5cf7ad3 100644 --- a/backend/newswitch/schemas/schema_io.py +++ b/backend/newswitch/schemas/schema_io.py @@ -48,7 +48,7 @@ class SchemaFormatError(ValueError): """Raised for unsupported file extensions.""" -def load_any(path: str | Path) -> Any: +def load_any(path: str | Path) -> Any: # noqa: ANN401 """Parse .json, .yaml or .yml into plain Python objects. `yaml.safe_load` would in fact read both, since YAML 1.2 is a superset of @@ -64,7 +64,7 @@ def load_any(path: str | Path) -> Any: raise SchemaFormatError(f"Unsupported file type: {path.suffix}") -def dump_any(obj: Any, path: str | Path) -> Path: +def dump_any(obj: Any, path: str | Path) -> Path: # noqa: ANN401 """Serialize to .json or .yaml, chosen by the file extension.""" path = Path(path) if path.suffix.lower() in JSON_SUFFIXES: From 136d46cf3e8d9be89cb9db07f83da7c775c96cec Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 28 Aug 2026 01:48:14 +0200 Subject: [PATCH 06/24] docs(srs): removed srs --- SRS/CHANGELOG_KC.md | 91 ----- SRS/InitReview.md | 506 ---------------------------- SRS/InvariantMatrix.csv | 72 ---- SRS/InvariantMatrix.md | 329 ------------------ SRS/KeyFeatureOverview.ods | Bin 25944 -> 0 bytes SRS/MVP_TASKS.md | 313 ----------------- SRS/OpenPoints.md | 613 --------------------------------- SRS/SRS_STANDARD.md | 243 -------------- SRS/SRS_V1.md | 671 ------------------------------------- SRS/TODOS.md | 0 SRS/__README.md | 18 - SRS/basicRequirementsV1.md | 171 ---------- 12 files changed, 3027 deletions(-) delete mode 100644 SRS/CHANGELOG_KC.md delete mode 100644 SRS/InitReview.md delete mode 100644 SRS/InvariantMatrix.csv delete mode 100644 SRS/InvariantMatrix.md delete mode 100644 SRS/KeyFeatureOverview.ods delete mode 100644 SRS/MVP_TASKS.md delete mode 100644 SRS/OpenPoints.md delete mode 100644 SRS/SRS_STANDARD.md delete mode 100644 SRS/SRS_V1.md delete mode 100644 SRS/TODOS.md delete mode 100644 SRS/__README.md delete mode 100644 SRS/basicRequirementsV1.md diff --git a/SRS/CHANGELOG_KC.md b/SRS/CHANGELOG_KC.md deleted file mode 100644 index 21b4552..0000000 --- a/SRS/CHANGELOG_KC.md +++ /dev/null @@ -1,91 +0,0 @@ -# Changelog from C.Karras - -## Version: 1.2 29.07.26 — DEC-001: live-video transport decided - -Documentation only. **No source code was changed.** - -### Decision -- **`DEC-001` — the live-video transport is LiveKit / WebRTC / VP8 with an SFU**, per - `basicRequirements.md` §Image handling. The architecture-freeze rule `CON-ARC-002` is - **exceptionally waived for this subsystem only**; all other subsystems remain frozen. -- Closes `OP-001` (was the highest-severity CONFLICT in the register). - -### Changed — `DOC/SRS_V1.md` -- `IF-CM-005` rewritten from ***UNCLEAR*** to a binding requirement; new `IF-CM-007` … `IF-CM-012` - specify the I420 conversion, the `capture_frame()` publish path, the SFU deployment, the - `livekit-client` subscriber, token/room configurability, and independence of the video plane from - the control plane. -- New `CON-ARC-002-E1` records the freeze exemption explicitly. -- New `NFR-PER-009` (VP8 encode CPU budget); `NFR-PER-003` notes that the latency target now sets the - encoder operating point. -- System-topology diagram updated: the video plane goes through an SFU, not a direct socket. -- New `ASM-07` (WebRTC needs UDP/ICE reachability), `ASM-08` (software VP8 fits the Pi 5 budget — - unproven), `DEP-08` confirmed, `DEP-09` (libvpx) added. -- **New Appendix D — Decision log.** All future decisions are recorded there. - -### Changed — `DOC/OpenPoints.md` -- `OP-001` marked ✅ CLOSED with a full Decision block; the original analysis is retained per the - register's own rule. -- `OP-004` (user identity) **raised in priority** — LiveKit rooms are token-authenticated, so video - now depends on identity. -- `OP-015` (latency target) **raised in priority** — the transport is fixed, the operating point is not. -- `OP-023` annotated: `DEC-001` establishes the precedent that freeze exemptions are granted - individually, named and recorded. - -### Changed — `DOC/MVP_TASKS.md` -- LiveKit removed from the not-in-MVP list; it is now MVP scope. -- **New tasks:** `T-039` (VP8 encode feasibility spike on the Pi 5), `T-047` (deploy the LiveKit - server), `T-048` (access tokens and room model), `T-049` (frontend `livekit-client` subscriber), - `T-077` (retire the superseded H.264/Zstd path — *after* the replacement is proven on hardware). -- `T-041` rewritten as a publish pipeline into the SFU; `T-019` reduced to deleting the dead - live-view URL conventions rather than unifying them. -- Critical path redrawn: three parallel tracks; `T-060` (user identity) pulled forward out of Phase 5. -- Totals: 54 → 59 tasks, ~34 → ~37 person-weeks, ~20–24 → ~21–26 calendar weeks. - -### Changed — `DOC/InvariantMatrix.md` / `.csv` -- New row `C-12` — video-plane (SFU) connection indicator, visible in every mode. The video plane can - now fail independently of the control plane (`IF-CM-012`), and the GUI must say so. 60 → 61 rows. - -### Open and now urgent -`OP-015` (latency target) and `OP-004` (user identity) both rose as a direct consequence of this -decision and should be answered before Phase 3 starts. `ASM-08` — software VP8 on a Pi 5 alongside -acquisition — is unproven and is what `T-039` exists to establish, early. - -## Version: 1.1 29.07.26 — Requirements engineering pass - -Documentation only. **No source code was changed.** - -### Added -- `DOC/SRS_STANDARD.md` — research findings on how to set up a proper SRS: standards landscape - (ISO/IEC/IEEE 29148:2018 as primary, ISO/IEC 25010:2023 for quality attributes), the prescribed SRS - outline, well-formed-requirement syntax, the nine individual and five set-level requirement quality - characteristics, requirement attributes, verification methods, and the identifier scheme adopted here. -- `DOC/SRS_V1.md` — draft SRS structured per 29148 clause 9.6. ~180 requirements across functional, - interface, usability, performance, data, constraint and quality sections, each with priority, source - and verification method. Includes a verification chapter with ten acceptance scenarios - (`ACC-01` … `ACC-10`) and a traceability appendix mapping `basicRequirements.md` sections and - `InitReview.md` findings onto requirement IDs. -- `DOC/OpenPoints.md` — register of 32 open points from the SRS draft, by severity (S1–S3) and status - (OPEN / ASSUMED / CONFLICT). Three are conflicts between source documents: live-video transport - (`OP-001`), cache-vs-durable-data (`OP-018`), and the boundary of the architecture freeze (`OP-023`). -- `DOC/InvariantMatrix.md` — GUI operability invariant matrix: eight-gate precedence chain, ten - invariants, eight operating modes, per-function availability for 60 GUI functions. Normative for the - rule that only usable controls may be shown. -- `DOC/InvariantMatrix.csv` — spreadsheet-editable companion with the same 60 rows; intended as the - source for generated frontend gating and as a directly executable test table. -- `DOC/MVP_TASKS.md` — 54 tasks in seven phases from the current tree to an MVP, with dependencies, - effort estimates, definition of done per task, critical path, an explicit not-in-MVP list, and the - ten decisions that must precede implementation. - -### Notes -- Every ***UNCLEAR*** marker in `SRS_V1.md` has a matching `OP-nnn` entry in `OpenPoints.md`. No open - question was resolved by invention. -- `DOC/basicRequirements.md` and `DOC/InitReview.md` were read as inputs and left unmodified, per - `DOC/TODOS.md`. -- Highest-priority decisions before implementation starts: `OP-023` (scope of the architecture freeze) - and `OP-011` (500 MB/s — per camera or aggregate). Both change what gets built, not just how. - -## Version: 1.0 28.07.26 -- Initial draft -- serves as original for all future versions -- basic design decisicions, that can be concluded from the current structure shall not be changed. What this means is written in ***General setup rule*** fo the SRS.md diff --git a/SRS/InitReview.md b/SRS/InitReview.md deleted file mode 100644 index 7900b9f..0000000 --- a/SRS/InitReview.md +++ /dev/null @@ -1,506 +0,0 @@ -# newswitch — Initial Architecture Review - -**Date:** 2026-07-29 -**Branch reviewed:** `DEV_KC` (working tree, including uncommitted changes) -**Scope:** Architecture of `backend/` and `frontend/`. No code was changed. - ---- - -## 1. Executive summary - -newswitch is a **two-halves monorepo** implementing a web-stack microscope controller: -a Python/FastAPI backend built on `rekuest-next`, and a React/TypeScript frontend whose -typed API layer is **generated from the running backend**. The overall design is coherent -and unusually well-documented for an alpha, with a clear `Protocol → Manager → State` -layering on the backend and a clean generated-client boundary to the frontend. - -**Verdict on the architecture: sound and worth keeping.** The layering choices (structural -protocols, state-with-locks, generated client, separated data planes) are the right ones for -a lab instrument that must run both virtually and against real hardware. - -**Verdict on the current tree: broken.** The in-flight UC2 hardware work has left the -backend unable to import at all, so the backend, its test suite, and the frontend codegen -are all down. This is a small, well-understood breakage — not an architectural problem — -but nothing else can be validated until it is fixed. - -| | Backend | Frontend | -|---|---|---| -| Source LOC | ~8,600 (`newswitch/`) | ~18,700 hand-written + ~6,400 shadcn `ui/` + ~5,100 generated `apps/` | -| Test LOC | ~1,580 (7 files) | ~270 (2 files) | -| Import/build state | **Fails to import** | Not buildable locally (`node_modules` absent) | -| Lint | 41 errors, **all** in in-flight UC2 files | not run (deps absent) | -| Format | 8 files unformatted, all in-flight | not run (deps absent) | - ---- - -## 2. System topology - -``` -┌──────────────────────────── frontend (Vite/React 19) ────────────────────────────┐ -│ src/apps/default/** ← GENERATED from the backend schemas (committed on purpose) │ -│ src/lib/rekuest/** ← hand-written client: transport, state, task, lock stores │ -│ src/components/** ← microscope panels + react-three-fiber "stage" 3D viewer │ -└───────┬────────────────────┬───────────────────────┬─────────────────────┬────────┘ - │ control plane │ video plane │ bulk data plane │ codegen - │ WS /ws │ WS /stream/{codec}/ │ HTTP /cache/{id}/** │ HTTP /schemas/* - │ (rekuest agent: │ {slot} │ (Zarr v3 chunks) │ (build time only) - │ actions, states, │ (H.264 / Zstd) │ HTTP /files/** │ - │ locks, tasks) │ │ │ -┌───────┴────────────────────┴───────────────────────┴─────────────────────┴────────┐ -│ backend (FastAPI + rekuest-next agent) │ -│ app.py wiring: provide_managers() @startup, @register functions │ -│ protocols/ Protocol interfaces + @state dataclasses (the contract) │ -│ managers/ implementations: virtual/ | uc2/ | io/ | cache/ │ -│ routines/ composite operations (MDA, region scan, calibration) │ -│ broadcasters/ FrameBroadcaster + shared H264/Zstd encoders │ -│ uc2_devices/ SDK-only camera driver layer (+ native C++ ring buffer, WIP) │ -└───────────────────────────────────────────────────────────────────────────────────┘ -``` - -### Four distinct data planes — a genuine strength - -The design deliberately does **not** push everything through one channel: - -1. **Control plane** — `rekuest-next` over `/ws`: action invocation, task lifecycle, - state snapshots + JSON-patch deltas, lock acquisition. -2. **Video plane** — `/stream/h264/{slot}` and `/stream/zstd/{slot}`: per-detector live - view. Encoders are *shared* between clients with identical configs - (`FrameBroadcaster._encoders` keyed by `(slot, config)`), so N viewers cost one encode. -3. **Bulk data plane** — acquired frames are written to a local **Zarr v3** store and served - as raw chunks over `/cache/{store_id}/{key}`, consumed directly by `zarrita` in the - browser. Large image data never travels through the control socket. -4. **File plane** — `/files/**` for exported PNG/TIFF artefacts. - -Separating these is the single best structural decision in the codebase. - ---- - -## 3. Backend architecture - -### 3.1 Layering - -``` -protocols/ ── Protocol (structural interface) + @state dataclass, paired per subsystem - ▲ e.g. stage.py → StageManager (Protocol) + StageState (@state) - │ -managers/ ── concrete implementations, selected at startup - │ virtual/ VirtualStage, VirtualLED, VirtualDetector, VirtualObjective, VirtualFilterBank - │ uc2/ UC2Serial, UC2Stage, Uc2Detector, (galvo, filter bank — unwired) - │ io/ LocalFileIOManager cache/ LocalCacheManager - │ + cross-cutting: LightPath, Metadata, Acquisition, Expanse, Calibration, PythonHook - ▲ -app.py ── provide_managers() constructs everything and returns a 26-tuple for DI -``` - -`provide_managers()` is the composition root. `@startup` returns a tuple whose **type -annotation** is the DI registration — rekuest matches each returned object to the -declared protocol/state type and injects it into `@register`ed functions by parameter type. - -**Assessment.** The protocol/manager split is the right abstraction and it demonstrably -works: the same registered functions drive the virtual microscope and (by design) real UC2 -hardware, selected by one flag (`ImswitchConfig.use_virtual_microscope`). Using structural -`Protocol` rather than inheritance keeps drivers free of framework coupling. - -**Concerns.** - -- **The composition root is doing too much.** `provide_managers()` is ~150 lines returning - a 26-element tuple, with the virtual/hardware branch interleaved into the middle of state - construction. Adding a subsystem means editing the return type, the tuple, and the body in - three places that must stay positionally aligned. This should be split into small - builder functions (`build_states()`, `build_device_managers(config, states)`, - `build_domain_managers(...)`), or the tuple replaced by a container object. -- **Hardware selection is inconsistent.** `use_virtual_microscope=False` swaps only serial, - stage and detector. Illumination, objective and filter bank stay virtual unconditionally, - even though `managers/uc2/filter_bank_manager.py` (212 LOC) and - `managers/uc2/galvo_scanner.py` (122 LOC) exist. There is no per-subsystem configuration. -- **Config is a stub.** `ImswitchConfig` has 5 fields, and real values are hardcoded at the - call sites: `port="/dev/ttyUSB0"`, `baudrate=115200`, `base_path="/tmp/newswitch/images"`, - `settings_path="uc2DevSettings.json"`. `ImswitchConfig` is the intended home for these - (`test.py` already mutates `use_virtual_microscope` on it), and the README states the - backend deliberately reads no `.env` — so the config object needs to grow rather than the - literals staying scattered. - -### 3.2 State and locking - -States are plain dataclasses decorated `@state(required_locks=[...])`; managers are -decorated `@context(locks=[...])`. Mutation of shared state requires holding the named lock, -and the lock vocabulary (`stage_position`, `camera_parameters`, `objective`, `illumination`, -`filter_bank`, `io`, `expanse_state`, `cache`, `hook_registry`) is exported to the frontend -via `/schemas/locks` and mirrored into `src/apps/default/hooks/locks/**`. - -This is the strongest idea in the system: **the concurrency model is part of the published -API**, so the UI can grey out a control because the backend lock that guards it is held — -by another browser tab, by a running MDA, or by a script. Optimistic updates are declared -the same way (`OptimisticInput` on `move_stage` → generated `OptimisticStageState` in -`moveStage.ts`), so the UI's optimistic behaviour is backend-authored rather than -reimplemented client-side. - -Caveat: 12 `@state` classes exist but only 11 are wired. `protocols/z_range.py` + -`managers/virtual/virtual_z_range.py` are referenced from nowhere (verified by grep) — dead -on arrival. - -### 3.3 The `Kube` / `KubeState` duplication - -`protocols/core.py` (569 LOC) defines **nine** `*Kube` classes (Objective, ObjectiveTurret, -Detector, Illumination, Dichroic, Stage, Filter, FilterBank, Generic) and **nine** parallel -`*KubeState` classes with near-identical fields — the difference being that `*KubeState` -carries live values (gain, exposure, intensity, wavelength) while `*Kube` carries only -identity + affine matrix. `MetadataManager` converts between them. - -This duplication is load-bearing and it **propagates into the frontend**: there are two -near-identical renderer trees, `components/stage/planes/kubes/**` (renders `KubeState`, for -the light path recorded in an acquired frame) and `components/stage/planes/lightpath/kubes/**` -(renders `Kube`, for the live/possible light path). Both are live — the file pairs differ by -6–32 lines each. One backend modelling decision is costing two parallel 3D renderer -hierarchies plus two `LightPathEdges` implementations plus two panel sets. - -**Recommendation:** unify on one kube type with an optional live-state payload -(`Kube` + `state: KubeLiveState | None`), and collapse the frontend renderers to one tree. -This is the single highest-leverage refactor available, and it should be done *before* more -kube types are added, not after. - -Related: `LightPath.transformation_hash()` (`core.py:254`) is dead (no callers) and buggy — -it filters `self.kubes` (which holds `Kube` values) with `isinstance(kube, FilterBankKubeState)`, -so the filter branch can never match, and it interpolates the whole `objective` object into -the hash string rather than its slot id. - -### 3.4 Acquisition path - -`capture_image` → `AcquistionManager.acquire()` → for each active detector: -`DetectorManager.capture_image()` → `LightPathManager.get_light_path_for_detector()` → -`MetadataManager.get_metadata_for_light_path()` → `CacheManager.save_frame()` (Zarr v3) → -`Frame` appended to `ExpanseState.current_frames` and returned. - -This is a clean pipeline with good separation. Two notes: - -- `LocalCacheManager` hardcodes `default_chunks=(1, 256, 256)` and **asserts** the array is - exactly 3-D; `AcquistionManager` satisfies this by reshaping every 2-D camera frame to - `(1, H, W)`. Multi-channel or true-volumetric detectors will not fit this contract without - a change to both. -- `delete_on_restart=True` wipes `/tmp/newswitch/cache` on every boot. Correct for a cache; - worth being explicit that the expanse is not durable across restarts. - -### 3.5 Streaming - -`FrameBroadcaster` is well-designed: shared encoders keyed by `(detector_slot, config)`, -refcounted subscriptions, `stop_all_encoders()` on shutdown to reap ffmpeg subprocesses. -The `@shutdown` hook (`release_managers`) explicitly documents *why* it exists. - -Issues: - -- **`release_managers` takes `detector: DetectorManager` and never uses it.** `Uc2DetectorManager` - has a `shutdown()` that stops streaming and releases every SDK camera handle — it is never - called. On the hardware path, camera handles leak on every restart. -- **`get_latest_frame()` / `_latest_frame` is vestigial** — `broadcast_sync` never assigns - `_latest_frame`, so the accessor always returns `None`. -- **The live loop is a `while True` with no backpressure.** `Uc2DetectorManager.acquire_live()` - sleeps only when `is_acquiring` is false; when it is true but no detector is active, the - inner `for` body never runs and the outer loop spins at 100% CPU with no sleep. - `VirtualDetectorManager` handles this case (`koil.sleep(0.1)`); the UC2 version does not. - Neither loop paces itself against exposure time — the virtual one even has a dangling - comment ("Use exposure time from first active detector for frame interval") with no code. - -### 3.6 Route-level dependency injection - -`routes/http/files.py`, `routes/http/cache.py` and `routes/ws/liveview.py` each reach managers -via `request.app.state.agent.get_context_for_type(X)`, with a hand-written -`get_X_from_request` helper duplicated per module. It works and is documented, but it is a -service-locator escape hatch: FastAPI routes bypass the typed DI that `@register`ed functions -enjoy. One shared `Depends` factory (`context_dependency(CacheManager)`) would remove the -triplication. - -**Security note on `/files/{file_path:path}`:** unlike the cache route — which does a proper -`is_relative_to(store_dir)` traversal check — the files route passes the client-supplied path -straight to `io_manager.file_exists()` and then to `FileResponse` with no confinement to -`LocalFileConfig.base_path`. Combined with `allow_origins=["*"]` CORS, this is an arbitrary-file-read -surface. Acceptable on an isolated lab network; not acceptable if the instrument is ever -reachable beyond it. Worth fixing regardless, since the correct pattern already exists 40 -lines away in `cache.py`. - -### 3.7 `uc2_devices/` — the hardware driver layer - -The intent is well thought through and thoroughly documented (an 18 KB README with mermaid -diagrams, a documented state machine for the native ring buffer, an on-disk RAW format spec): - -- SDK-only layer *below* the managers, so `Uc2DetectorManager` composes a driver rather than - inheriting from one. -- **ABC rather than Protocol here — deliberately and correctly**, because drivers share real - logic (`apply_settings`, `compute_variance`, `save_stack`, context-manager lifecycle). - The README explains this choice explicitly. Good. -- The stated boundary — *control in Python, frame data never through Python* — is the right - call for a Pi-class device at ~1 GB/s, with `grab_frame()` as an explicitly-labelled - snapshot-only path and the native ring buffer owning the sustained stream. - -**However, this layer is currently mid-demolition** and is the source of every blocking -problem in §5.1. The entire C++ core, the pybind11 bindings, the ring buffer, the HIK driver -and the tests are deleted in the working tree (~4,000 lines), the root `CMakeLists.txt` has -its pybind targets commented out, and the remaining Python still imports the deleted pieces. -The documentation now describes a system that no longer exists in the tree. - ---- - -## 4. Frontend architecture - -### 4.1 The generated-client boundary - -`plugins/generate-app.ts` (1,648 LOC) runs in Vite's `buildStart`, fetches -`/schemas/implementations`, `/schemas/states`, `/schemas/locks` from a *running* backend, and -emits `src/apps/default/**`: one module per action with a Zod schema, TS types, an -`ActionDefinition` (including `lockKeys` and optimistic accessors) and a `use()` hook; -plus one module per state and lock. `blok.json` and the generated tree are committed on -purpose. - -This is a strong contract: 25 backend registrations map 1:1 to 25 generated action modules, -and validation, lock metadata and optimistic behaviour all derive from the same source of -truth. Docstrings become JSDoc. - -The known hazard is documented honestly in the README and CI: **the generator fails silently** -(fetch errors are warnings; it falls back to the committed output). The mitigations are real -— `just dev` sequences backend-then-frontend, `just drift-check` and the `codegen-drift` CI -job boot the backend, regenerate, and fail on any diff, and compose's `depends_on` + -healthcheck removes the race entirely. The compensating controls are the right ones; the -residual risk is that a developer running `yarn dev` alone gets no hard failure. - -Also worth noting: `codegen-drift` is the only CI job that catches this, and it currently -cannot pass, because the backend cannot boot (§5.1). - -### 4.2 The `lib/rekuest` client - -~6,000 LOC of hand-written client, layered: - -- `transport/` — `TransportProvider` + `subscription-manager`: one WebSocket per app, INIT - handshake declaring the action/state/lock keys this client cares about, reconnect with - exponential backoff, ping keepalive. -- `state/` — global state store with snapshot + **JSON-patch delta** application - (`fast-json-patch`), Zod validation of snapshots, and a `selectPath` selector family. - `materialization.ts` reconstructs typed values from the wire format. -- `task/` — task store + `useAction`, optimistic application/rollback. -- `locks/` — lock store and `useLock`. -- `createScopedProvider` / `createScopedStore` — per-route scoping, so `/` and `/replay` - hold independent store instances. - -The union wire format is handled by a well-explained `createIndexedUnion` Zod codec -(positional `{__use, __value}` envelopes, idempotent decode so patched documents re-validate). -This is the only part of the frontend with real unit tests (`indexed-union.test.ts`, -`store.test.ts`). - -**Concern: `BundleProvider.tsx` is 1,377 lines** and is the orchestration hub for every -store, subscription and patch stream. It is the highest-risk file in the frontend — the most -complex, the most central, and effectively untested. If any part of the client deserves -extraction and unit tests, it is this one. - -`src/store/createScopedStore.ts` and `src/hooks/useLock.tsx` are thin re-export shims for -`lib/rekuest` equivalents — harmless, but two import paths for one thing. - -### 4.3 Components - -Two tiers: `components/ui/**` (~6,400 LOC of vendored shadcn/Radix primitives — treat as -dependency, not code to maintain) and the application tier: - -- `components/microscope/**` — control panels (stage, camera, illumination, filter bank, - objective, MDA). **`MultidimensionalAcquisitionControl.tsx` (1,110) and - `MultidimensionalAcquisitionDialog.tsx` (995) are outliers** — 2,100 lines for one feature, - with visible overlap between the two. Prime extraction candidates. -- `components/stage/**` — the react-three-fiber viewer: planes, volumes, kube renderers, - Zarr-backed chunk loading with an LRU cache and colormaps, live textures fed from the - H.264/Zstd sockets, interaction controllers. Architecturally the most interesting part, and - where the `Kube`/`KubeState` duplication (§3.3) is paid for. -- `components/lightpathstate/**` — a `@xyflow/react` + `dagre` node-graph view of the light path. - -Local UI state lives in `src/store/**` (zustand + immer, scoped per route) and is properly -kept separate from backend-synced state in `lib/rekuest/state`. - -### 4.4 Live-view wiring is inconsistent - -Three URL conventions coexist and only one of them is correct: - -| Consumer | URL it builds | Backend route | Status | -|---|---|---|---| -| `useH264LiveTexture` | `BACKEND_API + "/stream/h264/" + slot` | `/stream/h264/{slot}` | path correct, but built from the **http** base and handed to `new WebSocket()` | -| `useZstdLiveTexture` | `BACKEND_API + "/stream/zstd/" + slot` | `/stream/zstd/{slot}` | same | -| `StreamingView` (default prop) | `VITE_WEBSOCKET_URL + "/video"` → `ws://…/ws/video` | **no such route** | dead | - -`LiveView.tsx` renders `` with no `url` prop, so the microscope panel's live -view points at an endpoint that does not exist — a leftover from an earlier single-stream -design (`/video`) that survived the move to per-slot, per-codec routes. The `constants.tsx` -pair uses `BACKEND_API` (http) where `BACKEND_WS` (ws) is the declared convention; modern -browsers do accept `http(s)` in the `WebSocket` constructor, so this may work today, but it -contradicts the project's own env-var contract and will break behind a proxy that maps the -two differently. - ---- - -## 5. Findings - -### 5.1 Blocking — the backend does not run - -1. **`newswitch/uc2_devices/__init__.py` is deleted, but `uc2_detector_manager.py` still - imports from it, and `app.py` imports that manager unconditionally at module scope.** - Every entry point dies at import: - - ``` - app.py:29 → managers/uc2/uc2_detector_manager.py:38 - → from newswitch.uc2_devices import CameraInfo, Uc2Camera, Uc2DevSettings - FileNotFoundError: .../newswitch/uc2_devices/__init__.py - ``` - - Consequence: `uvicorn main:app` fails, `uv run pytest` fails at conftest collection - (**the whole 1,583-line backend suite is unrunnable**), `just dev-backend` fails, and the - frontend codegen therefore silently falls back to committed hooks. - (Aggravating detail: the editable install is a scikit-build-core loader whose file map - still points at the deleted `__init__.py`, so a plain re-sync is also needed after - restoring it.) - -2. **No concrete camera driver exists.** `hik_camera.py` is deleted, so `Uc2Camera` — an ABC - with abstract methods — is the only "driver". `Uc2DetectorManager`'s default - `drivers=(Uc2Camera,)` calls `Uc2Camera.enumerate()`, an abstract classmethod with an - empty body returning `None`, and then iterates it. The hardware path cannot work even - after fixing (1). - -3. **The native layer is gone but still referenced.** ~4,000 lines of C++ (ring buffer, - MVS/HIK capture engine, pybind11 bindings, C++ and Python tests) are deleted; - `backend/CMakeLists.txt` has all pybind targets commented out; `uc2_camera.py` still tries - to load `_ringbuffer` (it degrades gracefully); `uc2_devices/README.md` still documents - the whole thing as present. The build backend is still `scikit-build-core`, so every wheel - build runs CMake for what is now an empty project. - -### 5.2 Structural - -4. **`Kube` / `KubeState` duplication** (§3.3) — 18 near-identical backend classes driving two - parallel frontend renderer hierarchies. Highest-leverage refactor. -5. **`provide_managers()` as a 26-tuple composition root** (§3.1) — three places must stay - positionally aligned per subsystem added. -6. **Partial hardware abstraction** (§3.1) — only 3 of 6 device subsystems switch on - `use_virtual_microscope`; two written UC2 managers are unreachable. -7. **`BundleProvider.tsx` at 1,377 LOC** (§4.2) — the frontend's most central and least - tested module. -8. **`Uc2DetectorManager.shutdown()` is never called** (§3.5) — `release_managers` receives - the detector and ignores it; SDK handles leak on the hardware path. -9. **Unbounded live-acquisition loops** (§3.5) — `Uc2DetectorManager.acquire_live()` busy-spins - when acquiring with no active detector; neither loop paces against exposure time. -10. **Path traversal on `/files/{file_path:path}`** (§3.6) — no confinement to `base_path`, - while the neighbouring cache route does it correctly. -11. **Live-view URLs inconsistent; `StreamingView` points at a dead route** (§4.4). - -### 5.3 Hygiene / smaller - -12. **Silent settings loss.** `app.py` passes `settings_path="uc2DevSettings.json"` (relative - to CWD) while the file lives at `newswitch/uc2_devices/uc2DevSettings.json`; - `Uc2DevSettings.load()` returns empty defaults for a missing file, so a misconfigured - path is indistinguishable from "no cameras configured". Separately, - `uc2DevSettings.json` and the README both document `recording_format`, but - `Uc2CameraSettings` has no such field and `from_dict` drops it silently. -13. **`enumerate()` contract mismatch (uncommitted).** The working tree changes the ABC's - return type to `list[Uc2Camera]`, but `Uc2DetectorManager` still treats the items as - `CameraInfo` (`driver(info)`, `info.serial`, `info.model`). -14. **Dead code:** `protocols/z_range.py` + `managers/virtual/virtual_z_range.py` (unwired); - `LightPath.transformation_hash()` (no callers, and buggy); `FrameBroadcaster.get_latest_frame()` - (always `None`); `plugins/gonio_meter.py` (docstring only). -15. **Lint/format regression, fully localised.** 41 ruff errors and 8 unformatted files — all - in the in-flight UC2 files (27 errors in `uc2_camera.py` alone). The rest of the backend - is clean, so this is new debt, not accumulated debt. Also, `[tool.ruff] extend-select` - is in the deprecated top-level position and ruff warns on every run; it belongs under - `[tool.ruff.lint]`. -16. **`pyproject.toml` metadata commented out** (uncommitted): `readme`, `homepage`. - Presumably a workaround for a build error; it makes published wheels metadata-poor. -17. **Test asymmetry.** Backend ~1,580 test LOC across 7 files vs frontend ~270 LOC across 2. - Nothing covers `BundleProvider`, the transport layer, the stage/3D components, or the - codegen plugin — the codegen plugin's only guard is the CI drift job. -18. **CORS `allow_origins=["*"]` with `allow_credentials=True`** — invalid per spec (browsers - reject the combination) and permissive by intent. Fine for a lab box; make it a config - knob before any wider deployment. -19. **Empty scaffolding:** `CLAUDE.md`, `DOC/SRS.md`, `DOC/TODOS.md`, `DOC/CHANGELOG_KC.md` - are 0 bytes. `newswitch/routes/__init__.py` and `newswitch/managers/__init__.py` are - docstring-only. - ---- - -## 6. Tooling, build and CI - -**Good:** -- `just` as the single task entry point, with recipes that encode the ordering constraint - (`dev` waits on the backend before starting vite) and warn loudly when it is violated. -- CI runs a 3-OS × 2-Python matrix for the backend, plus coverage, ruff check *and* ruff - format on one platform. -- The frontend job comments record real history: `types` used to point at a solution tsconfig - with `files: []` and checked **zero** files, hiding 165 type errors and 90 lint problems. - Both gates are now blocking. That is exactly the right response. -- `codegen-drift` boots the real backend and diffs the regenerated output — the correct - compensating control for a silently-failing generator, and only possible because both - halves live at one commit. -- `ci-ok` aggregate job so path-filtered skips don't produce a vacuously-green required check. -- Dockerfile runs as uid 1000 with a documented reason (bind-mounted `agent_data.db` would - otherwise be root-owned); compose healthcheck probes the exact endpoint codegen needs. -- lefthook: commitlint on `commit-msg`, ruff/prettier on `pre-commit`, with `src/apps/**` - excluded because the generator owns its formatting. -- Unified semantic-release bumping `backend/pyproject.toml` and `frontend/package.json` to - one version. - -**Gaps:** -- **No CI job builds the native/C++ side**, and `scikit-build-core` is still the build backend - for a CMake project that now compiles nothing. `just build` runs `uv build`, which will - invoke CMake; this is untested in CI. -- **`agent_data.db` exists at both repo root and `backend/`** as untracked runtime artefacts - (`*.db` is gitignored, correctly). The root copy suggests the backend has been started from - the wrong CWD at some point — the same class of CWD-relative-path fragility as finding (12). -- CI never exercises the Docker path. - ---- - -## 7. Recommendations, in order - -**Immediate — restore a working tree (hours):** -1. Restore `newswitch/uc2_devices/__init__.py` (and re-sync the editable install), or make - `app.py`'s `Uc2DetectorManager` import lazy so the virtual path never depends on hardware - modules. Prefer **both**: a hardware driver should not be able to break virtual mode at - import time. -2. Get `uv run pytest` green again and confirm `just drift-check` passes — nothing else can - be verified until these two do. -3. Run `ruff format` + `ruff check --fix` over the UC2 files and clear the remaining 38 by - hand; move `extend-select` under `[tool.ruff.lint]`. -4. Decide the fate of the native layer and make the tree say so: either restore it, or drop - `scikit-build-core` back to a pure-Python backend and move `uc2_devices/README.md`'s - native sections to a design doc marked "planned". Documentation describing deleted code is - worse than no documentation. - -**Short term — close the correctness gaps (days):** -5. Call `detector.shutdown()` from `release_managers` (§5.2-8). -6. Add the missing sleep/pacing to both `acquire_live()` loops (§5.2-9). -7. Confine `/files/**` to `base_path` using the same check `cache.py` already implements (§5.2-10). -8. Point `StreamingView` at a real route and unify the three live-view URL conventions on - `BACKEND_WS` (§5.2-11). -9. Move `port`, `baudrate`, `base_path`, `settings_path` into `ImswitchConfig`; resolve - `uc2DevSettings.json` relative to the package, and make a missing settings file at an - explicitly-configured path an error rather than empty defaults (§5.3-12). -10. Restore or write one concrete camera driver so the hardware path is exercisable at all — - a `MockCamera` (the README already contains a complete one) would let the whole UC2 - branch be unit-tested with no SDK present. - -**Medium term — pay down the structural debt (weeks):** -11. Unify `Kube`/`KubeState` and collapse the two frontend renderer trees (§3.3). Do this - before adding kube types. -12. Break up `provide_managers()` and replace the 26-tuple with a container (§3.1). -13. Extract `BundleProvider.tsx` into testable units and add transport/patch-application - tests (§4.2). -14. Split the ~2,100-line MDA control/dialog pair (§4.3). -15. Make hardware selection per-subsystem and wire up (or delete) the unreachable UC2 - filter-bank and galvo managers (§5.2-6). -16. Delete the dead code in §5.3-14 rather than leaving it to be rediscovered. - ---- - -## 8. Closing assessment - -The bones are good. The protocol/manager/state layering, the four separated data planes, the -lock vocabulary as published API, and the generate-the-client-from-the-backend contract are -all decisions that will still look right in a year — and the tooling around them (the -sequencing in `just dev`, the drift-check job, the honest comments about gates that used to -be dead) shows a team that has already been bitten by the failure modes and built controls -for them. - -The two things holding it back are both fixable and both localised: an in-flight hardware -refactor that has left the tree in a non-importing state, and one modelling decision -(`Kube`/`KubeState`) whose duplication has now replicated itself across the frontend. Fix the -first this week; plan the second before the kube taxonomy grows further. - -*No code was modified in the course of this review.* diff --git a/SRS/InvariantMatrix.csv b/SRS/InvariantMatrix.csv deleted file mode 100644 index ebde1e3..0000000 --- a/SRS/InvariantMatrix.csv +++ /dev/null @@ -1,72 +0,0 @@ -ID,Group,Function,Lock,CapabilityKey,M0_DISCONNECTED,M1_OBSERVER,M2_IDLE,M3_LIVE,M4_MOVING,M5_PROCEDURE,M6_SAFE_ERROR,M7_CALIB_MANUAL,Requirement,Notes,Status -S-01,Session,Backend connection indicator,,,V,V,V,V,V,V,V,V,IF-UI-004,,ACTIVE -S-02,Session,Device connection indicators,,,V,V,V,V,V,V,V,V,IF-UI-003,,ACTIVE -S-03,Session,Connected-user list,,,V,V,V,V,V,V,V,V,REQ-SES-011,,ACTIVE -S-04,Session,Control-authority indicator,,,V,V,V,V,V,V,V,V,REQ-SES-011,,ACTIVE -S-05,Session,Request control,,,H,A,H,H,H,H,D,H,REQ-SES-003,,ACTIVE -S-06,Session,Release control,,,H,H,A,A,D,C,D,D,REQ-SES-012,,ACTIVE -S-07,Session,Safe state / stop all,,,H,D,A,A,A,C,D,A,REQ-SAF-001,See OP-028 (observer access),ACTIVE -S-08,Session,Acknowledge error / recover,,,H,D,H,H,H,H,A,H,REQ-DEV-005,,ACTIVE -S-09,Session,Stale-state banner,,,V,H,H,H,H,H,H,H,INV-005,,ACTIVE -D-01,Devices,Device list / status,,,V,V,V,V,V,V,V,V,REQ-DEV-002,,ACTIVE -D-02,Devices,Initialise / start device,,,H,D,A,D,D,D,A,D,REQ-DEV-001,,ACTIVE -D-03,Devices,Stop device,,,H,D,C,C,D,D,D,D,REQ-DEV-001,,ACTIVE -D-04,Devices,Reload device configuration,,,H,D,C,D,D,D,C,D,REQ-DAT-002,,ACTIVE -C-01,Camera,Select live-view camera,camera_parameters,,H,D,A,A,A,D,D,A,REQ-IMG-002,,ACTIVE -C-02,Camera,Start live view,camera_parameters,,H,D,A,H,A,D,D,H,REQ-IMG-002,,ACTIVE -C-03,Camera,Stop live view,camera_parameters,,H,D,H,A,A,D,D,A,REQ-IMG-002,,ACTIVE -C-04,Camera,Snapshot,io,,H,D,A,A,D,D,D,A,REQ-IMG-004,See OP-030 (snapshot while moving),ACTIVE -C-05,Camera,Camera name,camera_parameters,,H,D,A,A,A,D,D,D,REQ-CAM-001,,ACTIVE -C-06,Camera,Exposure time (slider + entry),camera_parameters,exposure,H,D,A,A,A,D,D,A,REQ-CAM-002,Per-camera G1 gate,ACTIVE -C-07,Camera,Gain (slider + entry),camera_parameters,gain,H,D,A,A,A,D,D,A,REQ-CAM-003,Per-camera G1 gate,ACTIVE -C-08,Camera,Frame rate (slider + entry),camera_parameters,framerate,H,D,A,A,A,D,D,A,REQ-CAM-004,Per-camera G1 gate,ACTIVE -C-09,Camera,Channel weighting,,channel_weighting,H,H,H,A,H,H,H,A,REQ-CAM-005,Live viewer only; see OP-003,ACTIVE -C-10,Camera,Live image canvas,,,V,V,H,V,V,V,H,V,REQ-IMG-002,,ACTIVE -C-11,Camera,Frame-drop / rate indicator,,,V,V,H,V,V,V,H,V,REQ-IMG-012,,ACTIVE -C-12,Camera,Video-plane (SFU) connection indicator,,,V,V,V,V,V,V,V,V,IF-CM-012,Video plane is separate from the control plane since DEC-001 (LiveKit),ACTIVE -L-01,Illumination,Light source on/off,illumination,switchable,H,D,A,A,A,D,D,A,REQ-LGT-001,,ACTIVE -L-02,Illumination,Light source power (slider + entry),illumination,power,H,D,A,A,A,D,D,A,REQ-LGT-002,G9 value gate: hidden while source is off,ACTIVE -L-03,Illumination,Light source identity / wavelength,,wavelength,V,V,V,V,V,V,V,V,REQ-LGT-005,,ACTIVE -L-04,Illumination,Emission indicator,,,V,V,V,V,V,V,V,V,NFR-SAF-003,,ACTIVE -T-01,Stage,Jog / step buttons (+/- per axis),stage_position,jog,H,D,A,A,A,D,D,A,REQ-STG-001,Per-axis G1 gate,ACTIVE -T-02,Stage,Position display (X/Y/Z),,,V,V,V,V,V,V,V,V,REQ-STG-006,,ACTIVE -T-03,Stage,Absolute position entry + confirm,stage_position,absolute,H,D,A,A,D,D,D,A,REQ-STG-009,,ACTIVE -T-04,Stage,Home (separate menu),stage_position,home,H,D,C,C,D,D,D,D,REQ-STG-004,Must not sit in the centre of the direction cluster (REQ-STG-005),ACTIVE -T-05,Stage,Stop motion,stage_position,,H,D,H,H,A,H,H,A,REQ-STG-011,,ACTIVE -T-06,Stage,Jog / step mode switch,stage_position,,H,D,A,A,D,D,D,A,REQ-STG-008,,ACTIVE -T-07,Stage,Step size (separate menu),stage_position,,H,D,A,A,D,D,D,A,REQ-STG-007,,ACTIVE -T-08,Stage,Velocity (separate menu),stage_position,velocity,H,D,A,A,D,D,D,D,REQ-STG-007,Per-controller G1 gate,ACTIVE -T-09,Stage,Acceleration (separate menu),stage_position,acceleration,H,D,A,A,D,D,D,D,REQ-STG-007,Per-controller G1 gate,ACTIVE -T-10,Stage,Jerk (separate menu),stage_position,jerk,H,D,A,A,D,D,D,D,REQ-STG-007,Per-controller G1 gate,ACTIVE -T-11,Stage,Travel-limit display,,limit_min/limit_max,V,V,V,V,V,V,V,V,REQ-STG-010,,ACTIVE -O-01,Optics,Objective selection,objective,switchable,H,D,A,C,D,D,D,D,REQ-OPT-001,Confirm in LIVE: changes FOV and focus,ACTIVE -O-02,Optics,Filter selection,filter_bank,switchable,H,D,A,A,A,D,D,D,REQ-OPT-002,,ACTIVE -O-03,Optics,Rotator control,UNCLEAR,axes,H,D,A,A,A,D,D,D,REQ-OPT-003,Lock undefined; see OP-032,DRAFT -O-04,Optics,Light-path view,,,V,V,V,V,V,V,V,V,REQ-OPT-004,,ACTIVE -P-01,Procedures,Z-scan configure,stage_position+camera_parameters,,H,D,A,A,D,D,D,D,REQ-PRC-001,,ACTIVE -P-02,Procedures,Z-scan start,stage_position+camera_parameters,,H,D,A,A,D,D,D,D,REQ-PRC-001,,ACTIVE -P-03,Procedures,Autofocus select measure,camera_parameters,,H,D,A,A,D,D,D,D,REQ-PRC-003,Measure set unspecified; see OP-005,DRAFT -P-04,Procedures,Autofocus start,stage_position+camera_parameters,,H,D,A,A,D,D,D,D,REQ-PRC-003,,ACTIVE -P-05,Procedures,Focus lock on/off,stage_position,,H,D,A,A,D,D,D,D,REQ-PRC-005,Principle unspecified; see OP-006,DRAFT -P-06,Procedures,Overview image start,stage_position+camera_parameters,,H,D,A,A,D,D,D,D,REQ-PRC-007,,ACTIVE -P-07,Procedures,Calibration manual mode,stage_position+objective,,H,D,A,D,D,D,D,A,REQ-PRC-011,,ACTIVE -P-08,Procedures,Calibration automatic mode,stage_position+objective,,H,D,A,D,D,D,D,H,REQ-PRC-011,,ACTIVE -P-09,Procedures,Calibration set landmark,stage_position,,H,D,H,H,H,H,H,A,REQ-PRC-009,,ACTIVE -P-10,Procedures,Calibration accept / reject result,objective,,H,D,H,H,H,H,H,A,REQ-PRC-012,,ACTIVE -P-11,Procedures,XY stitching configure & start,stage_position+camera_parameters,,H,D,A,A,D,D,D,D,REQ-PRC-014,Overlap/blending unspecified; see OP-007,DRAFT -P-12,Procedures,Time series configure & start,camera_parameters,,H,D,A,A,D,D,D,D,REQ-PRC-017,Bounds unspecified; see OP-026,DRAFT -P-13,Procedures,Sample-area definition,stage_position,,H,D,A,A,D,D,D,D,REQ-PRC-018,,ACTIVE -P-14,Procedures,Sample-area fine adjustment at power-up,stage_position,,H,D,A,D,D,D,D,D,REQ-PRC-019,,ACTIVE -P-15,Procedures,Abort running procedure,,,H,D,H,H,H,A,D,A,REQ-PRC-020,,ACTIVE -P-16,Procedures,Procedure progress display,,,V,V,H,H,H,V,V,V,REQ-PRC-021,,ACTIVE -X-01,Data,Acquired-data viewer,,,V,V,V,V,V,V,V,V,REQ-DAT-001,,ACTIVE -X-02,Data,Export / download dataset,io,,H,A,A,A,A,A,A,A,REQ-DAT-006,Observer permitted; confirm against OP-004,ACTIVE -X-03,Data,Clear acquired data,expanse_state,,H,D,C,D,D,D,D,D,REQ-DAT-005,,ACTIVE -X-04,Data,Browse historical states,,,V,V,V,V,V,V,V,V,REQ-HIS-001,,ACTIVE -X-05,Data,Re-apply a historical state,all device locks,,H,D,C,D,D,D,D,D,REQ-HIS-003,May switch lasers on; see OP-008 and OP-013,DRAFT -X-06,Data,Storage-capacity indicator,,,V,V,V,V,V,V,V,V,REQ-DAT-008,,ACTIVE -G-01,Diagnostics,Log viewer,,,V,A,A,A,A,A,A,A,REQ-LOG-001,,ACTIVE -G-02,Diagnostics,Log level selection,,,H,D,A,A,A,A,A,A,REQ-LOG-004,,ACTIVE -G-03,Diagnostics,Settings view (read),,,V,V,V,V,V,V,V,V,REQ-DAT-002,,ACTIVE -G-04,Diagnostics,Settings edit,,,H,D,C,D,D,D,D,D,REQ-DAT-002,,ACTIVE -G-05,Diagnostics,Error / notification area,,,V,V,V,V,V,V,V,V,REQ-LOG-005,,ACTIVE diff --git a/SRS/InvariantMatrix.md b/SRS/InvariantMatrix.md deleted file mode 100644 index 2094333..0000000 --- a/SRS/InvariantMatrix.md +++ /dev/null @@ -1,329 +0,0 @@ -# GUI Operability Invariant Matrix — newswitch - -**Document ID:** INVMAT -**Version:** 1.0 (draft) -**Date:** 2026-07-29 -**Normative for:** `SRS_V1.md` → `IF-UI-005`, `IF-UI-010`, `NFR-USA-002`, `NFR-USA-003` -**Editable companion:** `DOC/InvariantMatrix.csv` (same content, spreadsheet-editable, one row per -GUI function) - ---- - -## 1. Purpose - -`basicRequirements.md` states: *"Only show GUI elements, which are really implemented and possible to -use in the current version of the microscope."* - -That is a strong requirement and it cannot be satisfied by ad-hoc `disabled={…}` expressions scattered -through the frontend. This document defines, for **every GUI function**, the conditions under which it -is shown, enabled, disabled or hidden — so that the rule is stated once, reviewable by a -non-programmer, and testable. - -"Invariant" here means: **these relationships hold in every version of the software.** Adding a -feature adds a row; it does not change the gating logic. - ---- - -## 2. How to edit this document - -1. Edit `DOC/InvariantMatrix.csv` in a spreadsheet (one row per GUI function, one column per mode), or - edit the tables in §7 of this file directly. Keep the two in step. -2. Adding a GUI function → add a row. Fill **every** column: an empty cell is a defect, not a default. -3. Adding an operating mode → add a column **and** review every existing row. -4. Never delete a row for a removed feature — mark its status `RETIRED` so the history of the decision - survives. -5. Any change here is a change to a normative document: log it in `DOC/CHANGELOG_KC.md`. - ---- - -## 3. Cell values - -| Code | Meaning | Rendered as | -|---|---|---| -| **A** | **Available** — control is shown and actionable | normal, interactive | -| **C** | **Confirm** — available, but activation requires an explicit confirmation step | normal + confirm dialog | -| **D** | **Disabled** — shown, not actionable, **with a stated reason** (`NFR-USA-003`) | greyed, tooltip gives cause | -| **V** | **View only** — a display element, never actionable | read-only indicator | -| **H** | **Hidden** — not rendered at all | absent from the DOM | - -**D vs. H is the central distinction.** -*Hidden* means "this instrument can never do this" — the user must not learn the control exists. -*Disabled* means "not right now" — the user must be able to see it and understand why. - ---- - -## 4. State variables - -The mode of the GUI is a function of these variables. They are all backend-owned; the frontend derives -its rendering from them and holds no authority of its own. - -| Var | Name | Values | Source | -|---|---|---|---| -| `conn` | Backend connection | `up` / `down` | frontend transport watchdog | -| `auth` | Control authority | `holder` / `observer` | backend session manager | -| `cap[f]` | Capability for function *f* | `supported` / `unsupported` | device capability descriptor (`REQ-DEV-003`) | -| `dev[s]` | Device status of subsystem *s* | `ready` / `absent` / `error` | device manager | -| `lock[l]` | Lock *l* | `free` / `self` / `other` | backend lock vocabulary | -| `act` | Current activity | `idle` / `live` / `moving` / `procedure` | backend | -| `safe` | Safe state | `normal` / `engaged` | backend | -| `val[x]` | Value-dependent visibility | e.g. light source on/off | device state | - ---- - -## 5. Gate precedence — the invariant that governs all others - -Every control is evaluated through this chain **in order**. The **first gate that fires wins**, and a -gate that hides always beats a gate that disables. - -``` - G1 CAPABILITY cap[f] = unsupported ................................ → H - G2 PRESENCE dev[s] = absent ..................................... → H - G3 CONNECTION conn = down ......................................... → D (whole GUI, one banner) - G4 SAFE STATE safe = engaged ...................................... → D (except recovery controls) - G5 DEVICE ERROR dev[s] = error ...................................... → D (except recovery controls) - G6 AUTHORITY auth = observer ..................................... → D - G7 LOCK lock[l] = other ..................................... → D - G8 MODE look up the matrix in §7 ............................ → A / C / D / H - G9 VALUE val[x] condition not met ............................ → H - otherwise .......................................................... → A -``` - -**INV-001** — A control for an unsupported capability is **never** rendered, in any mode, for any user, -including the control-holding user. Capability gating precedes everything. - -**INV-002** — A control that is rendered and enabled **shall** succeed when activated, or fail with a -device-reported error. It shall never fail because the GUI offered something the system could not do. - -**INV-003** — A disabled control **shall** state its cause, and the cause **shall** be the gate that -fired: "no control authority", "held by ", "device not ready", "procedure running", "safe state -engaged", "connection lost". - -**INV-004** — Hiding is a property of the *instrument*; disabling is a property of the *moment*. If a -control's visibility would change without a hardware or configuration change, it must be D, not H. - -**INV-005** — Every display element (`V`) remains visible in every mode, including `conn = down`, where -it shows the last known value marked explicitly as stale. - -**INV-006** — No control that commands motion or emission is `A` while `auth = observer`. There is no -exception, including for read-back or "preview" controls that internally command the device. - -**INV-007** — The safe-state / stop-all control is reachable in every mode in which any device can be -active. It is the only control that may bypass gates G6–G8 ***UNCLEAR*** — see `OP-010` and `OP-028` -below. - -**INV-008** — A procedure holds the locks of every subsystem it drives for its whole duration -(`REQ-PRC-022`); therefore during `act = procedure` the affected controls are disabled by G7, not by a -separate rule. G8 restates this only for readability. - ---- - -## 6. Operating modes (matrix columns) - -| Mode | Condition | Description | -|---|---|---| -| **M0 DISCONNECTED** | `conn = down` | No backend. GUI shows last known state, marked stale | -| **M1 OBSERVER** | `conn = up`, `auth = observer` | Connected, watching, no device authority | -| **M2 IDLE** | `auth = holder`, `act = idle` | Full control, nothing running | -| **M3 LIVE** | `auth = holder`, `act = live` | Live view running | -| **M4 MOVING** | `auth = holder`, `act = moving` | Commanded stage motion in progress | -| **M5 PROCEDURE** | `auth = holder`, `act = procedure` | Z-scan / stitch / time series / MDA / autofocus running | -| **M6 SAFE / ERROR** | `safe = engaged` or `dev[*] = error` | Safe state entered or unrecovered device error | -| **M7 CALIB-MANUAL** | `act = procedure`, manual calibration | Special: the procedure *requires* the user to drive the stage | - -M7 exists because manual calibration (`REQ-PRC-011`) inverts the usual rule — a procedure is running, -yet stage control must remain with the user. - ---- - -## 7. The matrix - -### 7.1 Session and global - -| # | GUI function | Lock | M0 | M1 | M2 | M3 | M4 | M5 | M6 | M7 | Requirement | -|---|---|---|---|---|---|---|---|---|---|---|---| -| S-01 | Backend connection indicator | — | V | V | V | V | V | V | V | V | IF-UI-004 | -| S-02 | Device connection indicators | — | V | V | V | V | V | V | V | V | IF-UI-003 | -| S-03 | Connected-user list | — | V | V | V | V | V | V | V | V | REQ-SES-011 | -| S-04 | Control-authority indicator | — | V | V | V | V | V | V | V | V | REQ-SES-011 | -| S-05 | Request control | — | H | A | H | H | H | H | D | H | REQ-SES-003 | -| S-06 | Release control | — | H | H | A | A | D | C | D | D | REQ-SES-012 | -| S-07 | Safe state / stop all | — | H | D | A | A | A | C | D | A | REQ-SAF-001 | -| S-08 | Acknowledge error / recover | — | H | D | H | H | H | H | A | H | REQ-DEV-005 | -| S-09 | Stale-state banner | — | V | H | H | H | H | H | H | H | INV-005 | - -### 7.2 Devices - -| # | GUI function | Lock | M0 | M1 | M2 | M3 | M4 | M5 | M6 | M7 | Requirement | -|---|---|---|---|---|---|---|---|---|---|---|---| -| D-01 | Device list / status | — | V | V | V | V | V | V | V | V | REQ-DEV-002 | -| D-02 | Initialise / start device | — | H | D | A | D | D | D | A | D | REQ-DEV-001 | -| D-03 | Stop device | — | H | D | C | C | D | D | D | D | REQ-DEV-001 | -| D-04 | Reload device configuration | — | H | D | C | D | D | D | C | D | REQ-DAT-002 | - -### 7.3 Camera and imaging - -| # | GUI function | Lock | M0 | M1 | M2 | M3 | M4 | M5 | M6 | M7 | Requirement | -|---|---|---|---|---|---|---|---|---|---|---|---| -| C-01 | Select live-view camera | `camera_parameters` | H | D | A | A | A | D | D | A | REQ-IMG-002 | -| C-02 | Start live view | `camera_parameters` | H | D | A | H | A | D | D | H | REQ-IMG-002 | -| C-03 | Stop live view | `camera_parameters` | H | D | H | A | A | D | D | A | REQ-IMG-002 | -| C-04 | Snapshot | `io` | H | D | A | A | D | D | D | A | REQ-IMG-004 | -| C-05 | Camera name | `camera_parameters` | H | D | A | A | A | D | D | D | REQ-CAM-001 | -| C-06 | Exposure time (slider + entry) | `camera_parameters` | H | D | A | A | A | D | D | A | REQ-CAM-002 | -| C-07 | Gain (slider + entry) | `camera_parameters` | H | D | A | A | A | D | D | A | REQ-CAM-003 | -| C-08 | Frame rate (slider + entry) | `camera_parameters` | H | D | A | A | A | D | D | A | REQ-CAM-004 | -| C-09 | Channel weighting | — | H | H | H | A | H | H | H | A | REQ-CAM-005 | -| C-10 | Live image canvas | — | V | V | H | V | V | V | H | V | REQ-IMG-002 | -| C-11 | Frame-drop / rate indicator | — | V | V | H | V | V | V | H | V | REQ-IMG-012 | -| C-12 | Video-plane (SFU) connection indicator | — | V | V | V | V | V | V | V | V | IF-CM-012 | - -**Note on C-06…C-08:** these rows are subject to G1 per camera. A camera that cannot set frame rate -renders no C-08 control **for that camera**, while another camera in the same GUI may render it -(`REQ-CAM-007`). - -### 7.4 Illumination - -| # | GUI function | Lock | M0 | M1 | M2 | M3 | M4 | M5 | M6 | M7 | Requirement | -|---|---|---|---|---|---|---|---|---|---|---|---| -| L-01 | Light source on/off | `illumination` | H | D | A | A | A | D | D | A | REQ-LGT-001 | -| L-02 | Light source power (slider + entry) | `illumination` | H | D | A | A | A | D | D | A | REQ-LGT-002 | -| L-03 | Light source identity / wavelength | — | V | V | V | V | V | V | V | V | REQ-LGT-005 | -| L-04 | Emission indicator | — | V | V | V | V | V | V | V | V | NFR-SAF-003 | - -**L-02 is additionally gated by G9:** hidden whenever the corresponding source is off -(`REQ-LGT-003`). This is the reference example of a value gate. - -### 7.5 Stage - -| # | GUI function | Lock | M0 | M1 | M2 | M3 | M4 | M5 | M6 | M7 | Requirement | -|---|---|---|---|---|---|---|---|---|---|---|---| -| T-01 | Jog / step buttons (± per axis) | `stage_position` | H | D | A | A | A | D | D | A | REQ-STG-001 | -| T-02 | Position display (X/Y/Z) | — | V | V | V | V | V | V | V | V | REQ-STG-006 | -| T-03 | Absolute position entry + confirm | `stage_position` | H | D | A | A | D | D | D | A | REQ-STG-009 | -| T-04 | Home (separate menu) | `stage_position` | H | D | C | C | D | D | D | D | REQ-STG-004/005 | -| T-05 | Stop motion | `stage_position` | H | D | H | H | A | H | H | A | REQ-STG-011 | -| T-06 | Jog ↔ step mode switch | `stage_position` | H | D | A | A | D | D | D | A | REQ-STG-008 | -| T-07 | Step size (separate menu) | `stage_position` | H | D | A | A | D | D | D | A | REQ-STG-007 | -| T-08 | Velocity (separate menu) | `stage_position` | H | D | A | A | D | D | D | D | REQ-STG-007 | -| T-09 | Acceleration (separate menu) | `stage_position` | H | D | A | A | D | D | D | D | REQ-STG-007 | -| T-10 | Jerk (separate menu) | `stage_position` | H | D | A | A | D | D | D | D | REQ-STG-007 | -| T-11 | Travel-limit display | — | V | V | V | V | V | V | V | V | REQ-STG-010 | - -**Per-axis G1:** an axis the controller does not expose renders no T-01 control for that axis -(`REQ-STG-003`). Likewise T-08…T-10 appear only for parameters the controller actually supports -(`REQ-STG-007`) — a controller without jerk control shows no jerk field, ever. - -### 7.6 Optics - -| # | GUI function | Lock | M0 | M1 | M2 | M3 | M4 | M5 | M6 | M7 | Requirement | -|---|---|---|---|---|---|---|---|---|---|---|---| -| O-01 | Objective selection | `objective` | H | D | A | C | D | D | D | D | REQ-OPT-001 | -| O-02 | Filter selection | `filter_bank` | H | D | A | A | A | D | D | D | REQ-OPT-002 | -| O-03 | Rotator control | ***UNCLEAR*** `OP-012` | H | D | A | A | A | D | D | D | REQ-OPT-003 | -| O-04 | Light-path view | — | V | V | V | V | V | V | V | V | REQ-OPT-004 | - -**O-01 is `C` in M3:** changing the objective during live view abruptly changes magnification, focus -and field of view. It is permitted, but confirmed. -**O-01/O-02 are `D` in M7:** calibration is objective- and filter-specific; changing either mid-run -invalidates the transform. - -### 7.7 Procedures - -| # | GUI function | Locks | M0 | M1 | M2 | M3 | M4 | M5 | M6 | M7 | Requirement | -|---|---|---|---|---|---|---|---|---|---|---|---| -| P-01 | Z-scan — configure | `stage_position`,`camera_parameters` | H | D | A | A | D | D | D | D | REQ-PRC-001 | -| P-02 | Z-scan — start | as P-01 | H | D | A | A | D | D | D | D | REQ-PRC-001 | -| P-03 | Autofocus — select measure | `camera_parameters` | H | D | A | A | D | D | D | D | REQ-PRC-003 | -| P-04 | Autofocus — start | `stage_position`,`camera_parameters` | H | D | A | A | D | D | D | D | REQ-PRC-003 | -| P-05 | Focus lock on/off | `stage_position` | H | D | A | A | D | D | D | D | REQ-PRC-005 | -| P-06 | Overview image — start | `stage_position`,`camera_parameters` | H | D | A | A | D | D | D | D | REQ-PRC-007 | -| P-07 | Calibration — manual mode | `stage_position`,`objective` | H | D | A | D | D | D | D | A | REQ-PRC-011 | -| P-08 | Calibration — automatic mode | `stage_position`,`objective` | H | D | A | D | D | D | D | H | REQ-PRC-011 | -| P-09 | Calibration — set landmark | `stage_position` | H | D | H | H | H | H | H | A | REQ-PRC-009 | -| P-10 | Calibration — accept / reject result | `objective` | H | D | H | H | H | H | H | A | REQ-PRC-012 | -| P-11 | XY stitching — configure & start | `stage_position`,`camera_parameters` | H | D | A | A | D | D | D | D | REQ-PRC-014 | -| P-12 | Time series — configure & start | `camera_parameters` | H | D | A | A | D | D | D | D | REQ-PRC-017 | -| P-13 | Sample-area definition | `stage_position` | H | D | A | A | D | D | D | D | REQ-PRC-018 | -| P-14 | Sample-area fine adjustment (power-up) | `stage_position` | H | D | A | D | D | D | D | D | REQ-PRC-019 | -| P-15 | Abort running procedure | — | H | D | H | H | H | A | D | A | REQ-PRC-020 | -| P-16 | Procedure progress display | — | V | V | H | H | H | V | V | V | REQ-PRC-021 | - -**P-01…P-14 are `D`, not `H`, in M5.** A user who cannot start a second Z-scan while the first is -running must still be able to see that the function exists — per `INV-004`, the restriction is a -property of the moment. - -### 7.8 Data and history - -| # | GUI function | Lock | M0 | M1 | M2 | M3 | M4 | M5 | M6 | M7 | Requirement | -|---|---|---|---|---|---|---|---|---|---|---|---| -| X-01 | Acquired-data viewer | — | V | V | V | V | V | V | V | V | REQ-DAT-001 | -| X-02 | Export / download dataset | `io` | H | A | A | A | A | A | A | A | REQ-DAT-006 | -| X-03 | Clear acquired data | `expanse_state` | H | D | C | D | D | D | D | D | REQ-DAT-005 | -| X-04 | Browse historical states | — | V | V | V | V | V | V | V | V | REQ-HIS-001 | -| X-05 | Re-apply a historical state | all device locks | H | D | C | D | D | D | D | D | REQ-HIS-003 | -| X-06 | Storage-capacity indicator | — | V | V | V | V | V | V | V | V | REQ-DAT-008 | - -**X-02 is `A` in M1:** downloading data does not command the instrument, so an observer may do it. -This is the only device-adjacent function an observer can act on — confirm against `OP-004`. -**X-05 is `C` and ***UNCLEAR***:** re-applying a state may switch lasers on. Blocked on `OP-008` and -`OP-013`. - -### 7.9 Diagnostics - -| # | GUI function | Lock | M0 | M1 | M2 | M3 | M4 | M5 | M6 | M7 | Requirement | -|---|---|---|---|---|---|---|---|---|---|---|---| -| G-01 | Log viewer | — | V | A | A | A | A | A | A | A | REQ-LOG-001 | -| G-02 | Log level selection | — | H | D | A | A | A | A | A | A | REQ-LOG-004 | -| G-03 | Settings view (read) | — | V | V | V | V | V | V | V | V | REQ-DAT-002 | -| G-04 | Settings edit | — | H | D | C | D | D | D | D | D | REQ-DAT-002 | -| G-05 | Error / notification area | — | V | V | V | V | V | V | V | V | REQ-LOG-005 | - ---- - -## 8. Capability keys - -G1 requires the backend to state, per device instance, which functions it supports. This is the -capability descriptor of `REQ-DEV-003`. Minimum key set implied by the matrix: - -| Subsystem | Capability keys | -|---|---| -| Camera | `exposure`, `gain`, `framerate`, `channel_weighting`, `roi`, `binning`, `trigger` — each with `readable`, `writable`, `min`, `max`, `step`, `unit` | -| Stage | per axis: `present`, `jog`, `absolute`, `home`, `velocity`, `acceleration`, `jerk`, `limit_min`, `limit_max`, `unit` | -| Illumination | per source: `switchable`, `power`, `min`, `max`, `unit`, `wavelength` | -| Objective | `slots`, `switchable`, per slot: `magnification`, `na`, `pixel_size` | -| Filter bank | per bank: `slots`, `switchable`, per slot: `name`, `band` | -| Rotator | `axes`, `absolute`, `continuous`, `min`, `max`, `unit` — ***UNCLEAR***, `OP-014` | - -**INV-009** — The capability descriptor is the **only** source for G1. The frontend shall not infer -support from a device name, model string or hardcoded table. - -**INV-010** — A capability descriptor shall report the *device's* limits, not the *GUI's* preferences -(`REQ-CAM-008`). - ---- - -## 9. Open points arising from this matrix - -| ID | Question | Affected cells | -|---|---|---| -| OP-028 | May the safe-state / stop-all control (S-07) be operated by an **observer** (`auth = observer`)? Physical-safety argument says yes; control-arbitration argument says no. `INV-007` currently says `D` in M1 | S-07 / M1 | -| OP-029 | During a procedure that outlives its user (`REQ-SES-008`), which mode applies — M5 with no holder, or a new mode M8 UNATTENDED? | all M5 cells | -| OP-030 | Is snapshot (C-04) permitted during stage motion (M4)? Currently `D` on image-quality grounds; a user tracking a moving sample may disagree | C-04 / M4 | -| OP-031 | Should M4 MOVING be a distinct mode at all, or is it a per-control lock state? A CAN-bus stage may report motion per axis, allowing Y control while X moves | all M4 cells | -| OP-032 | Are rotators (O-03) covered by the `filter_bank` lock or do they need their own lock in the backend vocabulary? | O-03 | - -These are registered in `DOC/OpenPoints.md` §5 with full context and recommendations. - ---- - -## 10. Verification - -| Method | What it proves | -|---|---| -| **I** | Every GUI function in the implementation appears as a row here; no row is unimplemented and no control is unlisted | -| **T** | Automated test per row: force the mode, assert the rendered state matches the cell. This matrix is directly executable as a test table | -| **D** | `ACC-04` in `SRS_V1.md` §4.3 — a camera without frame-rate control shows no frame-rate control | - -**Recommendation:** generate the frontend gating from `InvariantMatrix.csv` rather than hand-writing -it. The file is already the shape of a lookup table, and the backend already publishes the lock -vocabulary and (once `REQ-DEV-003` exists) the capability descriptors that G1 and G7 need. diff --git a/SRS/KeyFeatureOverview.ods b/SRS/KeyFeatureOverview.ods deleted file mode 100644 index ddb00600d371b1e2eaf3d1ac41b22a59ef12c75c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25944 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 SPdB93h7SV;#bEO1*Z%>}bpdDq diff --git a/SRS/MVP_TASKS.md b/SRS/MVP_TASKS.md deleted file mode 100644 index c092c0b..0000000 --- a/SRS/MVP_TASKS.md +++ /dev/null @@ -1,313 +0,0 @@ -# Path to a Minimum Viable Product — newswitch - -**Document ID:** MVP_TASKS -**Version:** 1.0 (draft) -**Date:** 2026-07-29 -**Derived from:** `DOC/SRS_V1.md` (target) and `DOC/InitReview.md` (Version 1.0, current state) -**Constraint:** the architecture of `basicRequirements.md` §Base architectural setup is preserved; -no task below changes the backend/frontend split, the routines → managers → protocols layering, the -DI model, the control-plane transport structure or the generated-client contract. - -**One exemption is in force:** `DEC-001` (2026-07-29) replaces the **video plane** with -LiveKit/WebRTC/VP8, explicitly waiving the freeze for that subsystem only (`CON-ARC-002-E1`). - ---- - -## 1. What the MVP is - -**One operator, at a browser, controls one real UC2 microscope and gets data off it.** - -Concretely — the MVP is reached when this scenario runs end to end on real hardware: - -> Start the backend on the Pi. Open the GUI in Chrome. See that the camera, stage and illumination are -> connected. Start the live view. Adjust exposure and gain — and see no frame-rate slider, because -> this camera has no frame-rate control. Switch on a light source and set its power. Jog the stage in -> X, Y and Z; type an absolute Z position and watch the stage move there. Take a snapshot; the file -> lands on the operator's machine. Run a Z-scan over 20 planes; the images and their metadata are -> stored and viewable. Open a second browser: it can watch but not touch. Close the first browser: -> the second gains control. Close both: the lasers go off and the stage stops. - -The live view in that scenario is delivered over **LiveKit/WebRTC/VP8** (`DEC-001`), which means an -SFU is part of the MVP deployment — not a later addition. - -**The MVP deliberately excludes** (see §6): stitching, focus lock, time series, DAHENG cameras, the -overview camera, and historical-state re-application. - ---- - -## 2. Task format - -| Field | Meaning | -|---|---| -| **ID** | `T-nnn`, stable | -| **Effort** | `S` ≤ 1 day · `M` 2–4 days · `L` 1–2 weeks · `XL` > 2 weeks | -| **Deps** | tasks that must complete first | -| **Req** | requirements from `SRS_V1.md` this task satisfies | -| **DoD** | definition of done — the observable result | - ---- - -## 3. Phase 0 — Restore a working tree - -**Nothing else can be verified until this phase is green.** All four tasks come from -`InitReview.md` §5.1 and §7. - -| ID | Task | Effort | Deps | Req | DoD | -|---|---|---|---|---|---| -| **T-001** | Restore `newswitch/uc2_devices/__init__.py` (or equivalent) and re-sync the editable install so the package imports | S | — | NFR-MNT-006 | `python -c "import newswitch.app"` succeeds | -| **T-002** | Make the `Uc2DetectorManager` import in `app.py` lazy, so a hardware-driver failure cannot break virtual mode at import time | S | T-001 | REQ-DEV-007 | Backend starts in virtual mode with the UC2 driver module deliberately broken | -| **T-003** | Get the backend test suite running and green again | M | T-001 | NFR-MNT-006 | `uv run pytest` collects and passes; CI backend job green | -| **T-004** | Restore lint/format cleanliness: `ruff format` + `ruff check --fix` over the UC2 files, clear the remaining errors by hand, move `extend-select` under `[tool.ruff.lint]` | S | T-001 | NFR-MNT-004 | `ruff check` and `ruff format --check` clean; no deprecation warning | -| **T-005** | Confirm the frontend codegen drift check passes against the restored backend | S | T-003 | IF-SW-003, NFR-CMP-001 | `just drift-check` green; `codegen-drift` CI job green | -| **T-006** | **Decide the fate of the native layer** and make the tree consistent with the decision: either restore the C++ ring buffer, bindings and tests, or drop `scikit-build-core` and move the native sections of `uc2_devices/README.md` into a design doc marked "planned" | M | T-001 | NFR-MNT-005, REQ-IMG-007 | No documentation describes absent code; `uv build` succeeds and does what the docs say | - -> **Blocked on a decision:** T-006 depends on `OP-011` (is 500 MB/s per camera or aggregate?). If the -> real sustained rate is well below 500 MB/s, a Python-level buffer may carry the MVP and the native -> layer can be deferred. **Answer `OP-011` before starting T-006.** - -**Phase exit criterion:** backend imports, tests pass, lint clean, codegen drift clean, and the tree's -documentation matches the tree's contents. - ---- - -## 4. Phase 1 — Foundations - -These are the pieces every later task depends on. Building them after the device work means retrofitting -them into the device work. - -| ID | Task | Effort | Deps | Req | DoD | -|---|---|---|---|---|---| -| **T-010** | **Logging facility** as a top-level backend tool: fatal/error/warning/info/debug, timestamp + level + module + user + device, level configurable at start-up | M | T-001 | REQ-LOG-001…004, CON-DEV-006 | Every layer logs through it; level set from config without code change | -| **T-011** | **Grow `ImswitchConfig`**: move `port`, `baudrate`, `base_path`, `settings_path` and travel limits out of call sites into config; resolve `uc2DevSettings.json` relative to the package, not the CWD | M | T-001 | IF-HW-007, REQ-DAT-004, NFR-FLX-002 | No hardcoded device paths remain; starting from any CWD works identically | -| **T-012** | Make a missing settings file at an explicitly configured path a hard error rather than silent empty defaults | S | T-011 | REQ-DEV-008 | Wrong path → clear startup error naming the path, not "no cameras found" | -| **T-013** | **Capability descriptor** — define the schema and publish it per device instance (parameter, readable/writable, min, max, step, unit) | L | T-001 | REQ-DEV-003, NFR-FUN-001 | Every manager returns a descriptor; schema published to the frontend; keys per `InvariantMatrix.md` §8 | -| **T-014** | **Safe state** — implement `enter_safe_state()`: all illumination off, all stage motion stopped, logged with cause | M | T-010 | REQ-SAF-001…003, NFR-SAF-001 | Callable from shutdown, last-user-disconnect and unrecoverable device error; verified in virtual mode | -| **T-015** | Call `detector.shutdown()` from `release_managers`; release every SDK camera handle on shutdown | S | T-001 | REQ-DEV-006, NFR-REL-004 | Restart cycle on hardware leaks no handles | -| **T-016** | Add pacing/sleep to both `acquire_live()` loops; pace against exposure time; no busy-spin with no active detector | S | T-001 | REQ-IMG-011, NFR-PER-007 | Idle backend CPU < 5 % of one core on the Pi | -| **T-017** | Confine `/files/{file_path:path}` to `base_path` using the traversal check `cache.py` already implements | S | T-001 | NFR-SEC-001 | Traversal attempt returns 404/403; regression test added | -| **T-018** | Make CORS origins configurable; closed list as the deployed default | S | T-011 | NFR-SEC-002, IF-CM-006 | Wildcard only under an explicit development flag | -| **T-019** | Retire the three inconsistent live-view URL conventions of `InitReview.md` §4.4 — the dead `/ws/video` default in `StreamingView` and the hand-written H.264/Zstd texture hooks are superseded by `DEC-001`. `IF-CM-004` stays in force for the control and bulk-data endpoints that remain | S | T-001 | IF-CM-004 | No dead route references remain; one URL convention for the surviving endpoints | - -**Phase exit criterion:** configuration, logging, capability reporting and safe state exist and are used. - ---- - -## 5. Phase 2 — Device control and GUI gating - -| ID | Task | Effort | Deps | Req | DoD | -|---|---|---|---|---|---| -| **T-020** | **One concrete camera driver.** Restore or rewrite the HIK driver on top of [UC2_HikPy]; plus a `MockCamera` so the whole UC2 branch is unit-testable with no SDK present | L | T-006, T-013 | IF-HW-001, NFR-FUN-002 | Hardware path enumerates and opens a real HIK camera; CI exercises the UC2 branch via `MockCamera` | -| **T-021** | Fix the `enumerate()` contract mismatch between the ABC and `Uc2DetectorManager` (`list[Uc2Camera]` vs. `CameraInfo`) | S | T-020 | IF-HW-001 | One consistent contract; typed and tested | -| **T-022** | **Per-subsystem hardware/virtual selection** replacing the single `use_virtual_microscope` flag | M | T-011 | IF-SW-006 | Any subsystem can be real while others are virtual; the mixed configuration is a tested case | -| **T-023** | Wire the existing UC2 **illumination** path to hardware (currently virtual unconditionally) | M | T-022 | REQ-LGT-001…004 | Real light source switches on/off and sets power from the GUI | -| **T-024** | Verify and complete the UC2 **stage** path over CAN: jog, step, absolute, home, position read-back, velocity/acceleration/jerk where supported | L | T-022, T-013 | REQ-STG-001…013, IF-HW-003 | All stage requirements demonstrable on hardware | -| **T-025** | **Soft travel limits** enforced in the backend, independent of frontend validation; limits from configuration | M | T-011, T-024 | REQ-STG-010, NFR-SAF-002 | Out-of-range command rejected with a message; no motion occurs | -| **T-026** | **Frontend capability gating (G1/G2)** — build device panels from the capability descriptor; unsupported parameters are not rendered | L | T-013 | IF-UI-005, REQ-DEV-004, REQ-CAM-007, REQ-STG-003/007 | `ACC-04` passes: a camera without frame-rate control shows no frame-rate control | -| **T-027** | **Frontend mode gating (G3–G9)** — implement the gate chain of `InvariantMatrix.md` §5 as one shared mechanism, driven by the CSV table | L | T-026, T-013 | IF-UI-010, NFR-USA-002/003 | Every row of `InvariantMatrix.csv` has a passing test; disabled controls state their cause | -| **T-028** | Bidirectional sync review: every control reflects device-originated changes; rejected values revert to the device-reported value with a message | M | T-026 | IF-UI-006, REQ-CAM-009 | Change made from a second client appears in the first within 200 ms | -| **T-029** | Move the stage **home** control out of the direction-button cluster into a separate menu; move step size / velocity / acceleration / jerk into that menu | S | T-026 | REQ-STG-005, REQ-STG-007 | Layout matches `basicRequirements.md` §Stages | -| **T-030** | Units and labels pass: ms, dB, 1/s, µm on every numeric control; slider and numerical entry always consistent | S | T-026 | IF-UI-007/008, NFR-USA-005 | Inspection checklist complete | - -**Phase exit criterion:** a real camera, a real stage and a real light source are controllable from the -GUI, and the GUI shows only what they support. - ---- - -## 6. Phase 3 — Imaging path - -| ID | Task | Effort | Deps | Req | DoD | -|---|---|---|---|---|---| -| **T-039** | **Spike: VP8 encode feasibility on the Pi 5.** Measure software libvpx VP8 encode cost at candidate resolutions and frame rates, alongside a running acquisition. Output: the encoder operating point (resolution, frame rate, decimation) that meets the latency budget within the CPU budget | M | — | NFR-PER-009, ASM-08 | A measured table of resolution × frame rate × CPU × latency; a chosen operating point, or an escalation that the Pi cannot carry it | -| **T-047** | **Deploy the LiveKit server** as a component of the instrument stack: compose service, configuration, healthcheck, startup ordering, lifecycle alongside the backend | M | T-011 | IF-CM-009, IF-CM-011, DEP-08 | Backend publishes to a running SFU; server restarts cleanly with the stack | -| **T-048** | **LiveKit access tokens and room model**: backend mints a token per user per session; room naming per instrument/detector; token lifetime and renewal | M | T-047, T-060 | IF-CM-011, REQ-SES-010 | A user with no valid token cannot subscribe; a reconnecting user is re-issued a token without losing their place in the authority queue | -| **T-049** | **Frontend LiveKit subscriber**: `livekit-client`, track subscription, render into `