优化部分多点标定功能
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user