Skip to content

Commit defd614

Browse files
default to new sml model on system resources by default.
1 parent 13553c5 commit defd614

13 files changed

Lines changed: 690 additions & 115 deletions

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "oshconnect"
3-
version = "0.5.1a13"
3+
version = "0.5.1a17"
44
description = "Library for interfacing with OSH, helping guide visualization efforts, and providing a place to store configurations. Implements OGC CS API Part 3 (Pub/Sub) MQTT topic conventions including :data topics and resource event topics."
55
readme = "README.md"
66
authors = [

src/oshconnect/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@
4242
AnyCommandSchema,
4343
)
4444

45+
# SensorML structured fields (carried by SystemResource)
46+
from .sensorml import Term, Characteristics, Capabilities
47+
4548
# Event system
4649
from .events import EventHandler, IEventListener, CallbackListener, DefaultEventTypes, AtomicEventTypes, Event, EventBuilder
4750

@@ -88,6 +91,10 @@
8891
"JSONCommandSchema",
8992
"AnyDatastreamRecordSchema",
9093
"AnyCommandSchema",
94+
# SensorML structured fields
95+
"Term",
96+
"Characteristics",
97+
"Capabilities",
9198
# Event system
9299
"EventHandler",
93100
"IEventListener",

src/oshconnect/resource_datamodels.py

Lines changed: 33 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from .api_utils import Link
1616
from .geometry import Geometry
1717
from .schema_datamodels import AnyCommandSchema, AnyDatastreamRecordSchema
18+
from .sensorml import Capabilities, Characteristics, Term
1819
from .timemanagement import TimeInstant, TimePeriod
1920

2021
if TYPE_CHECKING:
@@ -36,60 +37,14 @@ class BoundingBox(BaseModel):
3637
# return self
3738

3839

39-
class SecurityConstraints:
40-
constraints: list
41-
42-
43-
class LegalConstraints:
44-
constraints: list
45-
46-
47-
class Characteristics:
48-
characteristics: list
49-
50-
51-
class Capabilities:
52-
capabilities: list
53-
54-
55-
class Contact:
56-
contact: list
57-
58-
59-
class Documentation:
60-
documentation: list
61-
62-
63-
class HistoryEvent:
64-
history_event: list
65-
66-
67-
class ConfigurationSettings:
68-
settings: list
69-
70-
71-
class FeatureOfInterest:
72-
feature: list
73-
74-
75-
class Input:
76-
input: list
77-
78-
79-
class Output:
80-
output: list
81-
82-
83-
class Parameter:
84-
parameter: list
85-
86-
87-
class Mode:
88-
mode: list
89-
90-
91-
class ProcessMethod:
92-
method: list
40+
# SensorML structured fields below (identifiers, characteristics,
41+
# capabilities, contacts, etc.) carry rich SWE Common / SensorML Term
42+
# trees on the wire. They were previously typed against bare-class
43+
# placeholders here, which made every SML+JSON server response fail to
44+
# parse (`dict is not instance of Characteristics`). Until we model
45+
# these properly as pydantic types, we accept them as raw `dict` /
46+
# `list[dict]` so cross-node sync round-trips them losslessly. See
47+
# ROADMAP.md.
9348

9449

9550
class BaseResource(BaseModel):
@@ -103,7 +58,11 @@ class BaseResource(BaseModel):
10358

10459

10560
class SystemResource(BaseModel):
106-
model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True)
61+
# `extra='allow'` lets unmodeled SensorML fields (e.g. ``position``
62+
# in the SML+JSON listing) round-trip through the model rather than
63+
# being silently dropped on parse — important for cross-node sync.
64+
model_config = ConfigDict(arbitrary_types_allowed=True,
65+
populate_by_name=True, extra='allow')
10766

10867
feature_type: str = Field(None, alias="type")
10968
system_id: str = Field(None, alias="id")
@@ -116,25 +75,28 @@ class SystemResource(BaseModel):
11675
label: str = Field(None)
11776
lang: str = Field(None)
11877
keywords: List[str] = Field(None)
119-
identifiers: List[str] = Field(None)
120-
classifiers: List[str] = Field(None)
78+
# SensorML Term objects (`{definition, label, value}`).
79+
identifiers: list[Term] = Field(None)
80+
classifiers: list[Term] = Field(None)
12181
valid_time: TimePeriod = Field(None, alias="validTime")
122-
security_constraints: List[SecurityConstraints] = Field(None, alias="securityConstraints")
123-
legal_constraints: List[LegalConstraints] = Field(None, alias="legalConstraints")
124-
characteristics: List[Characteristics] = Field(None)
125-
capabilities: List[Capabilities] = Field(None)
126-
contacts: List[Contact] = Field(None)
127-
documentation: List[Documentation] = Field(None)
128-
history: List[HistoryEvent] = Field(None)
82+
security_constraints: list[dict] = Field(None, alias="securityConstraints")
83+
legal_constraints: list[dict] = Field(None, alias="legalConstraints")
84+
# SensorML CharacteristicList / CapabilityList — each carries inner
85+
# SWE Common components routed via `AnyComponent`'s `type` discriminator.
86+
characteristics: list[Characteristics] = Field(None)
87+
capabilities: list[Capabilities] = Field(None)
88+
contacts: list[dict] = Field(None)
89+
documentation: list[dict] = Field(None)
90+
history: list[dict] = Field(None)
12991
definition: str = Field(None)
13092
type_of: str = Field(None, alias="typeOf")
131-
configuration: ConfigurationSettings = Field(None)
132-
features_of_interest: List[FeatureOfInterest] = Field(None, alias="featuresOfInterest")
133-
inputs: List[Input] = Field(None)
134-
outputs: List[Output] = Field(None)
135-
parameters: List[Parameter] = Field(None)
136-
modes: List[Mode] = Field(None)
137-
method: ProcessMethod = Field(None)
93+
configuration: dict = Field(None)
94+
features_of_interest: list[dict] = Field(None, alias="featuresOfInterest")
95+
inputs: list[dict] = Field(None)
96+
outputs: list[dict] = Field(None)
97+
parameters: list[dict] = Field(None)
98+
modes: list[dict] = Field(None)
99+
method: dict = Field(None)
138100

139101
def to_smljson_dict(self) -> dict:
140102
"""Render this system as an `application/sml+json` dict (SensorML JSON encoding).

src/oshconnect/sensorml.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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

Comments
 (0)