diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 161e70f..828b3fd 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -46,6 +46,7 @@ from orisys_app_shared.record.sample import to_record_sample from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs from orisys_studio.replay import ReplayEngine from orisys_studio.replay_controller import ReplayController +from orisys_studio.calibration_service import CalibrationService from orisys_studio.display_prefs import DisplayPrefsController from orisys_studio.view_layout import ViewLayoutController from orisys_studio import session_metadata @@ -1509,57 +1510,20 @@ class StudioMainWindow(QMainWindow): self.recorder.log_event("calibration_exported", str(path)) self.statusBar().showMessage(t("cal.export_only", path=Path(path).name)) + # ------------------------------------------------- calibration (forwards) + # "What does this coefficient file claim, and does it match the bench?" lives in + # ``CalibrationService`` (with the rest of the calibration file work, and without + # Qt). The dialog and the worker hand-over stay here: they are interaction, not a + # rule about files. def calibration_identity(self, path: str) -> dict: - """What device a coefficient file says it belongs to. - - Read with the SDK's own parser so the check sees the same document the - device would load. A file that cannot be read yields no identity, and the - caller then applies it without a mismatch claim. - """ - try: - import orisys.calibration as sdk_calibration - - document = sdk_calibration.load(path, strict=True) - except Exception: # noqa: BLE001 - reported by the apply path itself - logger.exception("Could not read calibration identity from %s", path) - return {} - identity = dict(getattr(document, "identity", {}) or {}) - pipeline = dict(getattr(document, "pipeline", {}) or {}) - return { - "sensor_type": str(identity.get("sensor_type") or ""), - "sn": str(identity.get("sn") or ""), - "config_ref": str(pipeline.get("config_ref") or ""), - } - - @staticmethod - def _same_path(first: str, second: str) -> bool: - if not first or not second: - return True # nothing to compare: not a mismatch - if first == second: - return True - try: - return Path(first).resolve() == Path(second).resolve() - except Exception: # noqa: BLE001 - a non-path value simply differs - return False + return CalibrationService.identity_of(path) def calibration_target_mismatch( self, path: str, *, sensor_type: str, config_ref: str ) -> list[str]: - """Reasons this calibration does not obviously belong to this device. - - Applying coefficients that were measured on a different sensor silently - changes what every recorded newton value means, so the mismatch is stated - before the confirmation, not discovered later. - """ - recorded = self.calibration_identity(path) - reasons: list[str] = [] - recorded_type = recorded.get("sensor_type") or "" - if recorded_type and sensor_type and recorded_type != sensor_type: - reasons.append(f"sensor_type: {recorded_type} != {sensor_type}") - recorded_config = recorded.get("config_ref") or "" - if recorded_config and not self._same_path(recorded_config, config_ref): - reasons.append(f"config_ref: {recorded_config} != {config_ref}") - return reasons + return CalibrationService.target_mismatch( + path, sensor_type=sensor_type, config_ref=config_ref + ) def _on_calibration_apply(self, path: str) -> None: """Push freshly exported coefficients into the device — on confirmation. diff --git a/apps/studio/src/orisys_studio/calibration_service.py b/apps/studio/src/orisys_studio/calibration_service.py index 690f2ad..4d9fac7 100644 --- a/apps/studio/src/orisys_studio/calibration_service.py +++ b/apps/studio/src/orisys_studio/calibration_service.py @@ -128,3 +128,67 @@ class CalibrationService: sensor_type=str(sensor_type or "box"), local_bundle_dir=local_bundle_dir, ) + + # ------------------------------------------------- who a file belongs to + # The rules below take no state: they answer "what does this file claim, and does it + # match what is on the bench?" — which is the question the *apply* path has to settle + # before coefficients measured on another sensor silently change every newton value. + @staticmethod + def identity_of(path: str | Path) -> dict: + """What device a coefficient file says it belongs to. + + Read with the SDK's own parser so the check sees the same document the device + would load. A file that cannot be read yields no identity, and the caller then + applies it without a mismatch claim. + """ + try: + import orisys.calibration as sdk_calibration + + document = sdk_calibration.load(str(path), strict=True) + except Exception: # noqa: BLE001 - reported by the apply path itself + logger.exception("Could not read calibration identity from %s", path) + return {} + identity = dict(getattr(document, "identity", {}) or {}) + pipeline = dict(getattr(document, "pipeline", {}) or {}) + return { + "sensor_type": str(identity.get("sensor_type") or ""), + "sn": str(identity.get("sn") or ""), + "config_ref": str(pipeline.get("config_ref") or ""), + } + + @staticmethod + def same_path(first: str, second: str) -> bool: + """Whether two configuration references name the same file. + + Two empty values are "the same" on purpose: nothing to compare is not a + mismatch, and refusing to apply an unlabelled calibration would be inventing a + disagreement. + """ + if not first or not second: + return True + if first == second: + return True + try: + return Path(first).resolve() == Path(second).resolve() + except Exception: # noqa: BLE001 - a non-path value simply differs + return False + + @classmethod + def target_mismatch( + cls, path: str | Path, *, sensor_type: str, config_ref: str + ) -> list[str]: + """Reasons this calibration does not obviously belong to this device. + + Applying coefficients that were measured on a different sensor silently changes + what every recorded newton value means, so the mismatch is stated before the + confirmation, not discovered later. + """ + recorded = cls.identity_of(path) + reasons: list[str] = [] + recorded_type = recorded.get("sensor_type") or "" + if recorded_type and sensor_type and recorded_type != sensor_type: + reasons.append(f"sensor_type: {recorded_type} != {sensor_type}") + recorded_config = recorded.get("config_ref") or "" + if recorded_config and not cls.same_path(recorded_config, config_ref): + reasons.append(f"config_ref: {recorded_config} != {config_ref}") + return reasons diff --git a/apps/studio/tests/test_calibration_service.py b/apps/studio/tests/test_calibration_service.py new file mode 100644 index 0000000..54f7e69 --- /dev/null +++ b/apps/studio/tests/test_calibration_service.py @@ -0,0 +1,81 @@ +"""Acceptance for the calibration service's rules about *whose* a file is. + +The window used to own these: reading a coefficient file's claimed identity and deciding +whether it obviously belongs to the device on the bench. They are pure rules about files, +so they now live with the rest of the calibration file work — and can be tested without +building a window. The end-to-end apply path is still covered by ``test_ui_smoke``. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from orisys_app_shared.force.calibration_io import ( + CalSample, + CalSessionStore, + build_export_model, +) +from orisys_studio.calibration_service import CalibrationService + + +def _calibration_file(directory: Path, *, sensor_type: str, config_ref: str) -> Path: + """A coefficient document that names the device it was measured on.""" + store = CalSessionStore() + store.aggregate("fn", CalSample(weight_n=0.0, raw_mean=0.0, n=10)) + store.aggregate("fn", CalSample(weight_n=5.0, raw_mean=500.0, n=10)) + model = build_export_model({key: store.channel(key) for key in ("fn", "fx", "fy")}) + document = model.to_dict() + document["identity"] = {"sensor_type": sensor_type, "sn": "SN-A"} + document["pipeline"] = {"config_ref": config_ref} + path = directory / f"{sensor_type}_calibration.json" + path.write_text(json.dumps(document), encoding="utf-8") + return path + + +def test_the_identity_names_the_sensor_and_the_configuration(tmp_path): + path = _calibration_file(tmp_path, sensor_type="box", config_ref="D:/lab/a/sensor.json") + + identity = CalibrationService.identity_of(path) + assert identity["sensor_type"] == "box" + assert identity["sn"] == "SN-A" + assert identity["config_ref"] == "D:/lab/a/sensor.json" + + +def test_a_file_that_cannot_be_read_claims_nothing(tmp_path): + """No identity must mean "no mismatch claim", not "mismatch".""" + broken = tmp_path / "not-a-calibration.json" + broken.write_text("this is not a coefficient document\n", encoding="utf-8") + + assert CalibrationService.identity_of(broken) == {} + assert CalibrationService.target_mismatch( + broken, sensor_type="finger", config_ref="D:/lab/b/sensor.json" + ) == [] + + +def test_another_devices_calibration_is_reported_with_its_reasons(tmp_path): + path = _calibration_file(tmp_path, sensor_type="box", config_ref="D:/lab/a/sensor.json") + + reasons = CalibrationService.target_mismatch( + path, sensor_type="finger", config_ref="D:/lab/b/sensor.json" + ) + assert any("sensor_type" in reason for reason in reasons), reasons + assert any("config_ref" in reason for reason in reasons), reasons + + # The same device and configuration is not a mismatch. + assert ( + CalibrationService.target_mismatch( + path, sensor_type="box", config_ref="D:/lab/a/sensor.json" + ) + == [] + ) + + +def test_the_same_configuration_named_differently_is_not_a_mismatch(tmp_path): + """A path is compared as a path: the apply path must not cry wolf over spelling.""" + assert CalibrationService.same_path("", "D:/lab/a/sensor.json") is True + assert CalibrationService.same_path("D:/lab/a/sensor.json", "") is True + assert CalibrationService.same_path("D:/lab/a/sensor.json", "D:/lab/./a/sensor.json") + + # ...but it does have to be a path: a non-path value simply differs. + assert CalibrationService.same_path("D:/lab/a/sensor.json", "not a path\x00") is False