优化部分多点标定功能

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
+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