feat: 为每个 Sensor 绑定只读 SensorContext
构造时一次绑定 SN、配置、资产和当前生效的标定:只接受显式给出的 SN,不做猜测;标定路径只取当前生效的标定,清除后为 None;换标定时整体替换。帧身份复用 stream-contract 的 FrameIdentity。load_config 改为复用 resolve_config_source,使记录的配置来源与实际读取的文件一致,并补一致性测试。已发布 Sensor 数值和资产包解析不变。回归 275 项、lint 通过。
This commit is contained in:
@@ -163,14 +163,54 @@ assert measurement.stamp == stamp
|
|||||||
|
|
||||||
未知字段被拒绝,是为了让不同版本的记录不会静默错配。采用政策 A:字段变更(包括新增字段)一律升 `STREAM_CONTRACT_VERSION`,同时把新版本加入读取方的 `SUPPORTED_CONTRACT_VERSIONS`;同版本内不新增字段。
|
未知字段被拒绝,是为了让不同版本的记录不会静默错配。采用政策 A:字段变更(包括新增字段)一律升 `STREAM_CONTRACT_VERSION`,同时把新版本加入读取方的 `SUPPORTED_CONTRACT_VERSIONS`;同版本内不新增字段。
|
||||||
|
|
||||||
|
## Sensor 上下文(只读绑定)
|
||||||
|
|
||||||
|
`Sensor.context` 返回一个 frozen `SensorContext`(`from orisys.sensors import SensorContext`),在构造时为本实例绑定一次以下内容,并在显式换标定时整体替换:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `sensor_id` | 仅显式提供的设备 SN(`device_id`);未显式绑定为 `None`,绝不由枚举顺序、文件夹名或相机编号猜测 |
|
||||||
|
| `product_family` / `processing_backend` | 由配置/资产包确定的产品家族与图像处理后端 |
|
||||||
|
| `config_name` / `config_source` / `config_fingerprint` | 配置名、实际读取的 JSON 文件、加载后配置的规范化 SHA-256 |
|
||||||
|
| `bundle_root` / `manifest_digest` / `assets` | 资产包根目录、manifest 内容摘要、manifest 声明的资产角色与包内相对路径(legacy manifest 与 descriptor manifest 都支持;运行时实际加载路径可能不同) |
|
||||||
|
| `calibration_path` / `calibration_fingerprint` | 当前**正在生效**的力标定文件与内容指纹;未标定或 `set_calibration(None)` 后为 `None`/`"none"` |
|
||||||
|
| `diagnostics` | 绑定过程中可容忍的资产/配置说明,不改变 Sensor 的构造结果 |
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 帧身份/时钟字段不在这里重复:`context.frame_identity(session_id=..., frame_id=..., source_frame_id=..., acquisition_id=..., sync_group_id=...)` 直接构造 T03 的 `FrameIdentity`。没有显式 SN 时它抛 `ValueError`,不伪造身份。
|
||||||
|
- 每个 Sensor 实例独立绑定,没有模块级共享缓存;创建或使用另一个 Sensor 不会改变本实例的上下文。
|
||||||
|
- 显式 `Sensor.set_calibration()` 换标定时原子替换上下文:SN/配置/资产绑定保持不变,标定路径与指纹更新;`set_calibration(None)` 后 `calibration_path` 立即为 `None`、指纹为 `"none"`,不保留构造时请求过的旧路径。
|
||||||
|
- 绑定异常只写入 `diagnostics`,不把原本可构造的 Sensor 变成构造失败。
|
||||||
|
- `assets`/`manifest_digest` 是 **manifest 声明的绑定**,不是运行时实际加载的路径:Finger 的导出目录由 `finger/exports.py` 在 cwd、打包目录、可编辑安装目录等候选中另行查找,实际加载路径由 T05 的图像块绑定;需要复现实验时以 T05/记录的身份为准。
|
||||||
|
- 资产取应用包内相对路径,不复制、不上传、不按顺序推断;配置指纹取实际加载后的配置内容,而不是文件名。
|
||||||
|
- 本任务(T04)不改变 Sensor 的数值与既有资产包解析行为,也不把上下文接入流入口;T05/T08 消费该上下文。
|
||||||
|
|
||||||
|
```python
|
||||||
|
from orisys import configs
|
||||||
|
from orisys.sensors import build_sensor_context
|
||||||
|
from orisys.stream_contract import FrameIdentity
|
||||||
|
|
||||||
|
context = build_sensor_context(
|
||||||
|
config_name="box",
|
||||||
|
config=configs.load_config("box"),
|
||||||
|
device_id="BOX-001",
|
||||||
|
bundle_candidates=["box"],
|
||||||
|
)
|
||||||
|
identity: FrameIdentity = context.frame_identity(
|
||||||
|
session_id="run-7", frame_id=12, source_frame_id=41,
|
||||||
|
)
|
||||||
|
assert identity.sensor_id == "BOX-001"
|
||||||
|
```
|
||||||
|
|
||||||
## 不包含什么
|
## 不包含什么
|
||||||
|
|
||||||
- 不实现 SensorStream 入口、实时最新选帧、完整回放和背压(T08)。
|
- 不实现 SensorStream 入口、实时最新选帧、完整回放和背压(T08)。
|
||||||
- 不实现图像块拆分、测量负载、ForceSession 重做、零点与输出滤波(T04–T07)。
|
- 不实现图像块拆分、测量负载、ForceSession 重做、零点与输出滤波(T05–T07)。
|
||||||
- 不实现批次、配组、多流调度(后续多流计划)。
|
- 不实现批次、配组、多流调度(后续多流计划)。
|
||||||
- 不实现录制/回放格式、Studio 迁移和离线重新滤波(T11、T13–T15)。
|
- 不实现录制/回放格式、Studio 迁移和离线重新滤波(T11、T13–T15)。
|
||||||
- 不修改现有 `Sensor`、`StreamPipeline` 和当前 `ForceSession`;现有 `ForceSession.FrameStamp` 在 T06 重做时被本合同的帧头替换。
|
- 不修改现有 `Sensor`、`StreamPipeline` 和当前 `ForceSession`;现有 `ForceSession.FrameStamp` 在 T06 重做时被本合同的帧头替换。
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
`tests/test_stream_contract.py` 覆盖 T03 的四项验收:逐路身份与时钟、组字段可缺但不半组、同 PTS 帧可区分、`source_dt_s` 与事件 JSON 往返;同时覆盖 frozen 校验、严格版本/未知字段拒绝(政策 A)、`SUPPORTED_CONTRACT_VERSIONS`、事件可哈希、`kind` 的 lower_snake 格式和非法负载拒绝。类型本身不启动传感器、流水线或会话。
|
`tests/test_stream_contract.py` 覆盖 T03 的四项验收:逐路身份与时钟、组字段可缺但不半组、同 PTS 帧可区分、`source_dt_s` 与事件 JSON 往返;同时覆盖 frozen 校验、严格版本/未知字段拒绝(政策 A)、`SUPPORTED_CONTRACT_VERSIONS`、事件可哈希、`kind` 的 lower_snake 格式和非法负载拒绝。`tests/test_sensor_context.py` 覆盖 T04 的只读 SensorContext:显式 SN/配置/资产/标定路径绑定、不猜 SN、实例隔离、换标定原子替换、绑定失败只报 diagnostics,以及 config source/资产包绑定。类型与上下文本身不启动传感器、流水线或会话。
|
||||||
|
|||||||
+50
-19
@@ -283,6 +283,48 @@ def resolve_sensor_config_path(name: str) -> Path | None:
|
|||||||
return bundle.sensor_json if bundle is not None else None
|
return bundle.sensor_json if bundle is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_config_source(config_name_or_path) -> Path | None:
|
||||||
|
"""Return the JSON file :func:`load_config` would read for this name/path.
|
||||||
|
|
||||||
|
Uses the same lookup order as ``load_config``: exact file, additive sensor
|
||||||
|
bundle, config directory stem, then the legacy ``"name"`` field scan. Returns
|
||||||
|
``None`` when the name has no file (never inventing a path), and follows
|
||||||
|
aliases exactly like loading does so a recorded path is the one that was
|
||||||
|
actually read.
|
||||||
|
"""
|
||||||
|
config_path = Path(config_name_or_path)
|
||||||
|
if config_path.exists() and config_path.is_file():
|
||||||
|
return config_path.resolve()
|
||||||
|
|
||||||
|
if config_path.is_absolute():
|
||||||
|
return None
|
||||||
|
|
||||||
|
requested = str(config_name_or_path)
|
||||||
|
candidates = [requested]
|
||||||
|
alias = CONFIG_ALIASES.get(requested)
|
||||||
|
if alias and alias not in candidates:
|
||||||
|
candidates.append(alias)
|
||||||
|
|
||||||
|
for name in candidates:
|
||||||
|
bundle = resolve_sensor_bundle(name)
|
||||||
|
if bundle is not None:
|
||||||
|
return bundle.sensor_json
|
||||||
|
|
||||||
|
for name in candidates:
|
||||||
|
direct = CONFIG_DIR / f"{name}.json"
|
||||||
|
if direct.exists():
|
||||||
|
return direct.resolve()
|
||||||
|
|
||||||
|
for candidate in _config_files():
|
||||||
|
try:
|
||||||
|
data = _load_json(candidate)
|
||||||
|
except Exception: # noqa: BLE001, S112 - unreadable configs are skipped during the scan
|
||||||
|
continue
|
||||||
|
if data.get("name") in candidates:
|
||||||
|
return candidate.resolve()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def resolve_sensor_asset_dir(name: str) -> Path | None:
|
def resolve_sensor_asset_dir(name: str) -> Path | None:
|
||||||
"""Return the default asset directory for a resolved sensor bundle."""
|
"""Return the default asset directory for a resolved sensor bundle."""
|
||||||
bundle = resolve_sensor_bundle(name)
|
bundle = resolve_sensor_bundle(name)
|
||||||
@@ -402,6 +444,10 @@ def load_config(config_name_or_path):
|
|||||||
2. Additive sensor bundle ``sensors/<name>/sensor.json``
|
2. Additive sensor bundle ``sensors/<name>/sensor.json``
|
||||||
3. File stem under the config directory (e.g. ``box`` -> ``config/box.json``)
|
3. File stem under the config directory (e.g. ``box`` -> ``config/box.json``)
|
||||||
4. JSON field ``"name"`` matching the requested name (keeps ``prototype`` compatible)
|
4. JSON field ``"name"`` matching the requested name (keeps ``prototype`` compatible)
|
||||||
|
|
||||||
|
The lookup itself lives in :func:`resolve_config_source`, so a caller that
|
||||||
|
records which file was read (for example ``SensorContext.config_source``)
|
||||||
|
cannot drift from what was actually loaded here.
|
||||||
"""
|
"""
|
||||||
config_path = Path(config_name_or_path)
|
config_path = Path(config_name_or_path)
|
||||||
|
|
||||||
@@ -413,31 +459,16 @@ def load_config(config_name_or_path):
|
|||||||
if config_path.is_absolute():
|
if config_path.is_absolute():
|
||||||
raise FileNotFoundError(f"Config file not found at absolute path: {config_path}")
|
raise FileNotFoundError(f"Config file not found at absolute path: {config_path}")
|
||||||
|
|
||||||
|
source = resolve_config_source(config_name_or_path)
|
||||||
|
if source is not None:
|
||||||
|
return _load_json(source)
|
||||||
|
|
||||||
requested = str(config_name_or_path)
|
requested = str(config_name_or_path)
|
||||||
candidates = [requested]
|
candidates = [requested]
|
||||||
alias = CONFIG_ALIASES.get(requested)
|
alias = CONFIG_ALIASES.get(requested)
|
||||||
if alias and alias not in candidates:
|
if alias and alias not in candidates:
|
||||||
candidates.append(alias)
|
candidates.append(alias)
|
||||||
|
|
||||||
for name in candidates:
|
|
||||||
bundle = resolve_sensor_bundle(name)
|
|
||||||
if bundle is not None:
|
|
||||||
return _load_json(bundle.sensor_json)
|
|
||||||
|
|
||||||
for name in candidates:
|
|
||||||
direct = CONFIG_DIR / f"{name}.json"
|
|
||||||
if direct.exists():
|
|
||||||
return _load_json(direct)
|
|
||||||
|
|
||||||
# Backward-compatible alias resolution: support old names via JSON "name" field.
|
|
||||||
for candidate in _config_files():
|
|
||||||
try:
|
|
||||||
data = _load_json(candidate)
|
|
||||||
except Exception: # noqa: S112 - skip unreadable config during alias scan
|
|
||||||
continue
|
|
||||||
if data.get("name") in candidates:
|
|
||||||
return data
|
|
||||||
|
|
||||||
available = [f.stem for f in _config_files()]
|
available = [f.stem for f in _config_files()]
|
||||||
available_str = ", ".join(available) if available else "none"
|
available_str = ", ".join(available) if available else "none"
|
||||||
raise FileNotFoundError(
|
raise FileNotFoundError(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import time
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -41,6 +42,9 @@ from .capture import (
|
|||||||
open_capture,
|
open_capture,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .sensors.context import SensorContext
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class FrameMeasurement:
|
class FrameMeasurement:
|
||||||
@@ -229,6 +233,10 @@ class Sensor:
|
|||||||
self.bind_calibration_context(build_context(
|
self.bind_calibration_context(build_context(
|
||||||
self._config, self.filters_state(), preprocessing={"mirror": self.mirror},
|
self._config, self.filters_state(), preprocessing={"mirror": self.mirror},
|
||||||
))
|
))
|
||||||
|
# One read-only binding of SN/config/assets/cal_path per instance. The
|
||||||
|
# later read-only SensorContext contract (T04) is additive; a binding
|
||||||
|
# problem is reported on the context, never by changing construction.
|
||||||
|
self._bind_sensor_context()
|
||||||
|
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
if self.calibration is not None and self.calibration.source_path is not None:
|
if self.calibration is not None and self.calibration.source_path is not None:
|
||||||
@@ -1435,6 +1443,48 @@ class Sensor:
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _bind_sensor_context(self) -> None:
|
||||||
|
"""Bind this instance's read-only SN/config/assets/cal_path context.
|
||||||
|
|
||||||
|
Called once during construction and again only when the force
|
||||||
|
calibration is explicitly replaced. The binding is local to this
|
||||||
|
instance; no state is cached or shared between sensors, and an
|
||||||
|
unexpected binding problem is reported on the context instead of
|
||||||
|
changing whether a published Sensor can be constructed. Partially
|
||||||
|
initialised Sensor doubles (tests build some with ``__new__``) are
|
||||||
|
tolerated: their missing fields bind as ``None``.
|
||||||
|
"""
|
||||||
|
from .sensors.context import build_sensor_context, fallback_sensor_context
|
||||||
|
|
||||||
|
config_name = getattr(self, "config_name", "")
|
||||||
|
config = getattr(self, "_config", None)
|
||||||
|
device_id = getattr(self, "device_id", None)
|
||||||
|
calibration = getattr(self, "calibration", None)
|
||||||
|
try:
|
||||||
|
bundle_candidates = self._bundle_name_candidates()
|
||||||
|
except Exception: # noqa: BLE001 - a test double may not carry the config
|
||||||
|
bundle_candidates = []
|
||||||
|
try:
|
||||||
|
self._sensor_context = build_sensor_context(
|
||||||
|
config_name=config_name,
|
||||||
|
config=config if isinstance(config, dict) else {},
|
||||||
|
device_id=device_id,
|
||||||
|
calibration=calibration,
|
||||||
|
bundle_candidates=bundle_candidates,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - context binding must not break a Sensor
|
||||||
|
self._sensor_context = fallback_sensor_context(
|
||||||
|
config_name=config_name,
|
||||||
|
config=config,
|
||||||
|
device_id=device_id,
|
||||||
|
reason=exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def context(self) -> "SensorContext":
|
||||||
|
"""Read-only SN/config/assets/calibration binding of this Sensor instance."""
|
||||||
|
return self._sensor_context
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def force_calibrated(self) -> bool:
|
def force_calibrated(self) -> bool:
|
||||||
"""True when at least one channel curve is loaded."""
|
"""True when at least one channel curve is loaded."""
|
||||||
@@ -1475,6 +1525,7 @@ class Sensor:
|
|||||||
self._calibration_context = None
|
self._calibration_context = None
|
||||||
self._force_readout.reset()
|
self._force_readout.reset()
|
||||||
self._apply_force_filter()
|
self._apply_force_filter()
|
||||||
|
self._bind_sensor_context()
|
||||||
return
|
return
|
||||||
if isinstance(cal, cal_mod.Calibration) or getattr(cal, "schema_version", None) == 3:
|
if isinstance(cal, cal_mod.Calibration) or getattr(cal, "schema_version", None) == 3:
|
||||||
selected = cal
|
selected = cal
|
||||||
@@ -1509,6 +1560,9 @@ class Sensor:
|
|||||||
if not self._uses_calibration_model():
|
if not self._uses_calibration_model():
|
||||||
self._force_readout.reset()
|
self._force_readout.reset()
|
||||||
self._apply_force_filter()
|
self._apply_force_filter()
|
||||||
|
# The context is a value object: an explicit calibration swap atomically
|
||||||
|
# replaces it (SN/config/assets stay the same; cal path/fingerprint update).
|
||||||
|
self._bind_sensor_context()
|
||||||
|
|
||||||
def _apply_force_filter(self):
|
def _apply_force_filter(self):
|
||||||
convert = self.calibration.convert if self.calibration is not None else None
|
convert = self.calibration.convert if self.calibration is not None else None
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ from .capabilities import (
|
|||||||
report,
|
report,
|
||||||
unavailable,
|
unavailable,
|
||||||
)
|
)
|
||||||
|
from .context import (
|
||||||
|
SensorAssetBinding,
|
||||||
|
SensorContext,
|
||||||
|
build_sensor_context,
|
||||||
|
fallback_sensor_context,
|
||||||
|
)
|
||||||
from .descriptor import (
|
from .descriptor import (
|
||||||
BACKEND_COMPOUND_EYE,
|
BACKEND_COMPOUND_EYE,
|
||||||
BACKEND_SINGLE_CAMERA,
|
BACKEND_SINGLE_CAMERA,
|
||||||
@@ -100,6 +106,11 @@ __all__ = [
|
|||||||
"Availability",
|
"Availability",
|
||||||
"AxisAvailability",
|
"AxisAvailability",
|
||||||
"Issue",
|
"Issue",
|
||||||
|
# read-only per-instance binding (T04)
|
||||||
|
"SensorContext",
|
||||||
|
"SensorAssetBinding",
|
||||||
|
"build_sensor_context",
|
||||||
|
"fallback_sensor_context",
|
||||||
# descriptor
|
# descriptor
|
||||||
"BundleDescriptor",
|
"BundleDescriptor",
|
||||||
"DescriptorError",
|
"DescriptorError",
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
"""Read-only ``SensorContext``: the SN, configuration, assets and calibration path
|
||||||
|
bound once for one Sensor instance.
|
||||||
|
|
||||||
|
Stage-1 architecture (K11.1, sensor-context task T04) makes one object own what
|
||||||
|
must not be re-guessed per frame: which physical sensor this is, which
|
||||||
|
``sensor.json``/configuration it runs, which bundle assets were selected, and
|
||||||
|
which force calibration was chosen. The context is a frozen value object; a
|
||||||
|
``Sensor`` binds it once during construction (and atomically replaces it only
|
||||||
|
when its calibration is explicitly swapped), so two Sensor instances cannot
|
||||||
|
share or overwrite each other's identity.
|
||||||
|
|
||||||
|
Identity fields are not duplicated here: :meth:`SensorContext.frame_identity`
|
||||||
|
builds the frozen :class:`~orisys.stream_contract.FrameIdentity` from stage 1,
|
||||||
|
so the per-frame stream contract remains the single home for frame identity and
|
||||||
|
clock fields.
|
||||||
|
|
||||||
|
The context is plain data (paths as strings, hashes, tuples): no Qt, no device
|
||||||
|
handle, no mutable mapping. ``build_sensor_context`` is tolerant by design - a
|
||||||
|
bundle that cannot be described is reported in ``diagnostics`` instead of
|
||||||
|
changing a published Sensor's construction behaviour.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .. import configs as sdk_configs
|
||||||
|
from ..stream_contract import FrameIdentity
|
||||||
|
from .descriptor import parse_descriptor
|
||||||
|
from .legacy import backend_from_config
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SensorAssetBinding",
|
||||||
|
"SensorContext",
|
||||||
|
"build_sensor_context",
|
||||||
|
"fallback_sensor_context",
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Manifest keys that describe the bundle itself rather than an asset file.
|
||||||
|
_MANIFEST_META_KEYS = frozenset({"name", "sensor_json", "version", "schema_version"})
|
||||||
|
|
||||||
|
|
||||||
|
def _json_digest(value: Any) -> tuple[str, str | None]:
|
||||||
|
"""Canonical SHA-256 of a JSON value plus an optional diagnostic."""
|
||||||
|
try:
|
||||||
|
text = json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||||
|
except (TypeError, ValueError) as exc: # a config that is not plain JSON
|
||||||
|
return (
|
||||||
|
hashlib.sha256(str(value).encode("utf-8")).hexdigest(),
|
||||||
|
f"config is not plain JSON for fingerprinting: {exc}",
|
||||||
|
)
|
||||||
|
return hashlib.sha256(text.encode("utf-8")).hexdigest(), None
|
||||||
|
|
||||||
|
|
||||||
|
def _file_digest(path: Path) -> str | None:
|
||||||
|
try:
|
||||||
|
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _relative_posix(path: Path, root: Path) -> str | None:
|
||||||
|
try:
|
||||||
|
return path.resolve().relative_to(root.resolve()).as_posix()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest_assets(
|
||||||
|
bundle: sdk_configs.SensorBundle,
|
||||||
|
manifest_raw: Mapping[str, Any] | None,
|
||||||
|
descriptor,
|
||||||
|
diagnostics: list[str],
|
||||||
|
) -> tuple[SensorAssetBinding, ...]:
|
||||||
|
if manifest_raw is None:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
if descriptor is not None:
|
||||||
|
# Descriptor-shaped manifest: bind the declared computational assets.
|
||||||
|
bindings = []
|
||||||
|
for declared in (descriptor.computational_assets, descriptor.assets):
|
||||||
|
for role, value in sorted(declared.items()):
|
||||||
|
rel = _relative_posix(Path(value), bundle.root)
|
||||||
|
if rel is None:
|
||||||
|
diagnostics.append(f"asset {role!r} escapes the bundle folder")
|
||||||
|
continue
|
||||||
|
bindings.append(SensorAssetBinding(str(role), rel))
|
||||||
|
return tuple(bindings)
|
||||||
|
|
||||||
|
# Legacy manifest (for example ``sensors/finger/manifest.json``): bind the
|
||||||
|
# declared asset directory and any single-file entries it names.
|
||||||
|
bindings = []
|
||||||
|
for role, value in sorted(manifest_raw.items()):
|
||||||
|
if (
|
||||||
|
role in _MANIFEST_META_KEYS
|
||||||
|
or not isinstance(value, str)
|
||||||
|
or not value.strip()
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
candidate = (bundle.root / Path(value)).resolve()
|
||||||
|
rel = _relative_posix(candidate, bundle.root)
|
||||||
|
if rel is None:
|
||||||
|
diagnostics.append(f"manifest entry {role!r} escapes the bundle folder")
|
||||||
|
continue
|
||||||
|
bindings.append(SensorAssetBinding(str(role), rel))
|
||||||
|
return tuple(bindings)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SensorAssetBinding:
|
||||||
|
"""One bundle asset the instance was bound to: a role and a bundle-relative path."""
|
||||||
|
|
||||||
|
role: str
|
||||||
|
path: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SensorContext:
|
||||||
|
"""The immutable identity/config/assets/calibration binding of one Sensor instance.
|
||||||
|
|
||||||
|
``sensor_id`` is only the **explicitly supplied** SN (``device_id``); it is
|
||||||
|
``None`` when the caller did not bind one. The context never guesses an SN
|
||||||
|
from enumeration order, bundle names or reserved camera indices - a frame
|
||||||
|
that needs a stream identity therefore fails loudly instead of being
|
||||||
|
attributed to the wrong device. ``calibration_path`` and
|
||||||
|
``calibration_fingerprint`` describe the calibration currently in effect:
|
||||||
|
``set_calibration(None)`` leaves them ``None``/``"none"`` even when the
|
||||||
|
Sensor was constructed with an explicit path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
sensor_id: str | None
|
||||||
|
product_family: str | None
|
||||||
|
processing_backend: str
|
||||||
|
config_name: str
|
||||||
|
config_source: str | None
|
||||||
|
config_fingerprint: str
|
||||||
|
bundle_root: str | None
|
||||||
|
manifest_digest: str | None
|
||||||
|
assets: tuple[SensorAssetBinding, ...]
|
||||||
|
calibration_path: str | None
|
||||||
|
calibration_fingerprint: str
|
||||||
|
diagnostics: tuple[str, ...] = field(default_factory=tuple)
|
||||||
|
|
||||||
|
def frame_identity(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session_id: str,
|
||||||
|
frame_id: int,
|
||||||
|
source_frame_id: int | None,
|
||||||
|
acquisition_id: str | None = None,
|
||||||
|
sync_group_id: str | None = None,
|
||||||
|
) -> FrameIdentity:
|
||||||
|
"""Build this stream's frozen frame identity from the bound SN.
|
||||||
|
|
||||||
|
The capture clock is not part of ``SensorContext``: it belongs to the
|
||||||
|
frame header and is produced with the frame. This method reuses
|
||||||
|
:class:`~orisys.stream_contract.FrameIdentity` rather than defining a
|
||||||
|
second identity shape.
|
||||||
|
"""
|
||||||
|
if self.sensor_id is None:
|
||||||
|
raise ValueError(
|
||||||
|
"SensorContext has no explicit sensor_id (SN); construct the Sensor "
|
||||||
|
"with device_id (or bind one) before building a frame identity"
|
||||||
|
)
|
||||||
|
return FrameIdentity(
|
||||||
|
sensor_id=self.sensor_id,
|
||||||
|
session_id=session_id,
|
||||||
|
frame_id=frame_id,
|
||||||
|
source_frame_id=source_frame_id,
|
||||||
|
acquisition_id=acquisition_id,
|
||||||
|
sync_group_id=sync_group_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
"""JSON-serialisable snapshot for records and diagnostics."""
|
||||||
|
return {
|
||||||
|
"sensor_id": self.sensor_id,
|
||||||
|
"product_family": self.product_family,
|
||||||
|
"processing_backend": self.processing_backend,
|
||||||
|
"config_name": self.config_name,
|
||||||
|
"config_source": self.config_source,
|
||||||
|
"config_fingerprint": self.config_fingerprint,
|
||||||
|
"bundle_root": self.bundle_root,
|
||||||
|
"manifest_digest": self.manifest_digest,
|
||||||
|
"assets": [
|
||||||
|
{"role": asset.role, "path": asset.path} for asset in self.assets
|
||||||
|
],
|
||||||
|
"calibration_path": self.calibration_path,
|
||||||
|
"calibration_fingerprint": self.calibration_fingerprint,
|
||||||
|
"diagnostics": list(self.diagnostics),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_sensor_context(
|
||||||
|
*,
|
||||||
|
config_name: str | Path,
|
||||||
|
config: Mapping[str, Any],
|
||||||
|
device_id: str | None = None,
|
||||||
|
calibration: Any = None,
|
||||||
|
bundle_candidates: tuple[str, ...] | list[str] = (),
|
||||||
|
) -> SensorContext:
|
||||||
|
"""Bind one Sensor's SN, configuration identity, assets and calibration path.
|
||||||
|
|
||||||
|
Every lookup is explicit and local to this call; nothing is shared between
|
||||||
|
instances. Optional bundle description problems are collected in
|
||||||
|
``diagnostics`` and never raised, so adding a context cannot change whether a
|
||||||
|
published Sensor can be constructed.
|
||||||
|
"""
|
||||||
|
diagnostics: list[str] = []
|
||||||
|
cfg = config if isinstance(config, Mapping) else {}
|
||||||
|
config_fingerprint, digest_problem = _json_digest(dict(cfg))
|
||||||
|
if digest_problem:
|
||||||
|
diagnostics.append(digest_problem)
|
||||||
|
|
||||||
|
config_source: str | None = None
|
||||||
|
try:
|
||||||
|
source = sdk_configs.resolve_config_source(config_name)
|
||||||
|
except Exception as exc: # noqa: BLE001 - diagnostics only
|
||||||
|
source = None
|
||||||
|
diagnostics.append(f"config source could not be resolved: {exc}")
|
||||||
|
if source is not None:
|
||||||
|
config_source = str(Path(source).resolve())
|
||||||
|
|
||||||
|
names = [str(candidate) for candidate in bundle_candidates]
|
||||||
|
product_family = next(
|
||||||
|
(name for name in names if name in sdk_configs.PRODUCT_BUNDLES), None
|
||||||
|
)
|
||||||
|
bundle = None
|
||||||
|
for name in names:
|
||||||
|
try:
|
||||||
|
bundle = sdk_configs.resolve_sensor_bundle(name)
|
||||||
|
except Exception as exc: # noqa: BLE001 - diagnostics only
|
||||||
|
diagnostics.append(f"bundle {name!r} could not be resolved: {exc}")
|
||||||
|
continue
|
||||||
|
if bundle is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
processing_backend = backend_from_config(cfg)
|
||||||
|
bundle_root: str | None = None
|
||||||
|
manifest_digest: str | None = None
|
||||||
|
assets: tuple[SensorAssetBinding, ...] = ()
|
||||||
|
if bundle is not None:
|
||||||
|
bundle_root = str(bundle.root)
|
||||||
|
if product_family is None and bundle.name in sdk_configs.PRODUCT_BUNDLES:
|
||||||
|
product_family = bundle.name
|
||||||
|
manifest_raw: Mapping[str, Any] | None = None
|
||||||
|
descriptor = None
|
||||||
|
if bundle.manifest_json is not None:
|
||||||
|
manifest_digest = _file_digest(bundle.manifest_json)
|
||||||
|
if manifest_digest is None:
|
||||||
|
diagnostics.append("bundle manifest.json is unreadable")
|
||||||
|
try:
|
||||||
|
loaded = json.loads(
|
||||||
|
Path(bundle.manifest_json).read_text(encoding="utf-8-sig")
|
||||||
|
)
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
diagnostics.append(f"bundle manifest.json could not be read: {exc}")
|
||||||
|
else:
|
||||||
|
if isinstance(loaded, Mapping):
|
||||||
|
manifest_raw = loaded
|
||||||
|
if loaded.get("schema_version") is not None:
|
||||||
|
try:
|
||||||
|
descriptor = parse_descriptor(bundle.root)
|
||||||
|
except Exception as exc: # noqa: BLE001 - reported, never fatal
|
||||||
|
diagnostics.append(
|
||||||
|
f"bundle manifest.json is not a valid descriptor: {exc}"
|
||||||
|
)
|
||||||
|
# Legacy manifests have no backend field; the config rule that
|
||||||
|
# already drives create_frame_processor stays in force.
|
||||||
|
else:
|
||||||
|
diagnostics.append("bundle manifest.json is not a JSON object")
|
||||||
|
if descriptor is not None:
|
||||||
|
processing_backend = descriptor.processing_backend
|
||||||
|
assets = _manifest_assets(bundle, manifest_raw, descriptor, diagnostics)
|
||||||
|
else:
|
||||||
|
diagnostics.append("no additive sensor bundle resolved for this config")
|
||||||
|
|
||||||
|
calibration_path: str | None = None
|
||||||
|
calibration_fingerprint = "none"
|
||||||
|
if calibration is not None:
|
||||||
|
source_path = getattr(calibration, "source_path", None)
|
||||||
|
if source_path is not None:
|
||||||
|
calibration_path = str(Path(source_path).resolve())
|
||||||
|
fingerprint = getattr(calibration, "fingerprint", None)
|
||||||
|
if isinstance(fingerprint, str) and fingerprint:
|
||||||
|
calibration_fingerprint = fingerprint
|
||||||
|
else:
|
||||||
|
calibration_fingerprint = "unknown"
|
||||||
|
diagnostics.append(
|
||||||
|
f"{type(calibration).__name__} exposes no content fingerprint"
|
||||||
|
)
|
||||||
|
|
||||||
|
sensor_id = None
|
||||||
|
if device_id is not None:
|
||||||
|
text = str(device_id).strip()
|
||||||
|
sensor_id = text or None
|
||||||
|
if sensor_id is None:
|
||||||
|
diagnostics.append("device_id was empty; no SN is bound")
|
||||||
|
|
||||||
|
return SensorContext(
|
||||||
|
sensor_id=sensor_id,
|
||||||
|
product_family=product_family,
|
||||||
|
processing_backend=processing_backend,
|
||||||
|
config_name=str(config_name),
|
||||||
|
config_source=config_source,
|
||||||
|
config_fingerprint=config_fingerprint,
|
||||||
|
bundle_root=bundle_root,
|
||||||
|
manifest_digest=manifest_digest,
|
||||||
|
assets=assets,
|
||||||
|
calibration_path=calibration_path,
|
||||||
|
calibration_fingerprint=calibration_fingerprint,
|
||||||
|
diagnostics=tuple(diagnostics),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fallback_sensor_context(
|
||||||
|
*,
|
||||||
|
config_name: str | Path,
|
||||||
|
config: Mapping[str, Any] | None,
|
||||||
|
device_id: str | None,
|
||||||
|
reason: BaseException,
|
||||||
|
) -> SensorContext:
|
||||||
|
"""A minimal context used when binding itself fails unexpectedly.
|
||||||
|
|
||||||
|
The Sensor keeps working; the failure and its cause are visible on the
|
||||||
|
context instead of crashing a published construction path.
|
||||||
|
"""
|
||||||
|
config_fingerprint, _ = _json_digest(
|
||||||
|
dict(config) if isinstance(config, Mapping) else {}
|
||||||
|
)
|
||||||
|
backend = (
|
||||||
|
backend_from_config(config) if isinstance(config, Mapping) else "single_camera"
|
||||||
|
)
|
||||||
|
sensor_id = str(device_id).strip() if device_id is not None else None
|
||||||
|
return SensorContext(
|
||||||
|
sensor_id=sensor_id or None,
|
||||||
|
product_family=None,
|
||||||
|
processing_backend=backend,
|
||||||
|
config_name=str(config_name),
|
||||||
|
config_source=None,
|
||||||
|
config_fingerprint=config_fingerprint,
|
||||||
|
bundle_root=None,
|
||||||
|
manifest_digest=None,
|
||||||
|
assets=(),
|
||||||
|
calibration_path=None,
|
||||||
|
calibration_fingerprint="none",
|
||||||
|
diagnostics=(f"context binding failed: {type(reason).__name__}: {reason}",),
|
||||||
|
)
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""``resolve_config_source`` and ``load_config`` must agree on what is read.
|
||||||
|
|
||||||
|
``SensorContext.config_source`` promises the JSON file that was actually loaded.
|
||||||
|
That promise only holds while the recorded lookup and the loading lookup are the
|
||||||
|
same implementation, so this module checks it against every installed config
|
||||||
|
name, the alias table, an explicit path and the legacy ``"name"`` field, and
|
||||||
|
pins the preserved error types/messages for a missing or non-JSON input.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from orisys import configs
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SENSORS = REPO_ROOT / "sensors"
|
||||||
|
|
||||||
|
|
||||||
|
def _read(path: str | Path) -> dict:
|
||||||
|
return json.loads(Path(path).read_text(encoding="utf-8-sig"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_listed_name_resolves_to_the_file_load_config_reads() -> None:
|
||||||
|
names = configs.list_config()
|
||||||
|
assert names
|
||||||
|
for name in names:
|
||||||
|
source = configs.resolve_config_source(name)
|
||||||
|
assert source is not None, name
|
||||||
|
assert _read(source) == configs.load_config(name), name
|
||||||
|
# The recorded source can be loaded directly with the same result.
|
||||||
|
assert configs.load_config(source) == configs.load_config(name), name
|
||||||
|
|
||||||
|
|
||||||
|
def test_aliases_resolve_like_loading() -> None:
|
||||||
|
aliases = dict(configs.CONFIG_ALIASES)
|
||||||
|
assert aliases
|
||||||
|
for alias, canonical in aliases.items():
|
||||||
|
source = configs.resolve_config_source(alias)
|
||||||
|
assert source is not None, alias
|
||||||
|
assert _read(source) == configs.load_config(alias), alias
|
||||||
|
canonical_source = configs.resolve_config_source(canonical)
|
||||||
|
if canonical_source is not None:
|
||||||
|
assert source == canonical_source, alias
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_paths_resolve_to_themselves(tmp_path: Path) -> None:
|
||||||
|
bundle_config = SENSORS / "box" / "sensor.json"
|
||||||
|
assert configs.resolve_config_source(bundle_config) == bundle_config.resolve()
|
||||||
|
assert configs.load_config(bundle_config) == configs.load_config("box")
|
||||||
|
|
||||||
|
copied = tmp_path / "copied-config.json"
|
||||||
|
copied.write_text(bundle_config.read_text(encoding="utf-8"), encoding="utf-8")
|
||||||
|
assert configs.resolve_config_source(copied) == copied.resolve()
|
||||||
|
assert configs.load_config(copied) == configs.load_config(bundle_config)
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_name_field_scan_agrees_with_loading() -> None:
|
||||||
|
"""A config's JSON ``"name"`` may differ from its file stem (``prototype``)."""
|
||||||
|
found = False
|
||||||
|
for name in configs.list_config():
|
||||||
|
source = configs.resolve_config_source(name)
|
||||||
|
if source is None:
|
||||||
|
continue
|
||||||
|
field = _read(source).get("name")
|
||||||
|
if (
|
||||||
|
isinstance(field, str)
|
||||||
|
and field
|
||||||
|
and field not in configs.list_config()
|
||||||
|
):
|
||||||
|
resolved = configs.resolve_config_source(field)
|
||||||
|
assert resolved is not None, field
|
||||||
|
assert _read(resolved) == configs.load_config(field), field
|
||||||
|
found = True
|
||||||
|
assert found, "expected at least one legacy name field in the installed configs"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_and_non_json_inputs_keep_their_error_contract(tmp_path: Path) -> None:
|
||||||
|
assert configs.resolve_config_source("no-such-config-name") is None
|
||||||
|
with pytest.raises(FileNotFoundError, match="Input config file not found"):
|
||||||
|
configs.load_config("no-such-config-name")
|
||||||
|
|
||||||
|
absolute_missing = tmp_path / "missing.json"
|
||||||
|
assert configs.resolve_config_source(absolute_missing) is None
|
||||||
|
with pytest.raises(FileNotFoundError, match="not found at absolute path"):
|
||||||
|
configs.load_config(absolute_missing)
|
||||||
|
|
||||||
|
not_json = tmp_path / "config.txt"
|
||||||
|
not_json.write_text("{}", encoding="utf-8")
|
||||||
|
# The source lookup names the existing file; loading still refuses a non-JSON
|
||||||
|
# file with the historical ValueError instead of silently reading it.
|
||||||
|
assert configs.resolve_config_source(not_json) == not_json.resolve()
|
||||||
|
with pytest.raises(ValueError, match="must be a JSON file"):
|
||||||
|
configs.load_config(not_json)
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
"""Read-only SensorContext (T04): SN/config/assets/cal_path bound once per instance.
|
||||||
|
|
||||||
|
The context is the stage-1 answer to "which physical sensor is this, which
|
||||||
|
configuration and assets does it run, and which force calibration was selected".
|
||||||
|
These tests protect the two properties the task names: the binding happens once
|
||||||
|
from explicit inputs (no SN guessed from enumeration order), and two Sensor
|
||||||
|
instances do not share or overwrite each other's context. The identity itself is
|
||||||
|
built through the frozen T03 ``FrameIdentity``, not a second shape.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from orisys import Sensor, configs
|
||||||
|
from orisys.sensors import SensorContext, build_sensor_context
|
||||||
|
from orisys.stream_contract import FrameIdentity
|
||||||
|
|
||||||
|
# The shipped box calibration has no declared identity; the device tests add one,
|
||||||
|
# exactly like an operator-managed <product>/<sn>.json file.
|
||||||
|
BOX_CALIBRATION = Path(configs.resolve_sensor_bundle("box").force_calibration_json)
|
||||||
|
BOX_CONFIG = Path(configs.resolve_sensor_bundle("box").sensor_json)
|
||||||
|
|
||||||
|
|
||||||
|
def _device_calibration(root: Path, sn: str, *, family: str = "box") -> Path:
|
||||||
|
"""Write a device calibration whose declared SN is ``sn``."""
|
||||||
|
document = json.loads(BOX_CALIBRATION.read_text(encoding="utf-8-sig"))
|
||||||
|
document["identity"] = {"sensor_type": family, "sn": sn}
|
||||||
|
path = root / family / f"{sn}.json"
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(document), encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _external_sensor(**kwargs) -> Sensor:
|
||||||
|
options = {"verbose": False, "backend": "cpu"}
|
||||||
|
options.update(kwargs)
|
||||||
|
return Sensor("external", **options)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ explicit binding
|
||||||
|
def test_context_binds_the_explicit_sn_config_and_calibration_path(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
root = tmp_path / "calibrations"
|
||||||
|
calibration = _device_calibration(root, "BOX-001")
|
||||||
|
sensor = _external_sensor(
|
||||||
|
config_name="box", calibration_dir=root, device_id="BOX-001"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
context = sensor.context
|
||||||
|
assert isinstance(context, SensorContext)
|
||||||
|
assert context.sensor_id == "BOX-001"
|
||||||
|
assert sensor.device_id == "BOX-001"
|
||||||
|
assert context.product_family == "box"
|
||||||
|
assert context.config_name == "box"
|
||||||
|
assert context.config_source == str(BOX_CONFIG.resolve())
|
||||||
|
assert len(context.config_fingerprint) == 64
|
||||||
|
assert context.bundle_root == str(BOX_CONFIG.parent.resolve())
|
||||||
|
assert context.calibration_path == str(calibration.resolve())
|
||||||
|
assert context.calibration_fingerprint == sensor.calibration.fingerprint
|
||||||
|
assert context.diagnostics == ()
|
||||||
|
# The published attributes still say what they said before T04.
|
||||||
|
assert sensor.calibration.source_path == calibration.resolve()
|
||||||
|
assert sensor.force_unit == "N"
|
||||||
|
finally:
|
||||||
|
sensor.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_never_guesses_an_sn_from_enumeration_order(tmp_path: Path) -> None:
|
||||||
|
"""No explicit SN means no identity; the stream header refuses to invent one."""
|
||||||
|
first = _external_sensor(config_name="box")
|
||||||
|
second = _external_sensor(config_name="box")
|
||||||
|
try:
|
||||||
|
assert first.context.sensor_id is None
|
||||||
|
assert second.context.sensor_id is None
|
||||||
|
# Same configuration and bundle, so the non-identity binding is stable.
|
||||||
|
assert first.context.config_fingerprint == second.context.config_fingerprint
|
||||||
|
assert first.context.bundle_root == second.context.bundle_root
|
||||||
|
with pytest.raises(ValueError, match="explicit sensor_id"):
|
||||||
|
first.context.frame_identity(
|
||||||
|
session_id="run-1", frame_id=0, source_frame_id=None
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
first.disconnect()
|
||||||
|
second.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_instances_keep_separate_contexts(tmp_path: Path) -> None:
|
||||||
|
root_one = tmp_path / "one"
|
||||||
|
root_two = tmp_path / "two"
|
||||||
|
_device_calibration(root_one, "BOX-001")
|
||||||
|
_device_calibration(root_two, "BOX-002")
|
||||||
|
first = _external_sensor(
|
||||||
|
config_name="box", calibration_dir=root_one, device_id="BOX-001"
|
||||||
|
)
|
||||||
|
second = _external_sensor(
|
||||||
|
config_name="box", calibration_dir=root_two, device_id="BOX-002"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
first_context = first.context
|
||||||
|
first_identity = first_context.frame_identity(
|
||||||
|
session_id="run-1", frame_id=0, source_frame_id=None
|
||||||
|
)
|
||||||
|
second_context = second.context
|
||||||
|
second_identity = second_context.frame_identity(
|
||||||
|
session_id="run-1", frame_id=0, source_frame_id=None
|
||||||
|
)
|
||||||
|
assert first_context != second_context
|
||||||
|
assert first_identity != second_identity
|
||||||
|
assert first_identity.sensor_id == "BOX-001"
|
||||||
|
assert second_identity.sensor_id == "BOX-002"
|
||||||
|
# Building the second Sensor did not mutate the first context.
|
||||||
|
assert first.context == first_context
|
||||||
|
assert first.context.calibration_path != second_context.calibration_path
|
||||||
|
assert isinstance(first_identity, FrameIdentity)
|
||||||
|
finally:
|
||||||
|
first.disconnect()
|
||||||
|
second.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_is_frozen_read_only_value_data(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "calibrations"
|
||||||
|
_device_calibration(root, "BOX-001")
|
||||||
|
sensor = _external_sensor(
|
||||||
|
config_name="box", calibration_dir=root, device_id="BOX-001"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
context = sensor.context
|
||||||
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
|
context.sensor_id = "BOX-999" # type: ignore[misc]
|
||||||
|
payload = context.as_dict()
|
||||||
|
assert payload["sensor_id"] == "BOX-001"
|
||||||
|
assert isinstance(payload["assets"], list)
|
||||||
|
finally:
|
||||||
|
sensor.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibration_swap_replaces_the_context_atomically(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "calibrations"
|
||||||
|
first_cal = _device_calibration(root, "BOX-001")
|
||||||
|
# The same device SN, a different calibration document: a live save always
|
||||||
|
# keeps the device identity, which is what a real recalibration does.
|
||||||
|
variant = root / "box" / "BOX-001-variant.json"
|
||||||
|
document = json.loads(BOX_CALIBRATION.read_text(encoding="utf-8-sig"))
|
||||||
|
document["identity"] = {"sensor_type": "box", "sn": "BOX-001"}
|
||||||
|
# A real recalibration is a different document; changing one point keeps the
|
||||||
|
# curve valid while making the content fingerprint different.
|
||||||
|
document["channels"]["normal"]["points"][1][1] = 0.3771
|
||||||
|
variant.write_text(json.dumps(document), encoding="utf-8")
|
||||||
|
sensor = _external_sensor(
|
||||||
|
config_name="box", calibration_dir=root, device_id="BOX-001"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
before = sensor.context
|
||||||
|
assert before.calibration_path == str(first_cal.resolve())
|
||||||
|
|
||||||
|
# The published live-swap entry stays available; the calibration identity
|
||||||
|
# must still match the bound SN.
|
||||||
|
sensor.set_calibration(variant)
|
||||||
|
after = sensor.context
|
||||||
|
assert after is not before
|
||||||
|
assert after.sensor_id == before.sensor_id == "BOX-001"
|
||||||
|
assert after.config_fingerprint == before.config_fingerprint
|
||||||
|
assert after.bundle_root == before.bundle_root
|
||||||
|
assert after.calibration_path == str(variant.resolve())
|
||||||
|
assert after.calibration_fingerprint == sensor.calibration.fingerprint
|
||||||
|
assert after.calibration_fingerprint != before.calibration_fingerprint
|
||||||
|
|
||||||
|
sensor.set_calibration(None)
|
||||||
|
assert sensor.context.calibration_path is None
|
||||||
|
assert sensor.context.calibration_fingerprint == "none"
|
||||||
|
assert sensor.force_unit == "raw"
|
||||||
|
finally:
|
||||||
|
sensor.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def test_clearing_a_calibration_loaded_from_a_path_reports_no_path() -> None:
|
||||||
|
"""The context describes the calibration in effect, not the path ever requested.
|
||||||
|
|
||||||
|
Constructing with an explicit ``cal_path`` then clearing the calibration must
|
||||||
|
leave no calibration path behind: the old path would otherwise be recorded as
|
||||||
|
if that calibration still shaped the measurements.
|
||||||
|
"""
|
||||||
|
sensor = _external_sensor(config_name="box", cal_path=str(BOX_CALIBRATION))
|
||||||
|
try:
|
||||||
|
assert sensor.context.calibration_path == str(BOX_CALIBRATION.resolve())
|
||||||
|
assert sensor.force_unit == "N"
|
||||||
|
|
||||||
|
sensor.set_calibration(None)
|
||||||
|
assert sensor.calibration is None
|
||||||
|
assert sensor.context.calibration_path is None
|
||||||
|
assert sensor.context.calibration_fingerprint == "none"
|
||||||
|
assert sensor.force_unit == "raw"
|
||||||
|
finally:
|
||||||
|
sensor.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def test_binding_failure_is_reported_without_breaking_construction(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""A context problem must not turn a previously constructible Sensor into an error."""
|
||||||
|
import orisys.sensors.context as context_module
|
||||||
|
|
||||||
|
def broken(**kwargs):
|
||||||
|
raise RuntimeError("bundle exploded")
|
||||||
|
|
||||||
|
monkeypatch.setattr(context_module, "build_sensor_context", broken)
|
||||||
|
sensor = _external_sensor(config_name="box")
|
||||||
|
try:
|
||||||
|
assert sensor.context.sensor_id is None
|
||||||
|
assert sensor.context.config_name == "box"
|
||||||
|
assert sensor.context.diagnostics == (
|
||||||
|
"context binding failed: RuntimeError: bundle exploded",
|
||||||
|
)
|
||||||
|
assert sensor.context.calibration_fingerprint == "none"
|
||||||
|
finally:
|
||||||
|
sensor.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------- configuration by path/name
|
||||||
|
def test_config_binding_follows_the_actual_loaded_file(tmp_path: Path) -> None:
|
||||||
|
custom = tmp_path / "custom-box.json"
|
||||||
|
custom.write_text(BOX_CONFIG.read_text(encoding="utf-8"), encoding="utf-8")
|
||||||
|
sensor = _external_sensor(config_name=str(custom))
|
||||||
|
try:
|
||||||
|
context = sensor.context
|
||||||
|
assert context.config_source == str(custom.resolve())
|
||||||
|
assert context.config_name == str(custom)
|
||||||
|
assert context.product_family is None
|
||||||
|
assert context.bundle_root is None
|
||||||
|
assert context.assets == ()
|
||||||
|
assert any("no additive sensor bundle" in line for line in context.diagnostics)
|
||||||
|
finally:
|
||||||
|
sensor.disconnect()
|
||||||
|
|
||||||
|
# The recorded source is the file load_config() actually reads.
|
||||||
|
assert configs.resolve_config_source("box") == BOX_CONFIG.resolve()
|
||||||
|
assert configs.resolve_config_source(custom) == custom.resolve()
|
||||||
|
assert configs.resolve_config_source("no-such-config-name") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_configuration_has_the_same_config_fingerprint(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "calibrations"
|
||||||
|
_device_calibration(root, "BOX-001")
|
||||||
|
first = _external_sensor(config_name="box")
|
||||||
|
second = _external_sensor(
|
||||||
|
config_name="box", calibration_dir=root, device_id="BOX-001"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
assert first.context.config_fingerprint == second.context.config_fingerprint
|
||||||
|
assert (
|
||||||
|
first.context.processing_backend
|
||||||
|
== second.context.processing_backend
|
||||||
|
== "compound_eye"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
first.disconnect()
|
||||||
|
second.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- asset binding
|
||||||
|
def test_bundle_assets_are_bound_as_relative_paths_once() -> None:
|
||||||
|
finger_config = configs.load_config("finger")
|
||||||
|
context = build_sensor_context(
|
||||||
|
config_name="finger",
|
||||||
|
config=finger_config,
|
||||||
|
device_id="FINGER-001",
|
||||||
|
bundle_candidates=["finger"],
|
||||||
|
)
|
||||||
|
assert context.sensor_id == "FINGER-001"
|
||||||
|
assert context.product_family == "finger"
|
||||||
|
assert context.bundle_root is not None
|
||||||
|
assert context.manifest_digest is not None and len(context.manifest_digest) == 64
|
||||||
|
roles = {asset.role for asset in context.assets}
|
||||||
|
assert "assets_dir" in roles
|
||||||
|
root = Path(context.bundle_root)
|
||||||
|
for asset in context.assets:
|
||||||
|
assert not Path(asset.path).is_absolute()
|
||||||
|
assert (root / asset.path).parent.is_dir()
|
||||||
|
|
||||||
|
# Two bindings of the same bundle describe the same immutable assets.
|
||||||
|
again = build_sensor_context(
|
||||||
|
config_name="finger", config=finger_config, bundle_candidates=["finger"]
|
||||||
|
)
|
||||||
|
assert again.assets == context.assets
|
||||||
|
assert again.manifest_digest == context.manifest_digest
|
||||||
|
assert again.config_fingerprint == context.config_fingerprint
|
||||||
Reference in New Issue
Block a user