|
| 1 | +# ============================================================================= |
| 2 | +# Copyright (c) 2026 Botts Innovative Research Inc. |
| 3 | +# Author: Ian Patterson |
| 4 | +# ============================================================================= |
| 5 | + |
| 6 | +"""SensorML 2.0 JSON-encoding structured-field models. |
| 7 | +
|
| 8 | +Three types are modeled here: |
| 9 | +
|
| 10 | +- `Term` — backs `SystemResource.identifiers` and `.classifiers`. Carries |
| 11 | + ``{definition, label?, value, codeSpace?, name?}`` per the SensorML |
| 12 | + IdentifierTerm / ClassifierTerm shape. |
| 13 | +- `Characteristics` and `Capabilities` — back the same-named fields on |
| 14 | + `SystemResource`. Each carries ``{definition?, label?, name?, |
| 15 | + description?, id?, <bucket>: [SWE Common component]}`` where |
| 16 | + ``<bucket>`` is ``characteristics`` for the former and ``capabilities`` |
| 17 | + for the latter. Inner components are typed against ``AnyComponent`` |
| 18 | + (the SWE Common discriminated union) and validated to carry a |
| 19 | + ``name`` per the SoftNamedProperty binding rule. |
| 20 | +
|
| 21 | +Models are permissive on optional metadata (label, name, description, |
| 22 | +id, codeSpace) because OSH and other servers vary in what they include |
| 23 | +on the wire. They are strict on the fields the spec marks required: |
| 24 | +``Term.definition`` / ``Term.value``, and the inner ``AnyComponent`` |
| 25 | +discriminator/name. ``model_rebuild(force=True)`` runs at the bottom so |
| 26 | +the recursive forward-ref machinery (each ``AnyComponent`` arm carries |
| 27 | +``list["AnyComponent"]``) doesn't leave a `MockValSer` on the |
| 28 | +serializer side — same `model_dump_json` regression the schema models |
| 29 | +needed.""" |
| 30 | +from __future__ import annotations |
| 31 | + |
| 32 | +from pydantic import BaseModel, ConfigDict, Field, model_validator |
| 33 | + |
| 34 | +from .swe_components import AnyComponent, check_named |
| 35 | + |
| 36 | + |
| 37 | +class Term(BaseModel): |
| 38 | + """SensorML `IdentifierTerm` / `ClassifierTerm` (SensorML 2.0 §7.2.5). |
| 39 | +
|
| 40 | + Used by ``SystemResource.identifiers`` and ``SystemResource.classifiers``. |
| 41 | + The wire shape OSH emits: |
| 42 | +
|
| 43 | + .. code-block:: json |
| 44 | +
|
| 45 | + {"definition": "http://.../SerialNumber", |
| 46 | + "label": "Serial Number", |
| 47 | + "value": "0123456879"} |
| 48 | + """ |
| 49 | + model_config = ConfigDict(populate_by_name=True, extra='allow') |
| 50 | + |
| 51 | + definition: str = Field(..., description="URI naming the term's semantics.") |
| 52 | + value: str = Field(..., description="The identifier/classifier value as a string.") |
| 53 | + label: str = Field(None, description="Optional display label.") |
| 54 | + name: str = Field(None, description="Optional NameToken — the field name in the containing object.") |
| 55 | + code_space: str = Field(None, alias='codeSpace', |
| 56 | + description="Optional URI naming the codelist `value` belongs to.") |
| 57 | + |
| 58 | + |
| 59 | +class Characteristics(BaseModel): |
| 60 | + """SensorML `CharacteristicList` (SensorML 2.0 §7.2.7). |
| 61 | +
|
| 62 | + Used by ``SystemResource.characteristics``. The wire shape carries a |
| 63 | + list of inner SWE Common components under the ``characteristics`` |
| 64 | + key, where each inner component is bound via SoftNamedProperty and |
| 65 | + must therefore carry a ``name``:: |
| 66 | +
|
| 67 | + {"definition": "http://.../OperatingRange", |
| 68 | + "label": "Operating Characteristics", |
| 69 | + "characteristics": [ |
| 70 | + {"type": "QuantityRange", "name": "voltage", …}, |
| 71 | + {"type": "QuantityRange", "name": "temperature", …} |
| 72 | + ]} |
| 73 | + """ |
| 74 | + model_config = ConfigDict(populate_by_name=True, extra='allow') |
| 75 | + |
| 76 | + definition: str = Field(None, |
| 77 | + description="URI naming the semantics of the list.") |
| 78 | + label: str = Field(None) |
| 79 | + description: str = Field(None) |
| 80 | + id: str = Field(None) |
| 81 | + name: str = Field(None) |
| 82 | + # Inner SWE Common components — typed against `AnyComponent` so the |
| 83 | + # discriminator on `type` routes to the right concrete subclass. |
| 84 | + characteristics: list[AnyComponent] = Field(..., |
| 85 | + description="Inner SWE Common components.") |
| 86 | + |
| 87 | + @model_validator(mode="after") |
| 88 | + def _characteristics_require_name(self): |
| 89 | + for i, c in enumerate(self.characteristics): |
| 90 | + check_named(c, f"Characteristics.characteristics[{i}]") |
| 91 | + return self |
| 92 | + |
| 93 | + |
| 94 | +class Capabilities(BaseModel): |
| 95 | + """SensorML `CapabilityList` (SensorML 2.0 §7.2.8). |
| 96 | +
|
| 97 | + Used by ``SystemResource.capabilities``. Isomorphic to |
| 98 | + `Characteristics` but with the inner-array bucket named |
| 99 | + ``capabilities`` instead of ``characteristics``.""" |
| 100 | + model_config = ConfigDict(populate_by_name=True, extra='allow') |
| 101 | + |
| 102 | + definition: str = Field(None, |
| 103 | + description="URI naming the semantics of the list.") |
| 104 | + label: str = Field(None) |
| 105 | + description: str = Field(None) |
| 106 | + id: str = Field(None) |
| 107 | + name: str = Field(None) |
| 108 | + capabilities: list[AnyComponent] = Field(..., |
| 109 | + description="Inner SWE Common components.") |
| 110 | + |
| 111 | + @model_validator(mode="after") |
| 112 | + def _capabilities_require_name(self): |
| 113 | + for i, c in enumerate(self.capabilities): |
| 114 | + check_named(c, f"Capabilities.capabilities[{i}]") |
| 115 | + return self |
| 116 | + |
| 117 | + |
| 118 | +# Defense-in-depth: same `MockValSer` rationale as the swe_components.py |
| 119 | +# and schema_datamodels.py rebuilds — the recursive forward-ref pattern |
| 120 | +# (`list[AnyComponent]` inside Characteristics/Capabilities) needs an |
| 121 | +# explicit force-rebuild to fully realize the serializer. |
| 122 | +Term.model_rebuild(force=True) |
| 123 | +Characteristics.model_rebuild(force=True) |
| 124 | +Capabilities.model_rebuild(force=True) |
| 125 | + |
| 126 | + |
| 127 | +__all__ = ["Term", "Characteristics", "Capabilities"] |
0 commit comments