fixed calibration data not loading due to path
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 2,
|
||||||
"unit": "N",
|
"unit": "N",
|
||||||
"channels": {
|
"channels": {
|
||||||
"normal": {
|
"normal": {
|
||||||
@@ -14,8 +14,22 @@
|
|||||||
[62000.0, 2.377]
|
[62000.0, 2.377]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"shear": {
|
"shear_x": {
|
||||||
"method": "piecewise_linear",
|
"method": "piecewise_linear",
|
||||||
|
"raw_key": "fshearx",
|
||||||
|
"input": "components",
|
||||||
|
"signed": true,
|
||||||
|
"extrapolate": true,
|
||||||
|
"clamp_output_min": 0.0,
|
||||||
|
"points": [
|
||||||
|
[1000.0, 0.0],
|
||||||
|
[36000.0, 1.19],
|
||||||
|
[68000.0, 2.06]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"shear_y": {
|
||||||
|
"method": "piecewise_linear",
|
||||||
|
"raw_key": "fsheary",
|
||||||
"input": "components",
|
"input": "components",
|
||||||
"signed": true,
|
"signed": true,
|
||||||
"extrapolate": true,
|
"extrapolate": true,
|
||||||
|
|||||||
+144
-22
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
|
import sys
|
||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from importlib.resources import as_file, files
|
from importlib.resources import as_file, files
|
||||||
@@ -25,29 +26,116 @@ def _resource_path(*parts: str) -> Path | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _candidate_config_dirs() -> list[Path]:
|
def _unique_paths(paths) -> list[Path]:
|
||||||
"""
|
"""Ordered, de-duplicated paths (Windows comparison is case-insensitive)."""
|
||||||
Candidate config directories for editable installs and packaged wheels.
|
|
||||||
"""
|
|
||||||
here = Path(__file__).resolve().parent
|
|
||||||
candidates = [
|
|
||||||
here / "configs", # packaged wheel layout: orisys/configs/*.json
|
|
||||||
here.parent.parent / "config", # editable install from repo: repo_root/config/*.json
|
|
||||||
here.parent / "config", # fallback
|
|
||||||
Path.cwd() / "config", # script launched from repo root
|
|
||||||
]
|
|
||||||
|
|
||||||
unique: list[Path] = []
|
unique: list[Path] = []
|
||||||
for path in candidates:
|
seen: set[str] = set()
|
||||||
if path not in unique:
|
for path in paths:
|
||||||
|
try:
|
||||||
|
key = str(path.resolve()).casefold()
|
||||||
|
except OSError: # pragma: no cover - defensive: unresolvable path
|
||||||
|
key = str(path).casefold()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
unique.append(path)
|
unique.append(path)
|
||||||
return unique
|
return unique
|
||||||
|
|
||||||
|
|
||||||
def _candidate_sensor_roots() -> list[Path]:
|
def _is_sdk_runtime_root(path: Path) -> bool:
|
||||||
"""Candidate roots that contain per-sensor bundle folders."""
|
"""Whether ``path`` carries the runnable SDK layout.
|
||||||
|
|
||||||
|
The anchor is the **folder layout**, never the folder's name: a distribution
|
||||||
|
folder may be called anything (a release checkout, a renamed copy, an
|
||||||
|
unpacked zip), but it always ships ``examples/`` beside ``sensors/``. That is
|
||||||
|
what distinguishes a run root from an arbitrary directory, and it is why a
|
||||||
|
renamed root keeps working while ``dist/**`` (``sensors/`` without
|
||||||
|
``examples/``) is not mistaken for one.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not path.is_dir():
|
||||||
|
return False
|
||||||
|
return (path / "examples").is_dir() and (path / "sensors").is_dir()
|
||||||
|
except OSError: # pragma: no cover - defensive: unreadable path
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_roots() -> list[Path]:
|
||||||
|
"""Runnable SDK roots for this process, nearest to the launcher first.
|
||||||
|
|
||||||
|
Evaluated once at import time, so a process that changes directory later
|
||||||
|
keeps the answer it started with.
|
||||||
|
"""
|
||||||
|
anchors: list[Path] = []
|
||||||
|
|
||||||
|
def add(path: Path) -> None:
|
||||||
|
if path not in anchors:
|
||||||
|
anchors.append(path)
|
||||||
|
|
||||||
|
cwd = Path.cwd()
|
||||||
|
add(cwd)
|
||||||
|
|
||||||
|
# A script run from a subdirectory (``<root>/examples/foo.py``, or a caller
|
||||||
|
# that launched from ``<root>/scripts``) still names its root through argv[0].
|
||||||
|
script_dir: Path | None = None
|
||||||
|
try:
|
||||||
|
argv0 = sys.argv[0] if sys.argv else ""
|
||||||
|
if argv0:
|
||||||
|
candidate = Path(argv0).expanduser()
|
||||||
|
script_dir = candidate.resolve().parent if candidate.is_file() else None
|
||||||
|
except (OSError, ValueError): # pragma: no cover - defensive
|
||||||
|
script_dir = None
|
||||||
|
|
||||||
|
# Walk up from both, so the root is found however deep the launcher sits.
|
||||||
|
for start in (cwd, script_dir):
|
||||||
|
if start is None:
|
||||||
|
continue
|
||||||
|
for candidate in (start, *start.parents):
|
||||||
|
add(candidate)
|
||||||
|
|
||||||
|
# Roots that put themselves on sys.path (``python <root>/examples/foo.py``
|
||||||
|
# with the root as cwd, ``PYTHONPATH=<root>``, and similar).
|
||||||
|
for entry in list(sys.path):
|
||||||
|
if not entry:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
add(Path(entry).expanduser())
|
||||||
|
except (OSError, ValueError): # pragma: no cover - defensive
|
||||||
|
continue
|
||||||
|
|
||||||
|
return [path for path in _unique_paths(anchors) if _is_sdk_runtime_root(path)]
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_config_dirs() -> list[Path]:
|
||||||
|
"""Candidate config directories, the running SDK root first.
|
||||||
|
|
||||||
|
A run root's ``config/`` outranks the packaged copy so a bundle's
|
||||||
|
``sensor.json`` and its ``force_calibration.json`` come from the same place.
|
||||||
|
"""
|
||||||
here = Path(__file__).resolve().parent
|
here = Path(__file__).resolve().parent
|
||||||
candidates = []
|
return _unique_paths([
|
||||||
|
*(root / "config" for root in _runtime_roots()),
|
||||||
|
here / "configs", # packaged wheel layout: orisys/configs/*.json
|
||||||
|
here.parent.parent / "config", # editable install from repo: repo_root/config/*.json
|
||||||
|
here.parent / "config", # fallback
|
||||||
|
Path.cwd() / "config", # script launched from repo root
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_sensor_roots() -> list[Path]:
|
||||||
|
"""Candidate roots that contain per-sensor bundle folders.
|
||||||
|
|
||||||
|
Lookup order:
|
||||||
|
|
||||||
|
1. the ``sensors/`` of every runnable SDK root found at startup, nearest
|
||||||
|
first -- so the bundle under the directory that is actually running wins,
|
||||||
|
whatever that directory is called;
|
||||||
|
2. the packaged resource ``orisys/sensors`` (wheel installs);
|
||||||
|
3. the module-relative candidates (editable checkout, packaged tree);
|
||||||
|
4. the current working directory, last, as a safety net.
|
||||||
|
"""
|
||||||
|
here = Path(__file__).resolve().parent
|
||||||
|
candidates = [root / "sensors" for root in _runtime_roots()]
|
||||||
packaged = _resource_path("sensors")
|
packaged = _resource_path("sensors")
|
||||||
if packaged is not None:
|
if packaged is not None:
|
||||||
candidates.append(packaged) # packaged wheel layout: orisys/sensors/
|
candidates.append(packaged) # packaged wheel layout: orisys/sensors/
|
||||||
@@ -57,11 +145,7 @@ def _candidate_sensor_roots() -> list[Path]:
|
|||||||
here.parent / "sensors",
|
here.parent / "sensors",
|
||||||
Path.cwd() / "sensors",
|
Path.cwd() / "sensors",
|
||||||
])
|
])
|
||||||
unique: list[Path] = []
|
return _unique_paths(candidates)
|
||||||
for path in candidates:
|
|
||||||
if path not in unique:
|
|
||||||
unique.append(path)
|
|
||||||
return unique
|
|
||||||
|
|
||||||
|
|
||||||
def _get_config_dir() -> Path:
|
def _get_config_dir() -> Path:
|
||||||
@@ -130,6 +214,44 @@ def resolve_sensor_bundle(name: str) -> SensorBundle | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def describe_bundle_resolution(name: str) -> dict:
|
||||||
|
"""Explain how ``name`` resolved, so a missing calibration stops being silent.
|
||||||
|
|
||||||
|
A bundle can resolve and still carry no ``force_calibration.json``: the
|
||||||
|
lookup stops at the first root that has ``<name>/sensor.json`` and never
|
||||||
|
asks whether that root also has the coefficients. Reporting which bundle was
|
||||||
|
found -- and which roots were passed over -- is what makes that case
|
||||||
|
distinguishable from "no bundle at all", which is otherwise what the caller
|
||||||
|
sees.
|
||||||
|
|
||||||
|
Returns ``name``, the ``searched`` roots in order, and the first root that
|
||||||
|
supplied ``<name>/sensor.json`` together with whether it carried a
|
||||||
|
``force_calibration.json``.
|
||||||
|
"""
|
||||||
|
requested = CONFIG_ALIASES.get(str(name), str(name))
|
||||||
|
searched = _candidate_sensor_roots()
|
||||||
|
for root in searched:
|
||||||
|
sensor_json = root / requested / "sensor.json"
|
||||||
|
if not sensor_json.is_file():
|
||||||
|
continue
|
||||||
|
return {
|
||||||
|
"name": requested,
|
||||||
|
"searched": searched,
|
||||||
|
"found_root": sensor_json.parent,
|
||||||
|
"sensor_json": sensor_json,
|
||||||
|
"force_calibration_json": (
|
||||||
|
sensor_json.parent / "force_calibration.json"
|
||||||
|
).is_file(),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"name": requested,
|
||||||
|
"searched": searched,
|
||||||
|
"found_root": None,
|
||||||
|
"sensor_json": None,
|
||||||
|
"force_calibration_json": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def resolve_sensor_config_path(name: str) -> Path | None:
|
def resolve_sensor_config_path(name: str) -> Path | None:
|
||||||
"""Return ``sensor.json`` for a resolved sensor bundle."""
|
"""Return ``sensor.json`` for a resolved sensor bundle."""
|
||||||
bundle = resolve_sensor_bundle(name)
|
bundle = resolve_sensor_bundle(name)
|
||||||
|
|||||||
+60
-4
@@ -217,6 +217,9 @@ class Sensor:
|
|||||||
self._force_raw = (0.0, 0.0, 0.0)
|
self._force_raw = (0.0, 0.0, 0.0)
|
||||||
self.calibration: Optional[cal_mod.Calibration] = None
|
self.calibration: Optional[cal_mod.Calibration] = None
|
||||||
self._force_cal_sensor_name: Optional[str] = None
|
self._force_cal_sensor_name: Optional[str] = None
|
||||||
|
#: Why no calibration was applied, when a bundle existed but carried no
|
||||||
|
#: usable coefficients. ``None`` means the question never came up.
|
||||||
|
self._force_cal_attempts: Optional[list[dict]] = None
|
||||||
self._init_force_calibration()
|
self._init_force_calibration()
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
@@ -330,10 +333,11 @@ class Sensor:
|
|||||||
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:
|
||||||
print(f"Force calibration: {self.calibration.source_path}")
|
print(f"Force calibration: {self.calibration.source_path}")
|
||||||
elif self._force_cal_sensor_name:
|
elif self._force_cal_attempts is not None:
|
||||||
print(
|
# A bundle was resolved for this sensor type, so "file missing"
|
||||||
f"Force calibration: sensors/{self._force_cal_sensor_name}/force_calibration.json"
|
# would be the wrong story: name the bundle and the roots that
|
||||||
)
|
# were searched instead.
|
||||||
|
print(self._force_calibration_note())
|
||||||
else:
|
else:
|
||||||
print("Force calibration: raw (no force_calibration.json)")
|
print("Force calibration: raw (no force_calibration.json)")
|
||||||
|
|
||||||
@@ -1258,22 +1262,71 @@ class Sensor:
|
|||||||
ordered.append(name)
|
ordered.append(name)
|
||||||
return ordered
|
return ordered
|
||||||
|
|
||||||
|
def _force_calibration_note(self) -> str:
|
||||||
|
"""Explain a bundle that resolved but carried no usable coefficients.
|
||||||
|
|
||||||
|
The old message ("raw (no force_calibration.json)") reads as "the file is
|
||||||
|
missing", which is exactly the wrong story when the lookup found a
|
||||||
|
bundle from another root and stopped there without ever asking whether
|
||||||
|
that root had coefficients.
|
||||||
|
"""
|
||||||
|
attempts = self._force_cal_attempts or []
|
||||||
|
found = [entry for entry in attempts if entry.get("found_root") is not None]
|
||||||
|
with_cal = [entry for entry in found if entry.get("force_calibration_json")]
|
||||||
|
chosen = (with_cal or found or attempts or [None])[0]
|
||||||
|
searched: list = []
|
||||||
|
for entry in attempts:
|
||||||
|
for root in entry.get("searched") or ():
|
||||||
|
if root not in searched:
|
||||||
|
searched.append(root)
|
||||||
|
lines: list[str] = []
|
||||||
|
if chosen is None:
|
||||||
|
lines.append("Force calibration: raw (no sensor bundle resolved)")
|
||||||
|
else:
|
||||||
|
root = chosen["found_root"]
|
||||||
|
if chosen.get("force_calibration_json"):
|
||||||
|
lines.append(
|
||||||
|
f"Force calibration: raw (a bundle was found at {root}, but its "
|
||||||
|
"force_calibration.json could not be loaded)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append(
|
||||||
|
f"Force calibration: raw (bundle found at {root} has no "
|
||||||
|
"force_calibration.json)"
|
||||||
|
)
|
||||||
|
if searched:
|
||||||
|
lines.append(
|
||||||
|
" roots searched: " + "; ".join(str(root) for root in searched)
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
def _init_force_calibration(self) -> None:
|
def _init_force_calibration(self) -> None:
|
||||||
"""Load force calibration from ``cal_path`` or the product bundle."""
|
"""Load force calibration from ``cal_path`` or the product bundle."""
|
||||||
self.calibration = None
|
self.calibration = None
|
||||||
self._force_cal_sensor_name = None
|
self._force_cal_sensor_name = None
|
||||||
|
self._force_cal_attempts = None
|
||||||
|
|
||||||
if self.cal_path is not None and str(self.cal_path).strip():
|
if self.cal_path is not None and str(self.cal_path).strip():
|
||||||
# Explicit path: missing/invalid raises (no silent fallback).
|
# Explicit path: missing/invalid raises (no silent fallback).
|
||||||
self.calibration = cal_mod.load(self.cal_path, strict=True)
|
self.calibration = cal_mod.load(self.cal_path, strict=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
attempts: list[dict] = []
|
||||||
for name in self._bundle_name_candidates():
|
for name in self._bundle_name_candidates():
|
||||||
cal = cal_mod.load(sensor_type=name)
|
cal = cal_mod.load(sensor_type=name)
|
||||||
if cal is not None:
|
if cal is not None:
|
||||||
self.calibration = cal
|
self.calibration = cal
|
||||||
self._force_cal_sensor_name = name
|
self._force_cal_sensor_name = name
|
||||||
|
self._force_cal_attempts = None
|
||||||
return
|
return
|
||||||
|
try:
|
||||||
|
attempts.append(configs.describe_bundle_resolution(name))
|
||||||
|
except Exception: # noqa: BLE001 - diagnostics must never break loading
|
||||||
|
continue
|
||||||
|
# Deliberately NOT setting ``_force_cal_sensor_name``: callers read a
|
||||||
|
# non-None value there as "this sensor is calibrated", and a bundle
|
||||||
|
# without coefficients is not that.
|
||||||
|
self._force_cal_attempts = attempts or None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def force_calibrated(self) -> bool:
|
def force_calibrated(self) -> bool:
|
||||||
@@ -1292,11 +1345,14 @@ class Sensor:
|
|||||||
if cal is None:
|
if cal is None:
|
||||||
self.calibration = None
|
self.calibration = None
|
||||||
self._force_cal_sensor_name = None
|
self._force_cal_sensor_name = None
|
||||||
|
self._force_cal_attempts = None
|
||||||
return
|
return
|
||||||
if isinstance(cal, cal_mod.Calibration):
|
if isinstance(cal, cal_mod.Calibration):
|
||||||
self.calibration = cal
|
self.calibration = cal
|
||||||
|
self._force_cal_attempts = None
|
||||||
return
|
return
|
||||||
self.calibration = cal_mod.load(cal, strict=True)
|
self.calibration = cal_mod.load(cal, strict=True)
|
||||||
|
self._force_cal_attempts = None
|
||||||
|
|
||||||
def _apply_force_filter(self):
|
def _apply_force_filter(self):
|
||||||
pre_filter = np.asarray(self._force_raw, dtype=np.float64)
|
pre_filter = np.asarray(self._force_raw, dtype=np.float64)
|
||||||
|
|||||||
@@ -243,8 +243,84 @@ def test_missing_cal_path_raises():
|
|||||||
|
|
||||||
def test_parse_shipped_box_file_directly():
|
def test_parse_shipped_box_file_directly():
|
||||||
cal = parse(json.loads(_box_cal_path().read_text(encoding="utf-8")), source_path=_box_cal_path())
|
cal = parse(json.loads(_box_cal_path().read_text(encoding="utf-8")), source_path=_box_cal_path())
|
||||||
assert cal.schema_version == 1
|
assert cal.schema_version == 2
|
||||||
assert cal.convert(10000.0, 36000.0, -36000.0) == pytest.approx((0.377, 1.19, -1.19))
|
assert cal.convert(10000.0, 36000.0, -36000.0) == pytest.approx((0.377, 1.19, -1.19))
|
||||||
|
# The shipped file names both shear axes outright: no shared block, no clone.
|
||||||
|
assert set(cal.channels) == {"fn", "fx", "fy"}
|
||||||
|
fx, fy = cal.get("FX"), cal.get("FY")
|
||||||
|
assert fx is not None and fy is not None and fx is not fy
|
||||||
|
for curve in (fx, fy):
|
||||||
|
assert curve.signed is True
|
||||||
|
assert curve.extrapolate is True
|
||||||
|
assert curve.clamp_output_min == pytest.approx(0.0)
|
||||||
|
assert len(curve.points) == 3
|
||||||
|
assert fx.raw_key == "fshearx"
|
||||||
|
assert fy.raw_key == "fsheary"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_shipped_box_shear_axes_can_drift_apart(tmp_path: Path):
|
||||||
|
"""The whole point of the split: FX and FY are independent curves.
|
||||||
|
|
||||||
|
A document with the same shape as the shipped one but different per-axis
|
||||||
|
points must apply each axis's own curve. Re-collapsing the axes into one
|
||||||
|
shared ``shear`` block makes this fail.
|
||||||
|
"""
|
||||||
|
doc = {
|
||||||
|
"schema_version": 2,
|
||||||
|
"unit": "N",
|
||||||
|
"channels": {
|
||||||
|
"normal": {
|
||||||
|
"method": "piecewise_linear",
|
||||||
|
"points": [[0.0, 0.0], [1000.0, 1.0]],
|
||||||
|
},
|
||||||
|
"shear_x": {
|
||||||
|
"method": "piecewise_linear",
|
||||||
|
"signed": True,
|
||||||
|
"input": "components",
|
||||||
|
"extrapolate": True,
|
||||||
|
"clamp_output_min": 0.0,
|
||||||
|
"points": [[0.0, 0.0], [500.0, 2.0]],
|
||||||
|
},
|
||||||
|
"shear_y": {
|
||||||
|
"method": "piecewise_linear",
|
||||||
|
"signed": True,
|
||||||
|
"input": "components",
|
||||||
|
"extrapolate": True,
|
||||||
|
"clamp_output_min": 0.0,
|
||||||
|
"points": [[0.0, 0.0], [500.0, 3.0]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
path = tmp_path / "split.json"
|
||||||
|
path.write_text(json.dumps(doc), encoding="utf-8")
|
||||||
|
cal = load(path)
|
||||||
|
assert cal is not None
|
||||||
|
assert cal.convert(0.0, 500.0, 500.0)[1:] == pytest.approx((2.0, 3.0))
|
||||||
|
assert cal.convert(0.0, -500.0, 500.0)[1:] == pytest.approx((-2.0, 3.0))
|
||||||
|
|
||||||
|
|
||||||
|
def test_out_of_range_readings_extend_instead_of_being_dropped():
|
||||||
|
"""Outside the calibrated range the nearest segment is extended.
|
||||||
|
|
||||||
|
This pins the "extend, never ignore, never reuse a previous coefficient"
|
||||||
|
behaviour of the shipped box file, so flipping ``extrapolate`` has to be a
|
||||||
|
deliberate edit that fails this test first.
|
||||||
|
"""
|
||||||
|
cal = load(sensor_type="box")
|
||||||
|
assert cal is not None
|
||||||
|
fx, fy, fn = cal.get("FX"), cal.get("FY"), cal.get("FN")
|
||||||
|
assert fx is not None and fy is not None and fn is not None
|
||||||
|
# 136000 = 2 x the top raw break (68000 -> 2.06): the last segment's slope is
|
||||||
|
# applied again, so the value keeps growing past the calibrated range.
|
||||||
|
(r0, n0), (r1, n1) = fx.points[-2], fx.points[-1]
|
||||||
|
extended = n1 + (n1 - n0) / (r1 - r0) * (136000.0 - r1)
|
||||||
|
assert extended > n1
|
||||||
|
assert fx.convert_component(136000.0) == pytest.approx(extended)
|
||||||
|
assert fy.convert_component(136000.0) == pytest.approx(extended)
|
||||||
|
# A negative beyond the range keeps its sign.
|
||||||
|
assert fx.convert_component(-136000.0) == pytest.approx(-extended)
|
||||||
|
# Below the first point the normal channel floors at clamp_output_min.
|
||||||
|
assert fn.convert(500.0) == pytest.approx(0.0)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------- filter fingerprint
|
# ------------------------------------------------------- filter fingerprint
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
from scipy.signal import butter, filtfilt
|
from scipy.signal import butter, filtfilt
|
||||||
|
|
||||||
from orisys import Sensor
|
from orisys import Sensor, configs
|
||||||
from orisys.signal_processing import (
|
from orisys.signal_processing import (
|
||||||
NORMAL_FORCE_CAL_OFFSET,
|
NORMAL_FORCE_CAL_OFFSET,
|
||||||
NORMAL_FORCE_CAL_SLOPE,
|
NORMAL_FORCE_CAL_SLOPE,
|
||||||
@@ -263,3 +265,156 @@ def test_configure_hhd_downsample_invalidates_solver_cache():
|
|||||||
sensor.configure(hhd_downsample="nope")
|
sensor.configure(hhd_downsample="nope")
|
||||||
finally:
|
finally:
|
||||||
sensor.disconnect()
|
sensor.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- bundle resolution anchors
|
||||||
|
#
|
||||||
|
# The bundle is chosen by the *running* root's folder layout, not by the root's
|
||||||
|
# name and not by whichever checkout happens to be importable. These build small
|
||||||
|
# real trees, because ``_runtime_roots`` asks the filesystem whether a directory
|
||||||
|
# carries ``examples/`` beside ``sensors/``.
|
||||||
|
_CAL_DOC = {
|
||||||
|
"schema_version": 2,
|
||||||
|
"unit": "N",
|
||||||
|
"channels": {
|
||||||
|
"normal": {"method": "piecewise_linear", "points": [[0.0, 0.0], [1000.0, 1.0]]},
|
||||||
|
"shear_x": {
|
||||||
|
"method": "piecewise_linear",
|
||||||
|
"signed": True,
|
||||||
|
"points": [[0.0, 0.0], [500.0, 2.0]],
|
||||||
|
},
|
||||||
|
"shear_y": {
|
||||||
|
"method": "piecewise_linear",
|
||||||
|
"signed": True,
|
||||||
|
"points": [[0.0, 0.0], [500.0, 3.0]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_root(base, *, name="anything", sensor_json=True, calibration=True):
|
||||||
|
"""A runnable SDK root: ``examples/`` beside ``sensors/<name>/``."""
|
||||||
|
root = base / name
|
||||||
|
(root / "examples").mkdir(parents=True, exist_ok=True)
|
||||||
|
if sensor_json:
|
||||||
|
(root / "examples" / "basic_pipeline.py").write_text("# demo\n", encoding="utf-8")
|
||||||
|
bundle = root / "sensors" / "mini"
|
||||||
|
bundle.mkdir(parents=True, exist_ok=True)
|
||||||
|
if sensor_json:
|
||||||
|
(bundle / "sensor.json").write_text(
|
||||||
|
json.dumps({"name": "prototype"}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
if calibration:
|
||||||
|
(bundle / "force_calibration.json").write_text(
|
||||||
|
json.dumps(_CAL_DOC), encoding="utf-8"
|
||||||
|
)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_root_anchor_keys_on_layout_not_on_the_folder_name(tmp_path):
|
||||||
|
named = _make_root(tmp_path, name="some-random-name")
|
||||||
|
assert configs._is_sdk_runtime_root(named) is True
|
||||||
|
|
||||||
|
no_examples = tmp_path / "dist-like"
|
||||||
|
(no_examples / "sensors" / "mini").mkdir(parents=True)
|
||||||
|
assert configs._is_sdk_runtime_root(no_examples) is False
|
||||||
|
|
||||||
|
neither = tmp_path / "plain"
|
||||||
|
neither.mkdir()
|
||||||
|
assert configs._is_sdk_runtime_root(neither) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_roots_walk_up_from_a_subdirectory(tmp_path, monkeypatch):
|
||||||
|
root = _make_root(tmp_path, name="renamed-release")
|
||||||
|
subdir = root / "examples"
|
||||||
|
monkeypatch.setattr(configs.Path, "cwd", classmethod(lambda cls: subdir))
|
||||||
|
roots = [str(path) for path in configs._runtime_roots()]
|
||||||
|
assert str(root) in roots
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_running_root_bundle_beats_a_calibration_less_checkout(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""The reported bug: a nearer bundle without coefficients must not win."""
|
||||||
|
running = _make_root(tmp_path, name="running-root")
|
||||||
|
checkout = _make_root(tmp_path, name="importable-checkout", calibration=False)
|
||||||
|
monkeypatch.setattr(configs, "_runtime_roots", lambda: [running, checkout])
|
||||||
|
|
||||||
|
bundle = configs.resolve_sensor_bundle("mini")
|
||||||
|
assert bundle is not None
|
||||||
|
assert bundle.root == (running / "sensors" / "mini").resolve()
|
||||||
|
assert bundle.force_calibration_json == (
|
||||||
|
running / "sensors" / "mini" / "force_calibration.json"
|
||||||
|
).resolve()
|
||||||
|
|
||||||
|
# The whole point: the running root's document is the one that reaches the
|
||||||
|
# sensor, split shear and all.
|
||||||
|
sensor = Sensor("external", config_name="mini", verbose=False, backend="cpu")
|
||||||
|
try:
|
||||||
|
assert sensor.force_calibrated is True
|
||||||
|
assert sensor.force_unit == "N"
|
||||||
|
assert sensor.calibration.source_path == (
|
||||||
|
running / "sensors" / "mini" / "force_calibration.json"
|
||||||
|
).resolve()
|
||||||
|
finally:
|
||||||
|
sensor.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_nearer_bundle_wins_even_without_coefficients(tmp_path, monkeypatch):
|
||||||
|
"""Nearest bundle wins, so a calibration-less nearer one hides a farther one.
|
||||||
|
|
||||||
|
That is the chosen precedence (the running root is authoritative), and the
|
||||||
|
diagnostic exists precisely so this stops being silent: this is the shape of
|
||||||
|
the original report, where a checkout's ``mini`` has only ``sensor.json``.
|
||||||
|
"""
|
||||||
|
checkout = _make_root(tmp_path, name="checkout", calibration=False)
|
||||||
|
running = _make_root(tmp_path, name="run-root")
|
||||||
|
monkeypatch.setattr(configs, "_runtime_roots", lambda: [checkout, running])
|
||||||
|
bundle = configs.resolve_sensor_bundle("mini")
|
||||||
|
assert bundle is not None
|
||||||
|
assert bundle.root == (checkout / "sensors" / "mini").resolve()
|
||||||
|
assert bundle.force_calibration_json is None
|
||||||
|
info = configs.describe_bundle_resolution("mini")
|
||||||
|
assert info["found_root"] == (checkout / "sensors" / "mini").resolve()
|
||||||
|
assert info["force_calibration_json"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_describe_bundle_resolution_names_the_bundle_and_the_searched_roots(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
checkout = _make_root(tmp_path, name="checkout", calibration=False)
|
||||||
|
running = _make_root(tmp_path, name="run-root")
|
||||||
|
roots = [checkout / "sensors", running / "sensors"]
|
||||||
|
# Patched at the root list, so no real bundle on this machine can satisfy
|
||||||
|
# the lookup while the "nothing found" case is being asserted.
|
||||||
|
monkeypatch.setattr(configs, "_candidate_sensor_roots", lambda: list(roots))
|
||||||
|
|
||||||
|
info = configs.describe_bundle_resolution("mini")
|
||||||
|
assert info["found_root"] == (checkout / "sensors" / "mini").resolve()
|
||||||
|
assert info["force_calibration_json"] is False
|
||||||
|
assert [str(path) for path in info["searched"]] == [str(p) for p in roots]
|
||||||
|
|
||||||
|
(checkout / "sensors" / "mini" / "sensor.json").unlink()
|
||||||
|
(running / "sensors" / "mini" / "sensor.json").unlink()
|
||||||
|
missing = configs.describe_bundle_resolution("mini")
|
||||||
|
assert missing["found_root"] is None
|
||||||
|
assert missing["sensor_json"] is None
|
||||||
|
assert missing["force_calibration_json"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_calibration_note_names_the_bundle_that_was_found(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
checkout = _make_root(tmp_path, name="checkout", calibration=False)
|
||||||
|
monkeypatch.setattr(configs, "_runtime_roots", lambda: [checkout])
|
||||||
|
sensor = Sensor("external", config_name="mini", verbose=False, backend="cpu")
|
||||||
|
try:
|
||||||
|
assert sensor.calibration is None
|
||||||
|
# Still None: callers read a non-None value here as "calibrated".
|
||||||
|
assert sensor._force_cal_sensor_name is None
|
||||||
|
note = sensor._force_calibration_note()
|
||||||
|
assert "has no force_calibration.json" in note
|
||||||
|
assert str((checkout / "sensors" / "mini").resolve()) in note
|
||||||
|
assert "roots searched" in note
|
||||||
|
finally:
|
||||||
|
sensor.disconnect()
|
||||||
|
|||||||
Reference in New Issue
Block a user