优化部分多点标定功能

This commit is contained in:
2026-10-16 18:58:31 +08:00
parent a3aeca1d12
commit 54e0b8006a
5 changed files with 288 additions and 11 deletions
+2
View File
@@ -354,6 +354,8 @@
"start": "Start acquisition",
"confirm": "At target force, start hold",
"liftoff": "Confirm the indenter has left the sensor",
"remeasure": "Measure this step again",
"remeasure_ready": "This step was discarded and progress stays on it. Reconnect the sensor, wait until the trace is back near its previous baseline, type this step's reference force, then click At target force, start hold.",
"stop": "Stop and keep data",
"stopped_reason": "operator stopped",
"finger_busy": "A single-point trial is running; finish it before multi-point acquisition",
+2
View File
@@ -354,6 +354,8 @@
"start": "开始采集",
"confirm": "已到目标力,开始保持",
"liftoff": "确认压头已脱离传感器",
"remeasure": "重测本档",
"remeasure_ready": "这一档已作废,进度停在同一档。接上传感器,等曲线回到断线前靠近零的位置,人工参考力填这一档的力,再点「已到目标力,开始保持」。",
"stop": "停止并保留数据",
"stopped_reason": "操作员停止",
"finger_busy": "单点试验进行中,请先结束再开始多点采集",
+158 -9
View File
@@ -133,8 +133,16 @@ _COMPONENT_SETS = (
("N", ("FN_N", "FX_N", "FY_N")),
("raw", ("FN_raw", "FX_raw", "FY_raw")),
)
#: Shown in stable_means.csv. Weights are the only load used on this bench.
_LOAD_TYPE_LABEL = {
"weight": "砝码",
"wrench": "六维力",
}
STABLE_MEANS_HEADER = (
"site_id",
"U",
"V",
"负载类型",
"repeat_index",
"step_index",
"branch",
@@ -188,36 +196,83 @@ def _without_outliers(values: list[float]) -> list[float]:
return kept or [centre]
def stable_component_rows(repeat_dir: Path, *, site_id: str, repeat_index: int) -> list[dict]:
def _uv_text(value) -> str:
"""One UV coordinate, blank when the point was not picked on the flat map."""
if value is None or value == "":
return ""
try:
number = float(value)
except (TypeError, ValueError):
return ""
if not math.isfinite(number):
return ""
if number == math.trunc(number):
return str(int(number))
return f"{number:.6g}"
def _point_uv(repeat_dir: Path) -> tuple[str, str]:
"""U and V stored with the repeat. Missing coordinates stay blank."""
path = repeat_dir / "session.json"
if not path.is_file():
return "", ""
try:
record = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return "", ""
uv = record.get("uv_px") if isinstance(record, dict) else None
if not isinstance(uv, dict):
return "", ""
return _uv_text(uv.get("u")), _uv_text(uv.get("v"))
def stable_component_rows(
repeat_dir: Path,
*,
site_id: str,
repeat_index: int,
uv: tuple[float, float] | None = None,
) -> list[dict]:
"""Mean Fn, Fx and Fy over the latter half of each completed hold.
The first half of a hold is the settling transient. Each component then
drops its own outliers before the mean. The file this feeds does not
contain a combined force.
contain a combined force. U and V are the flat-map coordinates of the
point. The load type is the reference used for that hold.
"""
reference_path = repeat_dir / "reference.csv"
if not reference_path.is_file():
return []
with reference_path.open(encoding="utf-8-sig", newline="") as handle:
holds = list(csv.DictReader(handle))
stamps: dict[int, int] = {}
stamps: list[int | None] = []
frames_path = repeat_dir / "capture" / "frames.csv"
if frames_path.is_file():
with frames_path.open(encoding="utf-8-sig", newline="") as handle:
for row in csv.DictReader(handle):
if row.get("dropped") not in (None, "", "0") or not row.get("t_host_mono_ns"):
# A gap is a hole in the force file, not a sample. A camera that
# is plugged back in starts its frame numbers again, so the time
# of a sample is the frame written with it, not the frame number.
if row.get("dropped") not in (None, "", "0"):
continue
stamps[int(row["frame_id"])] = int(row["t_host_mono_ns"])
text = str(row.get("t_host_mono_ns") or "").strip()
stamps.append(int(text) if text else None)
samples: list[tuple[int, str, tuple[float, float, float]]] = []
forces_path = repeat_dir / "capture" / "forces.csv"
if forces_path.is_file():
with forces_path.open(encoding="utf-8-sig", newline="") as handle:
for row in csv.DictReader(handle):
for index, row in enumerate(csv.DictReader(handle)):
if index >= len(stamps):
break
components = _components(row)
stamp = stamps.get(int(row["frame_id"])) if row.get("frame_id") else None
stamp = stamps[index]
if components is None or stamp is None:
continue
samples.append((stamp, components[0], components[1]))
if uv is None:
u_text, v_text = _point_uv(repeat_dir)
else:
u_text, v_text = _uv_text(uv[0]), _uv_text(uv[1])
rows = []
for hold in holds:
if hold.get("hold_complete") not in (None, "", "1"):
@@ -230,8 +285,12 @@ def stable_component_rows(repeat_dir: Path, *, site_id: str, repeat_index: int)
used = [item[2] for item in chosen if item[1] == unit]
cleaned = [_without_outliers(list(axis)) for axis in zip(*used)] if used else []
means = tuple(sum(axis) / len(axis) for axis in cleaned) if cleaned else ("", "", "")
source = str(hold.get("reference_source") or "").strip()
rows.append({
"site_id": site_id,
"U": u_text,
"V": v_text,
"负载类型": _LOAD_TYPE_LABEL.get(source, source),
"repeat_index": repeat_index,
"step_index": hold.get("step_index", ""),
"branch": hold.get("branch", ""),
@@ -362,6 +421,7 @@ class Campaign:
self._rotation: tuple[tuple[float, float, float], ...] | None = None
self._pending: dict | None = None
self._single_site = False
self._finished_step_pending = False
@property
def is_active(self) -> bool:
@@ -547,6 +607,7 @@ class Campaign:
self.simulate = bool(simulate)
self.reason = ""
self._single_site = False
self._finished_step_pending = False
self._completed_holds = 0
self._trial_serial = 0
self.directory = _session_directory(Path(root), self.sensor_model, self.sensor_sn)
@@ -597,6 +658,7 @@ class Campaign:
if branch == "baseline" and force != 0:
raise ValueError("the unloaded baseline needs a reference of 0 when you are using weights")
now = time.perf_counter_ns()
self._finished_step_pending = False
self._pending = {"source": "weight", "force": force, "fx": "", "fy": "", "fz": ""}
self._active = (self._step_index, branch, target, force, now)
self._hold_started_ns = now
@@ -620,6 +682,7 @@ class Campaign:
raise ValueError("the normal component is above the maximum force you entered")
_branch, _target = self._steps[self._step_index]
now = time.perf_counter_ns()
self._finished_step_pending = False
self._pending = {
"source": "wrench",
"force": force,
@@ -659,8 +722,10 @@ class Campaign:
self._completed_holds += 1
self._step_index += 1
if self._step_index < len(self._steps):
self._finished_step_pending = True
self.phase = "load"
return
self._finished_step_pending = False
self._close_repeat(complete=True)
self._repeat_index += 1
if self._single_site:
@@ -718,6 +783,72 @@ class Campaign:
self._finish_session("incomplete")
self.phase = "aborted"
def can_remeasure_step(self) -> bool:
"""True while a hold can be dropped, or the step that just finished can be taken back."""
if not self.is_active or self._trial_dir is None:
return False
if self.phase == "hold":
return True
return self.phase == "load" and self._step_index > 0 and self._finished_step_pending
def remeasure_step(self) -> None:
"""Discard the hold in progress, or the step that just finished, and wait on that step.
The discarded window is kept in the file with hold_complete 0, so it is
left out of the stable means. The next confirmation records the same
step again.
"""
if not self.can_remeasure_step():
raise RuntimeError("this step is already waiting to be measured again")
if self.phase == "hold":
if self._active is not None and self._reference is not None:
step_index, branch, target, force, start = self._active
now = time.perf_counter_ns()
self._reference.writerow(self._reference_values(
step_index, branch, target, start, now, force, 0,
))
self._reference_file.flush()
self._active = None
self._pending = None
self._hold_started_ns = None
self._event("hold_discarded", {"step_index": self._step_index})
self.phase = "load"
return
self._mark_last_hold_incomplete()
self._step_index -= 1
self._completed_holds = max(0, self._completed_holds - 1)
self._finished_step_pending = False
self._event("step_remeasured", {"step_index": self._step_index})
def _mark_last_hold_incomplete(self) -> None:
"""The step that just advanced is no longer a finished hold."""
if self._trial_dir is None or self._reference_file is None:
raise RuntimeError("no step is open")
path = self._trial_dir / "reference.csv"
self._reference_file.flush()
self._reference_file.close()
self._reference_file = None
self._reference = None
with path.open(encoding="utf-8-sig", newline="") as handle:
rows = list(csv.reader(handle))
if len(rows) < 2 or "hold_complete" not in rows[0]:
self._reopen_reference()
raise RuntimeError("no finished step to measure again")
column = rows[0].index("hold_complete")
if rows[-1][column] != "1":
self._reopen_reference()
raise RuntimeError("this step is already waiting to be measured again")
rows[-1][column] = "0"
with path.open("w", newline="", encoding="utf-8") as handle:
csv.writer(handle).writerows(rows)
self._reopen_reference()
def _reopen_reference(self) -> None:
path = self._trial_dir / "reference.csv"
handle = path.open("a", newline="", encoding="utf-8")
self._reference_file = handle
self._reference = csv.writer(handle)
def reacquire(self, site_id: str) -> None:
"""Append one more repeat of a point into this same session."""
if self.is_active:
@@ -943,13 +1074,15 @@ class Campaign:
return
rows = []
for trial in self._document.get("trials") or []:
repeat_dir = self.directory / trial["directory"]
rows.extend(stable_component_rows(
self.directory / trial["directory"],
repeat_dir,
site_id=str(trial.get("site_id", "")),
repeat_index=int(trial.get("repeat_index") or 0),
uv=self._uv_of(str(trial.get("site_id", "")), repeat_dir),
))
path = self.directory / "stable_means.csv"
with path.open("w", newline="", encoding="utf-8") as handle:
with path.open("w", newline="", encoding="utf-8-sig") as handle:
writer = csv.DictWriter(handle, fieldnames=list(STABLE_MEANS_HEADER))
writer.writeheader()
writer.writerows(rows)
@@ -963,6 +1096,22 @@ class Campaign:
self._document["completion"] = self.completion()
write_json(self.directory / "session.json", self._document)
def _uv_of(self, site_id: str, repeat_dir: Path) -> tuple[float, float] | None:
"""UV from the repeat, then from the point still in this session."""
u_text, v_text = _point_uv(repeat_dir)
if u_text != "" or v_text != "":
return float(u_text or 0), float(v_text or 0)
for site in self.sites:
if site.site_id == site_id and site.uv_px is not None:
return site.uv_px
for record in (self._document or {}).get("sites") or []:
if str(record.get("site_id")) != site_id:
continue
uv = record.get("uv_px")
if isinstance(uv, dict) and "u" in uv and "v" in uv:
return float(uv["u"]), float(uv["v"])
return None
def _steps_for(self, site: SiteSpec) -> list[tuple[str, float]]:
return load_steps(
list(site.levels_n), float(self.max_force_n), min_force_n=self.min_force_n,
@@ -191,11 +191,13 @@ class MultipointPanel(QWidget):
self.simulate_button = QPushButton(page)
self.start_button = QPushButton(page)
self.liftoff_button = QPushButton(page)
self.remeasure_button = QPushButton(page)
self.stop_button = QPushButton(page)
actions.addWidget(self.simulate_button, 0, 0)
actions.addWidget(self.start_button, 0, 1)
actions.addWidget(self.liftoff_button, 1, 0, 1, 2)
actions.addWidget(self.stop_button, 2, 0, 1, 2)
actions.addWidget(self.remeasure_button, 2, 0, 1, 2)
actions.addWidget(self.stop_button, 3, 0, 1, 2)
actions.setColumnStretch(0, 1)
actions.setColumnStretch(1, 1)
layout.addLayout(actions)
@@ -213,6 +215,7 @@ class MultipointPanel(QWidget):
self.simulate_button.clicked.connect(lambda: self._begin(simulate=True))
self.start_button.clicked.connect(lambda: self._begin(simulate=False))
self.liftoff_button.clicked.connect(self._liftoff)
self.remeasure_button.clicked.connect(self._remeasure)
self.stop_button.clicked.connect(self._stop)
self._timer = QTimer(self)
self._timer.setInterval(200)
@@ -265,6 +268,8 @@ class MultipointPanel(QWidget):
self.simulate_button.setText(t("multipoint.simulate"))
self.start_button.setText(t("multipoint.start"))
self.liftoff_button.setText(t("multipoint.liftoff"))
self.remeasure_button.setText(t("multipoint.remeasure"))
self.remeasure_button.setToolTip(t("multipoint.remeasure_ready"))
self.stop_button.setText(t("multipoint.stop"))
self.table.setHorizontalHeaderLabels([
t("multipoint.col_id"), t("multipoint.col_x"), t("multipoint.col_y"),
@@ -537,6 +542,12 @@ class MultipointPanel(QWidget):
def _liftoff(self) -> None:
self._run(self.campaign.confirm_liftoff)
def _remeasure(self) -> None:
def go():
self.campaign.remeasure_step()
self.message.setText(t("multipoint.remeasure_ready"))
self._run(go)
def _stop(self) -> None:
self.campaign.stop(t("multipoint.stopped_reason"))
self._refresh()
@@ -604,6 +615,7 @@ class MultipointPanel(QWidget):
for widget in (self.fx_edit, self.fy_edit, self.fz_edit, self.rotation):
widget.setEnabled(self.campaign.phase == "load" and wrench)
self.liftoff_button.setEnabled(self.campaign.phase == "liftoff")
self.remeasure_button.setEnabled(self.campaign.can_remeasure_step())
self.stop_button.setEnabled(active)
self.reacquire_button.setEnabled(
not active and bool(self._selected) and self.campaign.directory is not None
+113 -1
View File
@@ -256,13 +256,17 @@ def test_stable_means_are_the_three_components_of_the_latter_half(tmp_path):
with (repeat / "reference.csv").open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=[
"step_index", "branch", "target_load_n", "t_start_host_mono_ns",
"t_end_host_mono_ns", "hold_complete",
"t_end_host_mono_ns", "hold_complete", "reference_source",
])
writer.writeheader()
writer.writerow({
"step_index": 1, "branch": "load", "target_load_n": 5,
"t_start_host_mono_ns": start, "t_end_host_mono_ns": end, "hold_complete": 1,
"reference_source": "weight",
})
(repeat / "session.json").write_text(
json.dumps({"uv_px": {"u": 128, "v": 64}}), encoding="utf-8",
)
with (repeat / "capture" / "frames.csv").open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=["frame_id", "t_host_mono_ns", "dropped"])
writer.writeheader()
@@ -296,6 +300,9 @@ def test_stable_means_are_the_three_components_of_the_latter_half(tmp_path):
assert float(rows[0]["FN_mean"]) == pytest.approx(3.0)
assert float(rows[0]["FX_mean"]) == pytest.approx(0.6)
assert float(rows[0]["FY_mean"]) == pytest.approx(-0.4)
assert rows[0]["U"] == "128"
assert rows[0]["V"] == "64"
assert rows[0]["负载类型"] == "砝码"
assert "Fmag" not in rows[0]
@@ -338,3 +345,108 @@ def test_an_outlier_in_the_stable_half_is_left_out_of_its_own_mean(tmp_path):
assert float(row["FN_mean"]) == pytest.approx(3.0)
assert float(row["FX_mean"]) == pytest.approx(0.575)
assert float(row["FY_mean"]) == pytest.approx(-0.375)
def test_a_reconnected_camera_keeps_each_hold_on_its_own_frames(tmp_path):
"""A camera that starts again reuses frame numbers. Each hold keeps its own rows."""
from orisys_autocal.multipoint import stable_component_rows
repeat = tmp_path / "repeat-001"
(repeat / "capture").mkdir(parents=True)
first = (1_000, 1_100)
second = (5_000, 5_100)
with (repeat / "reference.csv").open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=[
"step_index", "branch", "target_load_n", "t_start_host_mono_ns",
"t_end_host_mono_ns", "hold_complete", "reference_source",
])
writer.writeheader()
writer.writerow({
"step_index": 2, "branch": "load", "target_load_n": 3,
"t_start_host_mono_ns": first[0], "t_end_host_mono_ns": first[1],
"hold_complete": 1, "reference_source": "weight",
})
writer.writerow({
"step_index": 3, "branch": "unload", "target_load_n": 2,
"t_start_host_mono_ns": second[0], "t_end_host_mono_ns": second[1],
"hold_complete": 1, "reference_source": "weight",
})
with (repeat / "capture" / "frames.csv").open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=["frame_id", "t_host_mono_ns", "dropped"])
writer.writeheader()
for frame_id, stamp in ((1, 1060), (2, 1080), (1, 5060), (2, 5080)):
writer.writerow({"frame_id": frame_id, "t_host_mono_ns": stamp, "dropped": 0})
with (repeat / "capture" / "forces.csv").open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=[
"frame_id", "valid", "FN_output_N", "FX_output_N", "FY_output_N",
])
writer.writeheader()
for frame_id, fn in ((1, 10), (2, 30), (1, 4), (2, 6)):
writer.writerow({
"frame_id": frame_id, "valid": 1,
"FN_output_N": fn, "FX_output_N": 0, "FY_output_N": 0,
})
rows = stable_component_rows(repeat, site_id="P1", repeat_index=1)
assert float(rows[0]["FN_mean"]) == pytest.approx(20.0)
assert rows[0]["branch"] == "load"
assert float(rows[1]["FN_mean"]) == pytest.approx(5.0)
assert rows[1]["branch"] == "unload"
def _hold_for(campaign: Campaign, force: float) -> None:
campaign.confirm_reference(force)
campaign.complete_hold(now_ns=campaign._hold_started_ns + int(campaign._hold_s * 1e9))
def test_a_finished_step_can_be_measured_again_without_skipping_ahead(tmp_path):
campaign = Campaign()
campaign.add_site("A", 0, 0, 1, [2, 3], 20, 1, max_force_n=10)
campaign.start(
tmp_path, sensor_model="finger", sensor_sn="SN1",
coordinate_frame="sensor", max_force_n=10, simulate=True,
)
_hold_for(campaign, 0)
_hold_for(campaign, 2)
assert campaign.progress()["target_n"] == 3
assert campaign.can_remeasure_step()
campaign.remeasure_step()
assert campaign.phase == "load"
assert campaign.progress()["branch"] == "load"
assert campaign.progress()["target_n"] == 2
assert not campaign.can_remeasure_step()
saved = list(csv.DictReader(
(campaign.directory / "sites" / "A" / "repeat-001" / "reference.csv").open(encoding="utf-8")
))
assert saved[-1]["target_load_n"] == "2.0"
assert saved[-1]["hold_complete"] == "0"
_hold_for(campaign, 2)
saved = list(csv.DictReader(
(campaign.directory / "sites" / "A" / "repeat-001" / "reference.csv").open(encoding="utf-8")
))
redone = [row for row in saved if row["target_load_n"] == "2.0"]
assert [row["hold_complete"] for row in redone] == ["0", "1"]
assert campaign.progress()["target_n"] == 3
def test_a_hold_that_drops_midway_stays_on_the_same_step(tmp_path):
campaign = Campaign()
campaign.add_site("A", 0, 0, 1, [2, 3], 20, 1, max_force_n=10)
campaign.start(
tmp_path, sensor_model="finger", sensor_sn="SN1",
coordinate_frame="sensor", max_force_n=10, simulate=True,
)
_hold_for(campaign, 0)
campaign.confirm_reference(2)
assert campaign.phase == "hold"
campaign.remeasure_step()
assert campaign.phase == "load"
assert campaign.progress()["target_n"] == 2
saved = list(csv.DictReader(
(campaign.directory / "sites" / "A" / "repeat-001" / "reference.csv").open(encoding="utf-8")
))
assert saved[-1]["hold_complete"] == "0"
assert saved[-1]["target_load_n"] == "2.0"