migrate pyqt5 to pyside6
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user