migrate pyqt5 to pyside6

This commit is contained in:
ykcheungab
2026-09-21 09:34:56 +08:00
parent e276b5de0f
commit ccedb9ddd9
66 changed files with 2184 additions and 395 deletions
@@ -0,0 +1,476 @@
"""Import and export a calibration sequence (workflow P5/P8).
A sequence is a small, human-readable document — a profile plus the points it
generated — so it can be reviewed, diffed, emailed, and reproduced on another
bench. The format follows this repository's other documents rather than
inventing a convention: a dotted ``format_version`` string, sorted sections,
``indent=2 / ensure_ascii=False`` JSON, and an atomic temp-then-replace write
(cf. ``record/experiment.py``'s ``session.json`` and ``force_calibration.json``).
The full field-by-field spec is ``apps/autocal/docs/sequence-format.md``.
Two properties matter more than the field names:
* **``summary`` is derived.** It exists so a human reading the file sees the
point count and the travel span without counting rows. It is *recomputed* on
load, and a mismatch against the file is reported — that is how a hand-edited
document announces itself instead of quietly disagreeing with its own contents.
* **The file states its frame.** A sequence whose numbers do not say whether they
are millimetres or user units is a sequence that will eventually be read wrong,
so ``frame`` records both the unit and the pulse equivalent it was built with.
Nothing here imports Qt, so the format can be exercised from a test process and
a future batch tool.
"""
from __future__ import annotations
import json
import math
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Mapping
#: The version this build writes, and the highest it will read.
FORMAT_VERSION = "1.0"
#: What a sequence document says it is. Checked on import: a force-calibration
#: file and a sequence file are both JSON, and applying one as the other must
#: fail loudly rather than half-work.
DOCUMENT_KIND = "autocal.sequence"
#: File extension by convention. Import accepts any ``.json``.
FILE_SUFFIX = ".oryseq"
#: The frame targets are expressed in.
TARGET_FRAME = "platform"
LENGTH_UNIT = "mm"
#: Platform traverse rate assumed when estimating a run's duration, in mm/s.
#: A first-pass planning figure, not a measurement: the axes' own speed setting
#: is in user units and the real rate depends on acceleration and the path.
#: Clearly labelled as an estimate wherever it is shown.
ASSUMED_TRAVERSE_MM_S = 20.0
#: The profile keys a document must carry, matching the panel's fields.
PROFILE_KEYS: tuple[str, ...] = (
"mode",
"pitch_mm",
"margin_mm",
"radius_mm",
"clear_height_mm",
"approach_speed_mm_s",
"press_speed_mm_s",
"press_depth_mm",
"dwell_s",
"settle_window_s",
"repeats",
"order",
)
class SequenceFormatError(ValueError):
"""A sequence document could not be read.
The message is what the operator sees, so it always names the specific
problem (which key, which version, which point) rather than "invalid file".
"""
@dataclass
class SequencePoint:
"""One generated calibration point.
``target_mm`` is where the axes go; ``sensor_mm`` is where that lands on the
sensor. Both are stored: the run needs the first, and the second is what makes
the point checkable against the surface. Re-deriving one from the other on
every load would silently change what the points mean when a sensor is
re-fixtured.
"""
id: int
target_mm: dict[str, float]
sensor_mm: dict[str, float] | None = None
clearance_mm: float = 0.0
enabled: bool = True
note: str = ""
def as_dict(self) -> dict[str, Any]:
payload: dict[str, Any] = {
"id": int(self.id),
"target_mm": {axis: float(self.target_mm.get(axis, 0.0)) for axis in "xyz"},
"clearance_mm": float(self.clearance_mm),
"enabled": bool(self.enabled),
"note": str(self.note),
}
if self.sensor_mm is not None:
payload["sensor_mm"] = {axis: float(self.sensor_mm.get(axis, 0.0)) for axis in "xyz"}
return payload
@dataclass
class SequenceDocument:
"""A sequence as it travels between machines."""
profile: dict[str, Any] = field(default_factory=dict)
points: list[SequencePoint] = field(default_factory=list)
name: str = ""
note: str = ""
device: dict[str, Any] = field(default_factory=dict)
units_per_mm: float = 0.0
created_at: str = ""
summary: dict[str, Any] = field(default_factory=dict)
#: Problems found while reading that did not stop the import. Shown to the
#: operator: "it loaded" and "it loaded as written" are different claims.
warnings: list[str] = field(default_factory=list)
@property
def point_count(self) -> int:
return len(self.points)
@property
def enabled_count(self) -> int:
return sum(1 for point in self.points if point.enabled)
# --------------------------------------------------------------------- derive
def summarise(
points: Iterable[SequencePoint], profile: Mapping[str, Any]
) -> dict[str, Any]:
"""The derived block: count, travel span, and an estimated duration.
Recomputed everywhere rather than trusted from a file, so a document cannot
claim a range its points do not have.
"""
ordered = list(points)
span: dict[str, float] = {}
minimum: dict[str, float] = {}
maximum: dict[str, float] = {}
for axis in "xyz":
values = [float(point.target_mm.get(axis, 0.0)) for point in ordered]
if values:
minimum[axis] = min(values)
maximum[axis] = max(values)
span[axis] = maximum[axis] - minimum[axis]
return {
"count": len(ordered),
"enabled": sum(1 for point in ordered if point.enabled),
"min_mm": minimum,
"max_mm": maximum,
"span_mm": span,
"pitch_mm": _number(profile.get("pitch_mm"), 0.0),
"estimated_duration_s": estimate_duration(ordered, profile),
}
def estimate_duration(
points: Iterable[SequencePoint], profile: Mapping[str, Any]
) -> float:
"""A planning estimate for one full run, in seconds.
Per point: the descent and press, the dwell, and the settle window, repeated
``repeats`` times; plus a traverse between points at
:data:`ASSUMED_TRAVERSE_MM_S`. An estimate, labelled as one wherever it is
shown — it assumes a constant traverse rate and ignores acceleration.
"""
ordered = list(points)
if not ordered:
return 0.0
repeats = max(1, int(_number(profile.get("repeats"), 1)))
clear_height = max(0.0, _number(profile.get("clear_height_mm"), 0.0))
approach = max(1e-6, _number(profile.get("approach_speed_mm_s"), 1.0))
press_speed = max(1e-6, _number(profile.get("press_speed_mm_s"), 1.0))
press_depth = max(0.0, _number(profile.get("press_depth_mm"), 0.0))
dwell = max(0.0, _number(profile.get("dwell_s"), 0.0))
settle = max(0.0, _number(profile.get("settle_window_s"), 0.0))
descent = clear_height / approach
press = press_depth / press_speed
per_point = (descent + press + dwell + settle) * repeats
total = per_point * len(ordered)
pitch = max(0.0, _number(profile.get("pitch_mm"), 0.0))
if pitch > 0:
total += (len(ordered) - 1) * (pitch / ASSUMED_TRAVERSE_MM_S) * repeats
return float(total)
def format_span(low: float | None, high: float | None, *, places: int = 3) -> str:
"""``min–max (span)``, or a single number when the travel is nil.
A zero span means every point shares that coordinate; printing
``12.400–12.400 (0.000)`` would be noise in a line whose job is to be read at
a glance.
"""
if low is None or high is None:
return "—"
if abs(high - low) < 10 ** (-places) / 2:
return f"{low:.{places}f}"
return f"{low:.{places}f}–{high:.{places}f} ({high - low:.{places}f})"
def format_duration(seconds: float | None) -> str:
"""``m:ss``, or a dash when no estimate can be made."""
if seconds is None or not math.isfinite(float(seconds)) or seconds <= 0:
return "—"
total = int(round(float(seconds)))
minutes, rest = divmod(total, 60)
if minutes >= 60:
hours, minutes = divmod(minutes, 60)
return f"{hours:d}:{minutes:02d}:{rest:02d}"
return f"{minutes:d}:{rest:02d}"
# ----------------------------------------------------------------- serialize
def to_document(
*,
profile: Mapping[str, Any],
points: Iterable[SequencePoint] = (),
name: str = "",
note: str = "",
device: Mapping[str, Any] | None = None,
units_per_mm: float = 0.0,
app_version: str = "",
shared_version: str = "",
shared_api: str = "",
) -> dict[str, Any]:
"""Build the sequence document."""
ordered = list(points)
return {
"format_version": FORMAT_VERSION,
"kind": DOCUMENT_KIND,
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"name": str(name),
"note": str(note),
"versions": {
"app": str(app_version),
"shared": str(shared_version),
"shared_api": str(shared_api),
},
"device": dict(device or {}),
"frame": {
"targets": TARGET_FRAME,
"length_unit": LENGTH_UNIT,
"units_per_mm": float(units_per_mm),
},
"profile": {
key: profile.get(key)
for key in PROFILE_KEYS
},
"points": [point.as_dict() for point in ordered],
"summary": summarise(ordered, profile),
}
def save(document: Mapping[str, Any], path: str | Path) -> Path:
"""Write ``document`` atomically, as UTF-8 JSON.
Temp-then-replace: a half-written sequence file that still parses is worse
than no file, because it looks like a design.
"""
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix(target.suffix + ".tmp")
temporary.write_text(
json.dumps(document, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
temporary.replace(target)
return target
# ------------------------------------------------------------------- parse
def load(path: str | Path, *, current_profile: Mapping[str, Any] | None = None) -> SequenceDocument:
"""Read a sequence document, validating it.
Args:
path: the file to read.
current_profile: the profile in force now. Missing profile keys are
taken from it and reported, rather than silently defaulting to
another bench's numbers.
Raises:
SequenceFormatError: with a message naming the specific problem.
"""
target = Path(path)
try:
text = target.read_text(encoding="utf-8")
except OSError as exc:
raise SequenceFormatError(f"cannot read {target.name}: {exc}") from exc
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
raise SequenceFormatError(
f"{target.name} is not valid JSON: line {exc.lineno}, column {exc.colno}"
) from exc
if not isinstance(data, dict):
raise SequenceFormatError(
f"{target.name}: the top level must be an object, found {type(data).__name__}"
)
return from_document(data, current_profile=current_profile, source=target.name)
def from_document(
data: Mapping[str, Any],
*,
current_profile: Mapping[str, Any] | None = None,
source: str = "document",
) -> SequenceDocument:
"""Validate and interpret an already-parsed sequence document."""
warnings: list[str] = []
kind = data.get("kind")
if kind != DOCUMENT_KIND:
raise SequenceFormatError(
f"{source}: not a sequence document "
f"(kind is {kind!r}, expected {DOCUMENT_KIND!r})"
)
raw_version = data.get("format_version")
if not isinstance(raw_version, str) or not raw_version.strip():
raise SequenceFormatError(f"{source}: missing or invalid format_version")
if _version_tuple(raw_version) > _version_tuple(FORMAT_VERSION):
raise SequenceFormatError(
f"{source}: written by a newer AutoCal "
f"(format {raw_version}, this build reads up to {FORMAT_VERSION})"
)
frame = data.get("frame")
if not isinstance(frame, dict):
raise SequenceFormatError(f"{source}: missing frame block")
if str(frame.get("length_unit") or "") != LENGTH_UNIT:
raise SequenceFormatError(
f"{source}: length_unit is {frame.get('length_unit')!r}, expected {LENGTH_UNIT!r}"
)
profile_in = data.get("profile")
if not isinstance(profile_in, dict):
raise SequenceFormatError(f"{source}: missing or invalid profile block")
profile: dict[str, Any] = dict(current_profile or {})
missing: list[str] = []
for key in PROFILE_KEYS:
if key in profile_in:
profile[key] = profile_in[key]
else:
missing.append(key)
if missing:
warnings.append(
"profile keys absent, kept the current value: " + ", ".join(sorted(missing))
)
points = _parse_points(data.get("points"), source=source, warnings=warnings)
stored_summary = data.get("summary")
recomputed = summarise(points, profile)
if isinstance(stored_summary, dict) and points:
differences = _summary_differences(stored_summary, recomputed)
if differences:
warnings.append(
"the stored summary disagrees with the points and was recomputed: "
+ "; ".join(differences)
)
device = data.get("device") if isinstance(data.get("device"), dict) else {}
return SequenceDocument(
profile=profile,
points=points,
name=str(data.get("name") or ""),
note=str(data.get("note") or ""),
device=dict(device),
units_per_mm=_number(frame.get("units_per_mm"), 0.0),
created_at=str(data.get("created_at") or ""),
summary=recomputed,
warnings=warnings,
)
def _parse_points(
raw: Any, *, source: str, warnings: list[str]
) -> list[SequencePoint]:
if raw is None:
warnings.append("no points in the file; only the profile was imported")
return []
if not isinstance(raw, list):
raise SequenceFormatError(
f"{source}: points must be a list, found {type(raw).__name__}"
)
points: list[SequencePoint] = []
for index, entry in enumerate(raw, start=1):
where = f"{source}: point {index}"
if not isinstance(entry, dict):
raise SequenceFormatError(f"{where} is not an object")
target = entry.get("target_mm")
if not isinstance(target, dict):
raise SequenceFormatError(f"{where} has no target_mm object")
for axis in "xyz":
if axis not in target:
raise SequenceFormatError(f"{where}: target_mm is missing '{axis}'")
sensor = entry.get("sensor_mm")
points.append(
SequencePoint(
id=int(_number(entry.get("id"), index)),
target_mm={axis: _number(target.get(axis), 0.0) for axis in "xyz"},
sensor_mm=(
{axis: _number(sensor.get(axis), 0.0) for axis in "xyz"}
if isinstance(sensor, dict)
else None
),
clearance_mm=_number(entry.get("clearance_mm"), 0.0),
enabled=bool(entry.get("enabled", True)),
note=str(entry.get("note") or ""),
)
)
return points
def _summary_differences(stored: Mapping[str, Any], recomputed: Mapping[str, Any]) -> list[str]:
"""Where the file's own summary disagrees with its points."""
differences: list[str] = []
if int(_number(stored.get("count"), -1)) != recomputed["count"]:
differences.append(
f"count {stored.get('count')} != {recomputed['count']}"
)
stored_span = stored.get("span_mm")
if isinstance(stored_span, dict):
for axis in "xyz":
if axis in recomputed["span_mm"]:
if not _close(_number(stored_span.get(axis), 0.0), recomputed["span_mm"][axis]):
differences.append(
f"span {axis} {stored_span.get(axis)} != {recomputed['span_mm'][axis]:.3f}"
)
return differences
def _close(first: float, second: float, tolerance: float = 1e-6) -> bool:
return abs(float(first) - float(second)) <= tolerance
def _number(value: Any, fallback: float) -> float:
"""A finite float, or the fallback. Never NaN, never a string."""
try:
number = float(value)
except (TypeError, ValueError):
return float(fallback)
if not math.isfinite(number):
return float(fallback)
return number
def _version_tuple(text: str) -> tuple[int, ...]:
"""``"1.10"`` as ``(1, 10)``.
Compared as tuples rather than strings: ``"1.10" < "1.9"`` is a real trap that
would make a newer file look older and load it as if it were supported.
"""
parts: list[int] = []
for chunk in str(text).split("."):
digits = "".join(character for character in chunk if character.isdigit())
parts.append(int(digits) if digits else 0)
return tuple(parts)
@@ -47,6 +47,12 @@ from PySide6.QtWidgets import (
from orisys_app_shared.i18n import t
from ..preferences import POINT_ORDERS, SURFACE_MODES
from ..sequence_io import (
SequencePoint,
format_duration,
format_span,
summarise,
)
#: Profile fields, in form order: ``(key, locale_key, kind)``.
#: ``kind`` is ``choice`` (a combo box) or ``number`` (a line edit).
@@ -69,6 +75,10 @@ PROFILE_FIELDS: tuple[tuple[str, str, str], ...] = (
PROFILE_ROWS = (len(PROFILE_FIELDS) + 1) // 2
#: Point-table columns: ``(key, locale_key)``.
#:
#: No actions column. It was rendered empty on every row, and an empty action
#: cell reads as a broken control — the per-point actions (re-measure, skip)
#: belong to the run, not to the design screen.
POINT_COLUMNS: tuple[tuple[str, str], ...] = (
("index", "sequence.col_index"),
("x", "sequence.col_x"),
@@ -77,7 +87,6 @@ POINT_COLUMNS: tuple[tuple[str, str], ...] = (
("status", "sequence.col_status"),
("sensitivity", "sequence.col_sensitivity"),
("samples", "sequence.col_samples"),
("actions", "sequence.col_actions"),
)
#: Editability of the point table. Positions are *generated*, not typed: a typed
@@ -85,20 +94,22 @@ POINT_COLUMNS: tuple[tuple[str, str], ...] = (
_EDITABLE_POINT_COLUMNS: tuple[str, ...] = ()
#: Column widths of the point table, by column index.
_POINT_COLUMN_WIDTHS = (50, 130, 130, 130, 120, 160, 90, 200)
_POINT_COLUMN_WIDTHS = (60, 150, 150, 150, 140, 190, 110)
#: Reserved controls and the phase that will fill each one.
PENDING_CONTROLS: tuple[str, ...] = ("generate", "simulate", "dry_run", "save", "load")
PENDING_CONTROLS: tuple[str, ...] = ("generate", "simulate", "dry_run")
#: Which phase each pending control belongs to (see docs/workflow.md).
PENDING_PHASES: dict[str, str] = {
"generate": "P5",
"simulate": "P6",
"dry_run": "P6",
"save": "P8",
"load": "P8",
}
#: Controls that work now: a sequence is a document, and P8 says it is a
#: versioned artifact. Import and export are the first half of that.
LIVE_CONTROLS: tuple[str, ...] = ("export", "import")
#: The validation checks, with the locale key naming what each one proves.
VALIDATION_CHECKS: tuple[tuple[str, str], ...] = (
("on_surface", "sequence.check_surface"),
@@ -140,8 +151,8 @@ class SequencePanel(QGroupBox):
generateRequested = Signal()
simulateRequested = Signal()
dryRunRequested = Signal()
saveRequested = Signal()
loadRequested = Signal()
exportRequested = Signal()
importRequested = Signal()
def __init__(self, profile: dict[str, Any] | None = None, parent: QWidget | None = None) -> None:
super().__init__(parent)
@@ -151,6 +162,8 @@ class SequencePanel(QGroupBox):
self._choices: dict[str, QComboBox] = {}
self._pending_buttons: dict[str, QPushButton] = {}
self._check_rows: dict[str, QLabel] = {}
self._summary: dict[str, Any] = {}
self._dirty = False
outer = QVBoxLayout(self)
outer.setContentsMargins(10, 8, 10, 10)
@@ -169,16 +182,48 @@ class SequencePanel(QGroupBox):
self.set_profile(profile or {})
self.retranslate()
# A profile edit changes the estimate, so the line follows the form.
for widget in self._profile_fields.values():
if isinstance(widget, QComboBox):
widget.currentIndexChanged.connect(self._mark_dirty)
else:
widget.textChanged.connect(self._mark_dirty)
self.refresh_summary()
# ------------------------------------------------------------------ profile
def _build_profile(self) -> QWidget:
box = QGroupBox(self)
box.setObjectName("groupBox")
grid = QGridLayout(box)
grid.setContentsMargins(10, 8, 10, 8)
outer = QVBoxLayout(box)
outer.setContentsMargins(10, 8, 10, 8)
outer.setSpacing(4)
# The name is what the document is called on disk and in the export, so
# it lives at the top of the block that defines the sequence rather than
# in a dialog.
name_row = QHBoxLayout()
name_row.setSpacing(8)
self._name_label = QLabel(box)
self._name_label.setObjectName("hintLabel")
self.name_edit = QLineEdit(box)
self.name_edit.setFixedHeight(28)
name_row.addWidget(self._name_label)
name_row.addWidget(self.name_edit, 1)
outer.addLayout(name_row)
self._note_label = QLabel(box)
self._note_label.setObjectName("hintLabel")
self.note_edit = QLineEdit(box)
self.note_edit.setFixedHeight(28)
outer.addWidget(self._note_label)
outer.addWidget(self.note_edit)
grid = QGridLayout()
grid.setContentsMargins(0, 0, 0, 0)
grid.setHorizontalSpacing(10)
grid.setVerticalSpacing(4)
outer.addLayout(grid)
# Two fields per row: label, field, label, field. Seeing the whole profile
# at once is what lets an operator sanity-check a pitch against a depth.
@@ -258,13 +303,21 @@ class SequencePanel(QGroupBox):
column.addWidget(button)
self._pending_buttons[key] = button
# Import/export work today: a sequence is a portable document (P8).
self._live_buttons: dict[str, QPushButton] = {}
for key in LIVE_CONTROLS:
button = QPushButton(box)
button.setProperty("rail", True)
button.setFixedHeight(28)
column.addWidget(button)
self._live_buttons[key] = button
self._pending_buttons["generate"].clicked.connect(self.generateRequested)
self._pending_buttons["simulate"].clicked.connect(self.simulateRequested)
self._pending_buttons["dry_run"].clicked.connect(self.dryRunRequested)
self._pending_buttons["save"].clicked.connect(self.saveRequested)
self._pending_buttons["load"].clicked.connect(self.loadRequested)
# None of these can run: generation, simulation, the runner and the
# dataset writer belong to later phases (see docs/workflow.md).
self._live_buttons["export"].clicked.connect(self.exportRequested)
self._live_buttons["import"].clicked.connect(self.importRequested)
# Generation, simulation and the runner belong to later phases (P5-P7).
for button in self._pending_buttons.values():
button.setEnabled(False)
@@ -273,6 +326,10 @@ class SequencePanel(QGroupBox):
# -------------------------------------------------------------- point table
def _mark_dirty(self, *_args: Any) -> None:
self._dirty = True
self.refresh_summary()
def _build_point_table(self) -> QWidget:
box = QGroupBox(self)
box.setObjectName("groupBox")
@@ -303,7 +360,6 @@ class SequencePanel(QGroupBox):
4: t("sequence.status_pending"),
5: "—",
6: "—",
7: "",
}
for column_index, text in values.items():
item = QTableWidgetItem(text)
@@ -311,7 +367,15 @@ class SequencePanel(QGroupBox):
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.setItem(row, column_index, item)
# The count and span line: the two numbers an operator reasons with, and
# the only place a wrong pitch or margin shows up before a seven-minute
# run does it for them.
self.summary_label = QLabel(box)
self.summary_label.setObjectName("statusValue")
self.summary_label.setWordWrap(True)
column.addWidget(self.table, 1)
column.addWidget(self.summary_label)
self.point_hint = QLabel(box)
self.point_hint.setObjectName("hintLabel")
@@ -400,7 +464,6 @@ class SequencePanel(QGroupBox):
4: status or t("sequence.status_pending"),
5: "—",
6: "—",
7: "",
}
for column, text in values.items():
item = QTableWidgetItem(text)
@@ -409,6 +472,77 @@ class SequencePanel(QGroupBox):
self.table.setItem(row, column, item)
return row
# ----------------------------------------------------------- points & state
def points(self) -> list[SequencePoint]:
"""The list as it stands, for export and for a future runner."""
points: list[SequencePoint] = []
for row in range(self.table.rowCount()):
cells = self.point_row(row)
try:
target = {axis: float(cells[axis]) for axis in "xyz"}
except (TypeError, ValueError):
continue # a placeholder row that was never generated
points.append(
SequencePoint(id=row + 1, target_mm=target, enabled=cells["status"] != t("sequence.status_skipped"))
)
return points
def set_points(self, points: list[SequencePoint]) -> None:
"""Replace the list. Used by import and by the future generator."""
self.table.setRowCount(0)
for point in points:
row = self.table.rowCount()
self.table.insertRow(row)
values = {
0: str(point.id),
1: f"{point.target_mm.get('x', 0.0):.3f}",
2: f"{point.target_mm.get('y', 0.0):.3f}",
3: f"{point.target_mm.get('z', 0.0):.3f}",
4: t("sequence.status_pending") if point.enabled else t("sequence.status_skipped"),
5: "—",
6: "—",
}
for column, text in values.items():
item = QTableWidgetItem(text)
if column >= 4:
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.setItem(row, column, item)
self._dirty = True
self.refresh_summary()
@property
def dirty(self) -> bool:
"""Whether the design has changed since it was last saved or loaded.
Import replaces the design outright, and there is no autosave; discarding
a designed sequence without asking would be worse than one extra prompt.
"""
return self._dirty
def mark_clean(self) -> None:
self._dirty = False
def name(self) -> str:
return self.name_edit.text().strip()
def set_name(self, value: str) -> None:
self.name_edit.setText(str(value or ""))
def note(self) -> str:
return self.note_edit.text().strip()
def set_note(self, value: str) -> None:
self.note_edit.setText(str(value or ""))
def refresh_summary(self) -> None:
"""Recompute the count, span and duration line from what is on screen."""
profile = self.profile()
points = self.points()
summary = summarise(points, profile)
self._summary = summary
self.summary_label.setText(_summary_text(summary, profile))
def planned_steps(self) -> list[SequenceStep]:
"""The steps the table describes, for the future runner.
@@ -432,6 +566,10 @@ class SequencePanel(QGroupBox):
for key, combo in self._choices.items():
_fill_choice(combo, key)
self._name_label.setText(t("sequence.field_name"))
self.name_edit.setPlaceholderText(t("sequence.name_hint"))
self._note_label.setText(t("sequence.field_note"))
self.preview_label.setText(t("sequence.preview_placeholder"))
self.preview_hint.setText(t("sequence.preview_hint"))
for key, _locale_key in VALIDATION_CHECKS:
@@ -442,6 +580,9 @@ class SequencePanel(QGroupBox):
self._pending_buttons[key].setToolTip(
t("sequence.pending_tooltip", phase=PENDING_PHASES[key])
)
for key in LIVE_CONTROLS:
self._live_buttons[key].setText(t(f"sequence.btn_{key}"))
self._live_buttons[key].setToolTip(t("sequence.import_export_tip"))
self.table.setHorizontalHeaderLabels([t(locale_key) for _key, locale_key in POINT_COLUMNS])
for row in range(self.table.rowCount()):
@@ -449,6 +590,33 @@ class SequencePanel(QGroupBox):
if item is not None and item.text() in ("", t("sequence.status_pending")):
item.setText(t("sequence.status_pending"))
self.point_hint.setText(t("sequence.points_hint"))
self.refresh_summary()
def _summary_text(summary: dict[str, Any], profile: dict[str, Any]) -> str:
"""The count / span / duration line under the point list.
Spans are ``min–max (span)`` with a nil span printed once; the duration is
labelled an estimate because it is one.
"""
count = int(summary.get("count") or 0)
enabled = int(summary.get("enabled") or count)
if not count:
return t("sequence.summary_empty")
spans = " · ".join(
f"{axis.upper()} {format_span(summary['min_mm'].get(axis), summary['max_mm'].get(axis))}"
for axis in "xyz"
if axis in summary.get("min_mm", {})
)
duration = format_duration(summary.get("estimated_duration_s"))
return t(
"sequence.summary",
count=count,
enabled=enabled,
spans=spans,
duration=duration,
)
def _fill_choice(combo: QComboBox, key: str) -> None:
@@ -314,6 +314,11 @@ class RunBar(QFrame):
self.step_label.setMinimumWidth(140)
layout.addWidget(self.step_label)
# Shown only while idle, in place of the step/progress/timing trio.
self._idle_hint = QLabel(self)
self._idle_hint.setObjectName("hintLabel")
layout.addWidget(self._idle_hint)
self.progress = QProgressBar(self)
self.progress.setTextVisible(False)
self.progress.setFixedHeight(12)
@@ -395,6 +400,14 @@ class RunBar(QFrame):
self.abort_button.setEnabled(self._has_run)
self.pause_button.setEnabled(self._has_run and busy)
self.run_button.setEnabled(not busy)
# With no run, "point 0/0" and "elapsed 0:00" are noise on a strip whose
# job is to be read at a glance. Idle shows the phase and why instead.
live = self._has_run or busy
self.step_label.setVisible(live)
self.progress.setVisible(live)
self.time_label.setVisible(live)
if not live:
self._idle_hint.setText(t("run.no_steps"))
@property
def phase(self) -> str:
@@ -415,6 +428,7 @@ class RunBar(QFrame):
else:
self.step_label.setText(t("run.no_steps"))
self.time_label.setText(t("run.elapsed_only", elapsed=_duration(0.0)))
self._refresh_buttons(self._phase in ("running", "paused"))
def _duration(seconds: float) -> str:
@@ -38,6 +38,14 @@ from PySide6.QtWidgets import (
from .. import __version__ as app_version
from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot
from ..preferences import load_preferences, save_preferences
from ..sequence_io import (
FILE_SUFFIX,
SequenceDocument,
SequenceFormatError,
load,
save,
to_document,
)
from .axis_panel import AxisPanel
from .axis_readout import AxisReadoutPanel
from .brand_bar import BrandBar
@@ -242,6 +250,8 @@ class AutoCalMainWindow(QMainWindow):
self.run_bar.runRequested.connect(self._on_run_requested)
self.run_bar.pauseRequested.connect(self._on_run_pause)
self.run_bar.abortRequested.connect(self._on_run_abort)
self.sequence_panel.exportRequested.connect(self._on_export_sequence)
self.sequence_panel.importRequested.connect(self._on_import_sequence)
self.axis_readout.stopAllRequested.connect(self._on_stop_all)
self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms)
self.axis_readout.enableAllRequested.connect(self._on_enable_all)
@@ -272,6 +282,106 @@ class AutoCalMainWindow(QMainWindow):
self.statusBar().showMessage(text)
self.status_strip.set_action_result(text, ok)
# ------------------------------------------------------- sequence import/export
def _default_sequence_path(self) -> str:
"""Start the file dialog somewhere useful and named after the profile."""
stem = self.sequence_panel.name() or "sequence"
return str(Path(outputs_dir()) / "sequences" / f"{stem}{FILE_SUFFIX}")
def _sequence_device_ref(self) -> dict:
setup = self.prefs.get("sensor_setup") or {}
return {
"sensor_type": str(setup.get("sensor_type") or ""),
"config_ref": str(setup.get("config_ref") or ""),
"geometry_id": str(setup.get("geometry_id") or ""),
"probe_tip_set": bool(setup.get("probe_tip_set")),
}
def export_sequence(self, path: str) -> str:
"""Write the current design. Returns the path written."""
document = to_document(
profile=self.sequence_panel.profile(),
points=self.sequence_panel.points(),
name=self.sequence_panel.name(),
note=self.sequence_panel.note(),
device=self._sequence_device_ref(),
units_per_mm=float(self.prefs.get("units_per_mm") or 0.0),
app_version=app_version,
shared_version=shared_version,
shared_api=API_VERSION,
)
written = save(document, path)
self.sequence_panel.mark_clean()
return str(written)
def import_sequence(self, path: str) -> SequenceDocument:
"""Read a design and apply it. Raises SequenceFormatError on a bad file."""
document = load(path, current_profile=self.sequence_panel.profile())
self.sequence_panel.set_profile(document.profile)
self.sequence_panel.set_points(document.points)
self.sequence_panel.set_name(document.name)
self.sequence_panel.set_note(document.note)
self.sequence_panel.mark_clean()
return document
def _on_export_sequence(self) -> None:
path, _selected = QFileDialog.getSaveFileName(
self,
t("sequence.dialog_export"),
self._default_sequence_path(),
t("sequence.filter_sequence"),
)
if not path:
return
try:
written = self.export_sequence(path)
except (OSError, ValueError) as exc:
self._report_sequence(t("sequence.export_failed", error=str(exc)), ok=False)
return
self._report_sequence(
t("sequence.exported", name=Path(written).name), ok=True
)
def _on_import_sequence(self) -> None:
# Import replaces the design and there is no autosave, so an unsaved
# sequence is confirmed before it is discarded.
if self.sequence_panel.dirty:
answer = QMessageBox.question(
self,
t("sequence.dialog_import"),
t("sequence.confirm_discard"),
)
if answer != QMessageBox.StandardButton.Yes:
return
path, _selected = QFileDialog.getOpenFileName(
self,
t("sequence.dialog_import"),
str(Path(outputs_dir()) / "sequences"),
t("sequence.filter_sequence"),
)
if not path:
return
try:
document = self.import_sequence(path)
except SequenceFormatError as exc:
self._report_sequence(t("sequence.import_failed", error=str(exc)), ok=False)
return
self._report_sequence(
t("sequence.imported", name=Path(path).name, count=document.point_count), ok=True
)
if document.warnings:
self.statusBar().showMessage(
t("sequence.import_warnings", count=len(document.warnings))
)
for warning in document.warnings:
logger.warning("Sequence import: %s", warning)
def _report_sequence(self, text: str, *, ok: bool) -> None:
self.statusBar().showMessage(text)
self.status_strip.set_action_result(text, ok)
def _apply_chrome_colours(self) -> None:
"""Style the status bar from the theme rather than Qt's default.