migrate pyqt5 to pyside6
This commit is contained in:
@@ -29,7 +29,8 @@ Replaces the vendor example program it was ported from:
|
||||
## Run it
|
||||
|
||||
PySide6 only — Qt's LGPLv3 binding, so an internal closed-source tool needs no
|
||||
licence purchase. (`PyQt5`, which the studio app uses, is GPLv3-or-commercial.)
|
||||
licence purchase. (PyQt5 is GPLv3-or-commercial; the studio app moved to PySide6
|
||||
for the same reason and no longer uses it.)
|
||||
|
||||
```powershell
|
||||
# with the environment that already has PySide6 (Anaconda py310, for example)
|
||||
@@ -130,8 +131,10 @@ The suite is written for **PySide6**. Where PySide6 is missing, the test modules
|
||||
are skipped rather than failed: a broken environment should not look like a
|
||||
broken application. `tests/test_binding_purity.py` enforces the binding policy
|
||||
statically — no module may import PyQt5/PySide2/PyQt6, no module may use an
|
||||
unscoped Qt enumerator (removed in PySide6), `shared/` may import no binding at
|
||||
all, and the controller interface must stay importable headless.
|
||||
unscoped Qt enumerator (removed in PySide6), `shared/` may import PySide6 and no
|
||||
other binding, and the controller interface must stay importable headless. The
|
||||
studio app enforces the same rules for itself in
|
||||
`apps/studio/tests/test_binding_purity.py`.
|
||||
|
||||
## Do not ship this app
|
||||
|
||||
|
||||
@@ -196,6 +196,8 @@
|
||||
"validation_title": "Checks",
|
||||
"points_title": "Calibration points",
|
||||
"field_mode": "Surface mode",
|
||||
"field_name": "Sequence name",
|
||||
"field_note": "Note",
|
||||
"field_pitch": "Pitch (mm)",
|
||||
"field_margin": "Margin (mm)",
|
||||
"field_radius": "Radius (mm)",
|
||||
@@ -221,8 +223,11 @@
|
||||
"btn_generate": "Generate points",
|
||||
"btn_simulate": "Simulate",
|
||||
"btn_dry_run": "Dry run (no contact)",
|
||||
"btn_save": "Save sequence…",
|
||||
"btn_load": "Load sequence…",
|
||||
"btn_export": "Export sequence…",
|
||||
"btn_import": "Import sequence…",
|
||||
"import_export_tip": "Saved as a JSON document (.oryseq), reusable across machines; the format version is checked on import.",
|
||||
"export_failed": "Export failed: {error}",
|
||||
"import_failed": "Import failed: {error}",
|
||||
"pending_tooltip": "This belongs to phase {phase} and is not implemented yet.",
|
||||
"col_index": "#",
|
||||
"col_x": "X (mm)",
|
||||
@@ -234,6 +239,16 @@
|
||||
"col_actions": "Actions",
|
||||
"not_generated": "—",
|
||||
"status_pending": "Pending",
|
||||
"status_skipped": "Skipped",
|
||||
"summary": "{count} points ({enabled} enabled) · {spans} · about {duration} (estimate)",
|
||||
"summary_empty": "No points yet: generate or import to see the count and travel span.",
|
||||
"dialog_export": "Export sequence",
|
||||
"dialog_import": "Import sequence",
|
||||
"filter_sequence": "Calibration sequence (*.oryseq *.json)",
|
||||
"exported": "Sequence exported: {name}",
|
||||
"imported": "Sequence imported: {name} ({count} points)",
|
||||
"import_warnings": "Import reported {count} warning(s)",
|
||||
"confirm_discard": "The current sequence has not been exported. Importing replaces it. Continue?",
|
||||
"points_hint": "Coordinates are generated and checked per point; they are not typed. A typed coordinate is one nothing has checked, and one wrong number ruins a run."
|
||||
},
|
||||
"action": {
|
||||
|
||||
@@ -196,6 +196,8 @@
|
||||
"validation_title": "校验",
|
||||
"points_title": "标定点列表",
|
||||
"field_mode": "曲面模式",
|
||||
"field_name": "序列名称",
|
||||
"field_note": "备注",
|
||||
"field_pitch": "点距 (mm)",
|
||||
"field_margin": "边界余量 (mm)",
|
||||
"field_radius": "曲面半径 (mm)",
|
||||
@@ -221,8 +223,11 @@
|
||||
"btn_generate": "生成标定点",
|
||||
"btn_simulate": "仿真",
|
||||
"btn_dry_run": "干跑(不接触)",
|
||||
"btn_save": "保存序列…",
|
||||
"btn_load": "载入序列…",
|
||||
"btn_export": "导出序列…",
|
||||
"btn_import": "导入序列…",
|
||||
"import_export_tip": "序列以 JSON 文档保存(.oryseq),可跨机器复用;导入前会校验格式版本。",
|
||||
"export_failed": "导出失败: {error}",
|
||||
"import_failed": "导入失败: {error}",
|
||||
"pending_tooltip": "该功能属于 {phase} 阶段,尚未实现。",
|
||||
"col_index": "#",
|
||||
"col_x": "X (mm)",
|
||||
@@ -234,6 +239,16 @@
|
||||
"col_actions": "操作",
|
||||
"not_generated": "—",
|
||||
"status_pending": "待测",
|
||||
"status_skipped": "已跳过",
|
||||
"summary": "{count} 点({enabled} 启用) · {spans} · 预计 {duration}(估计值)",
|
||||
"summary_empty": "尚无标定点:生成或导入后显示点数与行程范围。",
|
||||
"dialog_export": "导出序列",
|
||||
"dialog_import": "导入序列",
|
||||
"filter_sequence": "标定序列 (*.oryseq *.json)",
|
||||
"exported": "已导出序列: {name}",
|
||||
"imported": "已导入序列: {name}({count} 点)",
|
||||
"import_warnings": "导入有 {count} 项提示",
|
||||
"confirm_discard": "当前序列尚未导出,导入将替换它。是否继续?",
|
||||
"points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。"
|
||||
},
|
||||
"action": {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# Calibration sequence format (`.oryseq`)
|
||||
|
||||
A calibration sequence is a small JSON document: the **profile** that generated
|
||||
the points, plus the **points** themselves. It is written by
|
||||
`导出序列…` in the Sequence tab and read by `导入序列…`.
|
||||
|
||||
Why JSON and not a binary container: a sequence is at most a few hundred points
|
||||
of text. It is read by a human, diffed in review, and emailed to whoever runs the
|
||||
bench. Binary would buy nothing and cost all three. (The *acquired data* of a run
|
||||
is a different problem — per-point time series are large and will need a sidecar;
|
||||
that decision belongs to workflow P8, not here.)
|
||||
|
||||
## Conventions
|
||||
|
||||
The format follows this repository's existing documents rather than inventing a
|
||||
convention. Where it matches `session.json` and `force_calibration.json`, that is
|
||||
deliberate:
|
||||
|
||||
| Convention | Value |
|
||||
|---|---|
|
||||
| Version field | `"format_version": "1.0"` — a dotted **string**, compared as a tuple |
|
||||
| Identity | `"kind": "autocal.sequence"` — checked on import |
|
||||
| Extension | `.oryseq` by convention; import accepts any `.json` |
|
||||
| Encoding | UTF-8, `indent=2`, `ensure_ascii=False`, trailing newline |
|
||||
| Write | temp file then `Path.replace` — a half-written sequence that still parses looks like a design |
|
||||
|
||||
## The document
|
||||
|
||||
```json
|
||||
{
|
||||
"format_version": "1.0",
|
||||
"kind": "autocal.sequence",
|
||||
"created_at": "2026-09-20T10:31:00+00:00",
|
||||
"name": "5 点灵敏度扫描",
|
||||
"note": "",
|
||||
"versions": { "app": "0.1.0", "shared": "0.1.0", "shared_api": "0.1" },
|
||||
"device": {
|
||||
"sensor_type": "box",
|
||||
"config_ref": "…\\config\\sensors\\box\\sensor.json",
|
||||
"geometry_id": "finger-1.0.0",
|
||||
"probe_tip_set": true
|
||||
},
|
||||
"frame": { "targets": "platform", "length_unit": "mm", "units_per_mm": 10.0 },
|
||||
"profile": {
|
||||
"mode": "flat", "pitch_mm": 2.0, "margin_mm": 1.0, "radius_mm": 0.0,
|
||||
"clear_height_mm": 5.0, "approach_speed_mm_s": 2.0, "press_speed_mm_s": 0.5,
|
||||
"press_depth_mm": 0.2, "dwell_s": 0.5, "settle_window_s": 0.5,
|
||||
"repeats": 3, "order": "serpentine"
|
||||
},
|
||||
"points": [
|
||||
{
|
||||
"id": 1,
|
||||
"target_mm": { "x": 0.0, "y": 0.0, "z": 12.4 },
|
||||
"sensor_mm": { "x": 0.0, "y": 0.0, "z": 0.0 },
|
||||
"clearance_mm": 5.0,
|
||||
"enabled": true,
|
||||
"note": ""
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"count": 12, "enabled": 12,
|
||||
"min_mm": { "x": 0.0, "y": 0.0, "z": 12.4 },
|
||||
"max_mm": { "x": 10.0, "y": 4.0, "z": 12.4 },
|
||||
"span_mm": { "x": 10.0, "y": 4.0, "z": 0.0 },
|
||||
"pitch_mm": 2.0,
|
||||
"estimated_duration_s": 380.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Fields
|
||||
|
||||
### `frame` — required
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `targets` | the frame `target_mm` is expressed in; today always `"platform"` |
|
||||
| `length_unit` | `"mm"`; anything else is refused |
|
||||
| `units_per_mm` | the pulse equivalent the points were built with |
|
||||
|
||||
A sequence whose numbers do not say whether they are millimetres or user units is
|
||||
a sequence that will eventually be read wrong. `units_per_mm` is recorded so a
|
||||
file from a differently-configured axis is detectable rather than silently
|
||||
believed.
|
||||
|
||||
### `profile` — required
|
||||
|
||||
The twelve fields the Sequence tab edits, and nothing else. A field absent from
|
||||
the file is **kept at its current value and reported**, never silently defaulted
|
||||
to another bench's number.
|
||||
|
||||
### `points` — required, may be empty
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `id` | 1-based, stable within the file; used for run progress and resume |
|
||||
| `target_mm` | where the axes go — required, with all three of `x`, `y`, `z` |
|
||||
| `sensor_mm` | where that point lands on the sensor; optional but written |
|
||||
| `clearance_mm` | the standoff for this point |
|
||||
| `enabled` | false = designed but deliberately skipped |
|
||||
| `note` | free text |
|
||||
|
||||
Both positions are stored on purpose. The platform target is what the run uses;
|
||||
the sensor position is what makes the point checkable against the surface.
|
||||
Deriving one from the other on every load would silently change what the points
|
||||
mean the moment a sensor is re-fixtured — and a re-fixture is exactly when a
|
||||
stored transform should be *noticed*, not recomputed from.
|
||||
|
||||
### `summary` — derived, not authoritative
|
||||
|
||||
Present so a human reading the file sees the point count and travel span without
|
||||
counting rows, and so a diff is legible. It is **recomputed on load**; a mismatch
|
||||
is reported as a warning and the recomputed values win. That is how a hand-edited
|
||||
document announces itself instead of quietly disagreeing with its own contents.
|
||||
|
||||
`estimated_duration_s` assumes a constant traverse rate
|
||||
(`sequence_io.ASSUMED_TRAVERSE_MM_S`) and ignores acceleration. It is a planning
|
||||
figure and is labelled an estimate wherever it is shown.
|
||||
|
||||
### `device` — a reference, not a requirement
|
||||
|
||||
Records which sensor the sequence was designed for. Importing a sequence for
|
||||
another sensor is **allowed and warned about**, because re-fixturing
|
||||
deliberately is a normal thing to do.
|
||||
|
||||
## Import rules
|
||||
|
||||
Every rejection names the specific problem — `invalid file` tells an operator
|
||||
nothing.
|
||||
|
||||
| Condition | Behaviour |
|
||||
|---|---|
|
||||
| not JSON | refused, with line and column |
|
||||
| top level not an object | refused |
|
||||
| `kind` missing or different | refused, naming what was found |
|
||||
| `format_version` missing or not a string | refused |
|
||||
| `format_version` newer than this build | refused, naming both versions |
|
||||
| `frame` missing, or `length_unit` not `mm` | refused |
|
||||
| `profile` not an object | refused |
|
||||
| a profile key absent | imported; the current value is kept and the key is reported |
|
||||
| `points` absent | imported as profile-only, and reported |
|
||||
| a point without `target_mm.x/y/z` | refused, naming the point and the axis |
|
||||
| `summary` disagreeing with `points` | imported; recomputed, and reported |
|
||||
| current design is unsaved | the operator is asked before it is replaced |
|
||||
|
||||
Version strings are compared as tuples, never as strings: `"1.10"` is newer than
|
||||
`"1.9"`, and a string comparison would make a newer file look older and load it
|
||||
as if it were supported.
|
||||
|
||||
## Compatibility rules
|
||||
|
||||
- **Adding an optional key** does not change `format_version`.
|
||||
- **Adding a required key, or changing a field's meaning or unit**, does.
|
||||
- A reader must refuse a document whose version it does not support rather than
|
||||
interpreting the parts it recognises — a sequence half-applied is worse than
|
||||
one rejected.
|
||||
- `1.x` readers are expected to accept `1.y` for `y <= x` on the same major
|
||||
version and report anything they did not use.
|
||||
|
||||
## Where the pieces live
|
||||
|
||||
| Concern | Module |
|
||||
|---|---|
|
||||
| The document, validation, the derived summary | `apps/autocal/src/orisys_autocal/sequence_io.py` |
|
||||
| The design screen and its controls | `apps/autocal/src/orisys_autocal/ui/sequence_panel.py` |
|
||||
| The file dialogs and the unsaved-work guard | `apps/autocal/src/orisys_autocal/ui/window.py` |
|
||||
| Why a sequence is an artifact (P8) | `apps/autocal/docs/workflow.md` |
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -66,6 +66,21 @@ def _scratch_dir() -> Path:
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_dir():
|
||||
"""A workspace-local scratch directory, cleared per test.
|
||||
|
||||
Deliberately not ``tmp_path``: this suite has to run inside a sandboxed
|
||||
shell, and ``tmp_path`` depends on a writable process temp root that such an
|
||||
environment may not provide.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
path = _scratch_dir()
|
||||
yield path
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_window(qt_app, monkeypatch):
|
||||
"""Build a window whose preference writes stay inside a scratch directory.
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
"""Binding policy for AutoCal.
|
||||
"""Binding policy for the Orisys apps.
|
||||
|
||||
AutoCal is **PySide6 only**, and this file is the guard for that:
|
||||
Both shipped apps are **PySide6 only**, and this file is AutoCal's guard for that:
|
||||
|
||||
* no module may import PyQt5, PySide2 or PyQt6 — the app's environment has
|
||||
PySide6 and nothing else, so such an import is a hard start-up failure;
|
||||
* no module may use an unscoped Qt enumerator (``Qt.AlignLeft``), which PySide6
|
||||
removed in favour of ``Qt.AlignmentFlag.AlignLeft``;
|
||||
* ``shared/`` — which serves both this app and the PyQt5-based studio — must
|
||||
import no Qt binding at all;
|
||||
* ``shared/`` may import PySide6 and nothing else: it is used by both apps, so it
|
||||
must not pull in a second binding, and the studio's own guard
|
||||
(``apps/studio/tests/test_binding_purity.py``) enforces the same rule from its
|
||||
side. (Until the studio moved to PySide6 this file forbade *any* binding in
|
||||
``shared/``; that rule existed for a two-binding workspace, which no longer
|
||||
exists. ``shared/theme/qss.py`` paints its chevron icons with PySide6 now, and
|
||||
under the old rule those imports were failing silently instead.)
|
||||
* the controller layer must stay importable from a headless process.
|
||||
|
||||
The reason the binding matters at all is licensing, not taste: PyQt5's free
|
||||
option is GPLv3-or-commercial, while PySide6 is LGPLv3, so an internal
|
||||
closed-source tool needs no licence purchase. See ``README.md``.
|
||||
option is GPLv3-or-commercial, while PySide6 is LGPLv3, so a closed-source tool
|
||||
needs no licence purchase. See ``README.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,13 +28,19 @@ from pathlib import Path
|
||||
APP_SRC = Path(__file__).resolve().parents[1] / "src"
|
||||
SHARED_SRC = Path(__file__).resolve().parents[3] / "shared" / "src"
|
||||
|
||||
#: Bindings the app must never import.
|
||||
#: Bindings no module may import.
|
||||
FORBIDDEN_BINDINGS = {"PyQt5", "PyQt6", "PySide2"}
|
||||
|
||||
#: The one binding allowed, and where it may be named.
|
||||
ALLOWED_BINDING = "PySide6"
|
||||
|
||||
#: Scoped enum holders we care about, with the value names actually used.
|
||||
#:
|
||||
#: Hand-maintained, and therefore incomplete by construction: the PySide6 migration
|
||||
#: found ``QPainter.RenderHint.Antialiasing`` and ``QDialogButtonBox.StandardButton.Ok`` slipping through a list
|
||||
#: like this one. The studio's guard asks PySide6 instead (``getattr`` + enum check),
|
||||
#: which is the better rule; this map is kept because AutoCal cannot import the binding
|
||||
#: in a headless test run.
|
||||
_SCOPED_ENUMS = {
|
||||
"Qt": (
|
||||
"AlignmentFlag",
|
||||
@@ -41,6 +52,12 @@ _SCOPED_ENUMS = {
|
||||
"ToolButtonStyle",
|
||||
"FocusPolicy",
|
||||
"ScrollBarPolicy",
|
||||
"CursorShape",
|
||||
"MouseButton",
|
||||
"Key",
|
||||
"PenStyle",
|
||||
"GlobalColor",
|
||||
"ItemFlag",
|
||||
),
|
||||
"QTableWidget": ("SelectionMode", "EditTrigger"),
|
||||
"QSizePolicy": ("Policy",),
|
||||
@@ -48,6 +65,11 @@ _SCOPED_ENUMS = {
|
||||
"QMessageBox": ("StandardButton", "Icon"),
|
||||
"QDialog": ("DialogCode",),
|
||||
"QAbstractItemView": ("SelectionMode", "EditTrigger"),
|
||||
"QDialogButtonBox": ("StandardButton",),
|
||||
"QPainter": ("RenderHint", "CompositionMode"),
|
||||
"QImage": ("Format",),
|
||||
"QSurfaceFormat": ("RenderableType", "OpenGLContextProfile", "SwapBehavior"),
|
||||
"QFrame": ("Shape",),
|
||||
}
|
||||
|
||||
|
||||
@@ -72,11 +94,13 @@ def _unscoped_enum_references(path: Path) -> list[str]:
|
||||
"""Attribute reads like ``Qt.AlignLeft`` — the spelling PySide6 rejects.
|
||||
|
||||
Parsed rather than pattern-matched for two reasons: a regex also hits prose
|
||||
in a comment or docstring (which is exactly where the scoped spelling gets
|
||||
*explained*), and ``QMessageBox.information(...)`` looks like an enumerator
|
||||
but is a static method. The parent map separates the two, and
|
||||
``Qt.AlignmentFlag.AlignLeft`` is an inner attribute on an allow-listed enum
|
||||
class rather than a plain name, so it is never a candidate.
|
||||
in a comment or docstring (which is exactly where the *unscoped* spelling gets
|
||||
explained, so a rewrite would destroy the explanation — this file quotes it
|
||||
three times, which is why the migration scripts skip it), and
|
||||
``QMessageBox.information(...)`` looks like an enumerator but is a static
|
||||
method. The parent map separates the two, and ``Qt.AlignmentFlag.AlignLeft`` is
|
||||
an inner attribute on an allow-listed enum class rather than a plain name, so it
|
||||
is never a candidate.
|
||||
"""
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
called = {
|
||||
@@ -152,15 +176,22 @@ def test_the_controller_interface_is_qt_free():
|
||||
assert not offenders, offenders
|
||||
|
||||
|
||||
def test_shared_imports_no_qt_binding():
|
||||
"""Shared code serves both products, so it must not pick a side."""
|
||||
def test_shared_imports_no_forbidden_binding():
|
||||
"""Shared code serves both products, so it must not pull in a second binding.
|
||||
|
||||
PySide6 is allowed and expected (``shared/theme/qss.py`` paints the chevron icons with
|
||||
it); what must never appear here is PyQt5/PyQt6/PySide2, which would either fail to
|
||||
import in a PySide6-only environment or — worse — work in one app's environment and not
|
||||
the other's. Before the studio moved to PySide6 this test forbade PySide6 itself, and
|
||||
the PyQt5 import in ``qss.py`` was failing silently as a result.
|
||||
"""
|
||||
offenders = []
|
||||
for path in _python_files(SHARED_SRC):
|
||||
qt_imports = _imported_packages(path) & {"PySide2", "PySide6"}
|
||||
qt_imports = _imported_packages(path) & FORBIDDEN_BINDINGS
|
||||
if qt_imports:
|
||||
offenders.append(f"{path.name}: {sorted(qt_imports)}")
|
||||
assert not offenders, (
|
||||
"shared code must not import PySide2/PySide6 (studio is PyQt5): "
|
||||
"shared code may import PySide6 only; these files import another binding: "
|
||||
f"{offenders}"
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import pytest
|
||||
|
||||
from conftest import pump
|
||||
|
||||
from orisys_autocal.sequence_io import SequenceFormatError, SequencePoint
|
||||
from orisys_autocal.ui.workspace import TABS
|
||||
|
||||
|
||||
@@ -187,13 +188,7 @@ def test_every_pending_control_names_the_phase_that_owns_it(make_window):
|
||||
"""A disabled control must say why, or it reads as a broken button."""
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
assert panel.pending_controls() == [
|
||||
"generate",
|
||||
"simulate",
|
||||
"dry_run",
|
||||
"save",
|
||||
"load",
|
||||
]
|
||||
assert panel.pending_controls() == ["generate", "simulate", "dry_run"]
|
||||
for name, phase in panel.pending_phases().items():
|
||||
button = panel._pending_buttons[name]
|
||||
assert not button.isEnabled(), f"{name} is usable but not implemented"
|
||||
@@ -201,6 +196,24 @@ def test_every_pending_control_names_the_phase_that_owns_it(make_window):
|
||||
assert button.text(), f"{name} has no label"
|
||||
|
||||
|
||||
def test_import_and_export_are_live_controls(make_window):
|
||||
"""A sequence is a document, and P8 says it is a versioned artifact."""
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
for name in ("export", "import"):
|
||||
button = panel._live_buttons[name]
|
||||
assert button.isEnabled(), f"{name} is not usable"
|
||||
assert button.text(), f"{name} has no label"
|
||||
assert button.toolTip()
|
||||
|
||||
|
||||
def test_the_point_list_has_no_empty_action_column(make_window):
|
||||
"""An empty action cell reads as a broken control."""
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
assert "actions" not in panel.column_keys()
|
||||
|
||||
|
||||
def test_the_sequence_tab_hands_out_no_runnable_steps(make_window):
|
||||
"""A runner must not be able to start from points nothing has validated."""
|
||||
window = _show(make_window())
|
||||
@@ -456,6 +469,110 @@ def test_the_status_bar_uses_its_own_theme_token(make_window):
|
||||
assert "QStatusBar" in window.statusBar().styleSheet()
|
||||
|
||||
|
||||
def test_the_sequence_summary_reports_count_and_span(make_window):
|
||||
"""The two numbers an operator reasons with, before anything runs."""
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
panel.set_points(
|
||||
[
|
||||
SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4}),
|
||||
SequencePoint(id=2, target_mm={"x": 10.0, "y": 0.0, "z": 12.4}),
|
||||
]
|
||||
)
|
||||
text = panel.summary_label.text()
|
||||
assert "2" in text
|
||||
# A nil Z span prints once, and the real X span prints both ends.
|
||||
assert "0.000–10.000" in text
|
||||
assert "12.400" in text
|
||||
|
||||
|
||||
def test_the_sequence_summary_updates_with_the_profile(make_window):
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
panel.set_points([SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 1.0})])
|
||||
before = panel.summary_label.text()
|
||||
panel.field("repeats").setText("10")
|
||||
assert panel.summary_label.text() != before
|
||||
|
||||
|
||||
def test_an_empty_sequence_says_so_rather_than_showing_zeros(make_window):
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
panel.set_points([])
|
||||
assert panel.summary_label.text()
|
||||
assert "0" not in panel.summary_label.text()[:2]
|
||||
|
||||
|
||||
def test_the_design_is_dirty_until_it_is_saved_or_loaded(make_window):
|
||||
"""Import replaces the design, and there is no autosave."""
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
panel.mark_clean()
|
||||
assert not panel.dirty
|
||||
panel.field("pitch_mm").setText("9")
|
||||
assert panel.dirty, "a profile edit did not mark the design dirty"
|
||||
|
||||
|
||||
def test_export_then_import_restores_the_design(make_window, tmp_dir):
|
||||
"""The round trip through the real panel, not just the format module."""
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
panel.set_name("bench sweep")
|
||||
panel.field("pitch_mm").setText("3.5")
|
||||
panel.set_points(
|
||||
[
|
||||
SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4}),
|
||||
SequencePoint(id=2, target_mm={"x": 3.5, "y": 0.0, "z": 12.4}),
|
||||
]
|
||||
)
|
||||
path = tmp_dir / "sweep.oryseq"
|
||||
window.export_sequence(str(path))
|
||||
assert path.is_file()
|
||||
assert not panel.dirty, "export did not clear the dirty flag"
|
||||
|
||||
# Change everything, then import it back.
|
||||
panel.set_name("")
|
||||
panel.field("pitch_mm").setText("1")
|
||||
panel.set_points([])
|
||||
document = window.import_sequence(str(path))
|
||||
|
||||
assert document.point_count == 2
|
||||
assert panel.name() == "bench sweep"
|
||||
assert panel.profile()["pitch_mm"] == 3.5
|
||||
assert panel.point_row_count() == 2
|
||||
assert not panel.dirty
|
||||
|
||||
|
||||
def test_importing_a_bad_file_reports_and_changes_nothing(make_window, tmp_dir):
|
||||
window = _show(make_window())
|
||||
panel = window.sequence_panel
|
||||
panel.field("pitch_mm").setText("4")
|
||||
path = tmp_dir / "bad.oryseq"
|
||||
path.write_text("not json at all", encoding="utf-8")
|
||||
with pytest.raises(SequenceFormatError) as excinfo:
|
||||
window.import_sequence(str(path))
|
||||
assert "not valid JSON" in str(excinfo.value)
|
||||
# The design is untouched: a refused import is not a partial import.
|
||||
assert panel.profile()["pitch_mm"] == 4
|
||||
|
||||
|
||||
def test_an_exported_sequence_names_the_sensor_it_was_designed_for(make_window, tmp_dir):
|
||||
import json
|
||||
|
||||
window = _show(make_window())
|
||||
window.prefs["sensor_setup"] = {
|
||||
"sensor_type": "finger",
|
||||
"geometry_id": "finger-1.0.0",
|
||||
"probe_tip_set": True,
|
||||
}
|
||||
path = tmp_dir / "named.oryseq"
|
||||
window.export_sequence(str(path))
|
||||
document = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert document["device"]["sensor_type"] == "finger"
|
||||
assert document["device"]["geometry_id"] == "finger-1.0.0"
|
||||
assert document["versions"]["app"]
|
||||
|
||||
|
||||
def test_language_switch_relabels_the_new_shell(make_window):
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"""The calibration sequence document: round trip, refusals, derived summary.
|
||||
|
||||
The format is a contract with another machine's bench, so most of these tests
|
||||
are about what happens when the file is *wrong* — a sequence half-applied is
|
||||
worse than one rejected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_autocal.sequence_io import (
|
||||
DOCUMENT_KIND,
|
||||
FORMAT_VERSION,
|
||||
SequenceFormatError,
|
||||
SequencePoint,
|
||||
_version_tuple,
|
||||
format_duration,
|
||||
format_span,
|
||||
from_document,
|
||||
load,
|
||||
save,
|
||||
summarise,
|
||||
to_document,
|
||||
)
|
||||
|
||||
#: Where scratch files go. Deliberately not ``tmp_dir``: this suite has to run
|
||||
#: in a sandboxed shell, and ``tmp_dir`` needs a writable process temp root that
|
||||
#: such an environment may not provide (the same reason ``conftest.make_window``
|
||||
#: avoids it).
|
||||
_SCRATCH = Path(__file__).resolve().parents[1] / "outputs" / "_test_sequence_io"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_dir():
|
||||
shutil.rmtree(_SCRATCH, ignore_errors=True)
|
||||
_SCRATCH.mkdir(parents=True, exist_ok=True)
|
||||
yield _SCRATCH
|
||||
shutil.rmtree(_SCRATCH, ignore_errors=True)
|
||||
|
||||
PROFILE = {
|
||||
"mode": "flat",
|
||||
"pitch_mm": 2.0,
|
||||
"margin_mm": 1.0,
|
||||
"radius_mm": 0.0,
|
||||
"clear_height_mm": 5.0,
|
||||
"approach_speed_mm_s": 2.0,
|
||||
"press_speed_mm_s": 0.5,
|
||||
"press_depth_mm": 0.2,
|
||||
"dwell_s": 0.5,
|
||||
"settle_window_s": 0.5,
|
||||
"repeats": 3,
|
||||
"order": "serpentine",
|
||||
}
|
||||
|
||||
|
||||
def _points(count: int = 6) -> list[SequencePoint]:
|
||||
return [
|
||||
SequencePoint(
|
||||
id=index + 1,
|
||||
target_mm={"x": 2.0 * index, "y": 0.0, "z": 12.4},
|
||||
sensor_mm={"x": 2.0 * index, "y": 0.0, "z": 0.0},
|
||||
clearance_mm=5.0,
|
||||
)
|
||||
for index in range(count)
|
||||
]
|
||||
|
||||
|
||||
def _document(**overrides) -> dict:
|
||||
payload = to_document(
|
||||
profile=PROFILE,
|
||||
points=_points(),
|
||||
name="sweep",
|
||||
units_per_mm=10.0,
|
||||
app_version="0.1.0",
|
||||
shared_version="0.1.0",
|
||||
shared_api="0.1",
|
||||
)
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ round trip
|
||||
|
||||
|
||||
def test_a_document_round_trips_through_a_file(tmp_dir):
|
||||
path = tmp_dir / "sweep.oryseq"
|
||||
save(_document(), path)
|
||||
back = load(path, current_profile=PROFILE)
|
||||
assert back.point_count == 6
|
||||
assert back.name == "sweep"
|
||||
assert back.profile["pitch_mm"] == 2.0
|
||||
assert back.units_per_mm == 10.0
|
||||
assert not back.warnings
|
||||
# Both frames survive: the platform target and the sensor position.
|
||||
assert back.points[0].target_mm["z"] == pytest.approx(12.4)
|
||||
assert back.points[0].sensor_mm is not None
|
||||
|
||||
|
||||
def test_the_written_file_is_readable_json_with_the_house_style(tmp_dir):
|
||||
path = tmp_dir / "sweep.oryseq"
|
||||
save(_document(), path)
|
||||
text = path.read_text(encoding="utf-8")
|
||||
assert text.endswith("\n")
|
||||
assert "\n " in text, "the document is not indented"
|
||||
assert "\\u" not in text, "non-ASCII was escaped"
|
||||
payload = json.loads(text)
|
||||
assert payload["kind"] == DOCUMENT_KIND
|
||||
assert payload["format_version"] == FORMAT_VERSION
|
||||
|
||||
|
||||
def test_saving_leaves_no_temporary_file_behind(tmp_dir):
|
||||
path = tmp_dir / "sweep.oryseq"
|
||||
save(_document(), path)
|
||||
assert [entry.name for entry in tmp_dir.iterdir()] == ["sweep.oryseq"]
|
||||
|
||||
|
||||
def test_saving_over_an_existing_file_replaces_it(tmp_dir):
|
||||
path = tmp_dir / "sweep.oryseq"
|
||||
save(_document(), path)
|
||||
save(_document(name="second"), path)
|
||||
assert json.loads(path.read_text(encoding="utf-8"))["name"] == "second"
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- refusals
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("case", "mutate", "expected"),
|
||||
[
|
||||
("wrong kind", lambda d: d.update(kind="something.else"), "not a sequence document"),
|
||||
("missing version", lambda d: d.pop("format_version"), "format_version"),
|
||||
("version not a string", lambda d: d.update(format_version=1.0), "format_version"),
|
||||
("newer version", lambda d: d.update(format_version="2.0"), "newer AutoCal"),
|
||||
("missing frame", lambda d: d.pop("frame"), "frame"),
|
||||
("wrong unit", lambda d: d.update(frame={"length_unit": "um"}), "length_unit"),
|
||||
("missing profile", lambda d: d.pop("profile"), "profile"),
|
||||
("points not a list", lambda d: d.update(points={}), "must be a list"),
|
||||
("point not an object", lambda d: d.update(points=["nope"]), "not an object"),
|
||||
("point missing axis", lambda d: d.update(points=[{"target_mm": {"x": 1, "y": 2}}]), "missing 'z'"),
|
||||
("point without target", lambda d: d.update(points=[{}]), "no target_mm"),
|
||||
],
|
||||
)
|
||||
def test_a_bad_document_is_refused_with_a_specific_message(case, mutate, expected):
|
||||
document = _document()
|
||||
mutate(document)
|
||||
with pytest.raises(SequenceFormatError) as excinfo:
|
||||
from_document(document, current_profile=PROFILE)
|
||||
assert expected in str(excinfo.value), case
|
||||
|
||||
|
||||
def test_a_non_json_file_is_refused_with_its_position(tmp_dir):
|
||||
path = tmp_dir / "broken.oryseq"
|
||||
path.write_text("this is not json", encoding="utf-8")
|
||||
with pytest.raises(SequenceFormatError) as excinfo:
|
||||
load(path, current_profile=PROFILE)
|
||||
assert "not valid JSON" in str(excinfo.value)
|
||||
assert "line 1" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_a_missing_file_is_refused_not_crashed(tmp_dir):
|
||||
with pytest.raises(SequenceFormatError):
|
||||
load(tmp_dir / "absent.oryseq", current_profile=PROFILE)
|
||||
|
||||
|
||||
def test_a_json_array_is_refused(tmp_dir):
|
||||
path = tmp_dir / "array.oryseq"
|
||||
path.write_text("[1, 2, 3]", encoding="utf-8")
|
||||
with pytest.raises(SequenceFormatError) as excinfo:
|
||||
load(path, current_profile=PROFILE)
|
||||
assert "top level" in str(excinfo.value)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- summary
|
||||
|
||||
|
||||
def test_the_summary_reports_count_and_travel_span():
|
||||
summary = summarise(_points(6), PROFILE)
|
||||
assert summary["count"] == 6
|
||||
assert summary["min_mm"]["x"] == 0.0
|
||||
assert summary["max_mm"]["x"] == 10.0
|
||||
assert summary["span_mm"]["x"] == pytest.approx(10.0)
|
||||
# A constant coordinate has a nil span, and says so.
|
||||
assert summary["span_mm"]["z"] == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_the_summary_is_recomputed_and_wins_over_the_file():
|
||||
"""A hand-edited summary must not be believed."""
|
||||
document = _document()
|
||||
document["summary"]["count"] = 999
|
||||
document["summary"]["span_mm"]["x"] = 999.0
|
||||
back = from_document(document, current_profile=PROFILE)
|
||||
assert back.summary["count"] == 6
|
||||
assert back.summary["span_mm"]["x"] == pytest.approx(10.0)
|
||||
assert back.warnings and "disagrees" in back.warnings[0]
|
||||
|
||||
|
||||
def test_an_agreeing_summary_produces_no_warning():
|
||||
back = from_document(_document(), current_profile=PROFILE)
|
||||
assert not any("disagrees" in warning for warning in back.warnings)
|
||||
|
||||
|
||||
def test_no_points_imports_the_profile_and_says_so():
|
||||
document = _document()
|
||||
document.pop("points")
|
||||
back = from_document(document, current_profile=PROFILE)
|
||||
assert back.point_count == 0
|
||||
assert any("no points" in warning for warning in back.warnings)
|
||||
|
||||
|
||||
def test_a_missing_profile_key_keeps_the_current_value():
|
||||
document = _document()
|
||||
document["profile"].pop("pitch_mm")
|
||||
back = from_document(document, current_profile=PROFILE)
|
||||
assert back.profile["pitch_mm"] == 2.0
|
||||
assert any("pitch_mm" in warning for warning in back.warnings)
|
||||
|
||||
|
||||
def test_a_disabled_point_is_counted_separately():
|
||||
points = _points(4)
|
||||
points[1] = SequencePoint(id=2, target_mm=points[1].target_mm, enabled=False)
|
||||
summary = summarise(points, PROFILE)
|
||||
assert summary["count"] == 4
|
||||
assert summary["enabled"] == 3
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- duration
|
||||
|
||||
|
||||
def test_the_duration_estimate_scales_with_repeats_and_points():
|
||||
one = summarise(_points(1), {**PROFILE, "repeats": 1})["estimated_duration_s"]
|
||||
three = summarise(_points(1), {**PROFILE, "repeats": 3})["estimated_duration_s"]
|
||||
assert three > one
|
||||
six = summarise(_points(6), {**PROFILE, "repeats": 3})["estimated_duration_s"]
|
||||
assert six > three
|
||||
|
||||
|
||||
def test_no_points_means_no_estimate():
|
||||
summary = summarise([], PROFILE)
|
||||
assert summary["estimated_duration_s"] == 0.0
|
||||
assert format_duration(0.0) == "—"
|
||||
|
||||
|
||||
def test_the_duration_is_formatted_as_a_bench_timer():
|
||||
assert format_duration(72.0) == "1:12"
|
||||
assert format_duration(3600.0) == "1:00:00"
|
||||
assert format_duration(None) == "—"
|
||||
assert format_duration(float("nan")) == "—"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- spans
|
||||
|
||||
|
||||
def test_a_zero_span_prints_once_not_twice():
|
||||
assert format_span(12.4, 12.4) == "12.400"
|
||||
assert format_span(0.0, 0.0) == "0.000"
|
||||
|
||||
|
||||
def test_a_real_span_shows_both_ends_and_the_length():
|
||||
assert format_span(0.0, 10.0) == "0.000–10.000 (10.000)"
|
||||
|
||||
|
||||
def test_a_missing_span_is_a_dash():
|
||||
assert format_span(None, None) == "—"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- version
|
||||
|
||||
|
||||
def test_versions_compare_as_tuples_not_strings():
|
||||
"""``"1.10" < "1.9"`` as strings would load a newer file as if supported."""
|
||||
assert _version_tuple("1.10") > _version_tuple("1.9")
|
||||
assert _version_tuple("1.0") == (1, 0)
|
||||
assert _version_tuple("1") == (1,)
|
||||
# A same-major newer minor is still refused, but is *recognised* as newer.
|
||||
document = _document()
|
||||
document["format_version"] = "1.10"
|
||||
with pytest.raises(SequenceFormatError):
|
||||
from_document(document, current_profile=PROFILE)
|
||||
@@ -77,8 +77,8 @@ STRINGS = {
|
||||
"stop_all": "全部停止",
|
||||
"run_title": "运行",
|
||||
"run_phase": "空闲",
|
||||
"run_step": "第 0/0 点",
|
||||
"run_time": "已用 0:00 · 预计剩余 —",
|
||||
"run_idle_hint": "尚未生成标定点",
|
||||
|
||||
"run_run": "运行",
|
||||
"run_pause": "暂停",
|
||||
"run_abort": "中止",
|
||||
@@ -129,6 +129,9 @@ STRINGS = {
|
||||
"seq_preview": "预览",
|
||||
"seq_validation": "校验",
|
||||
"seq_points": "标定点列表",
|
||||
"seq_name": "序列名称",
|
||||
"seq_note": "备注",
|
||||
"seq_summary": "6 点(6 启用) · X 0.000–10.000 (10.000) · Y 0.000 · Z 12.400–13.400 (1.000) · 预计 2:24(估计值)",
|
||||
"seq_fields": [
|
||||
("曲面模式", "平面"), ("点距 (mm)", "2.000"), ("边界余量 (mm)", "1.000"),
|
||||
("曲面半径 (mm)", "—"), ("安全高度 (mm)", "5.000"), ("进给速度 (mm/s)", "2.000"),
|
||||
@@ -140,12 +143,12 @@ STRINGS = {
|
||||
"seq_preview_hint": "生成后可在此检查点是否落在曲面上、是否在行程内、接触能否被确认。",
|
||||
"seq_checks": ["在曲面范围内", "在行程内", "接触可确认"],
|
||||
"seq_checks_pending": "尚未生成标定点。",
|
||||
"seq_buttons": [("生成标定点", True), ("仿真", True), ("干跑(不接触)", True), ("保存序列…", True), ("载入序列…", True)],
|
||||
"point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "状态", "灵敏度(上次)", "样本", "操作"],
|
||||
"seq_buttons": [("生成标定点", True), ("仿真", True), ("干跑(不接触)", True), ("导出序列…", False), ("导入序列…", False)],
|
||||
"point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "状态", "灵敏度(上次)", "样本"],
|
||||
"point_rows": [
|
||||
["1", "0.000", "0.000", "12.400", "待测", "—", "—", ""],
|
||||
["2", "2.000", "0.000", "12.400", "待测", "—", "—", ""],
|
||||
["3", "4.000", "0.000", "12.400", "待测", "—", "—", ""],
|
||||
["1", "0.000", "0.000", "12.400", "待测", "—", "—"],
|
||||
["2", "2.000", "0.000", "12.400", "待测", "—", "—"],
|
||||
["3", "4.000", "0.000", "12.400", "待测", "—", "—"],
|
||||
],
|
||||
"points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。",
|
||||
"sensor_note": "设备与信号源、实时图像、力曲线",
|
||||
@@ -169,8 +172,8 @@ STRINGS = {
|
||||
"stop_all": "Stop all",
|
||||
"run_title": "Run",
|
||||
"run_phase": "Idle",
|
||||
"run_step": "Point 0/0",
|
||||
"run_time": "Elapsed 0:00 · about — left",
|
||||
"run_idle_hint": "No points generated",
|
||||
|
||||
"run_run": "Run",
|
||||
"run_pause": "Pause",
|
||||
"run_abort": "Abort",
|
||||
@@ -221,6 +224,9 @@ STRINGS = {
|
||||
"seq_preview": "Preview",
|
||||
"seq_validation": "Checks",
|
||||
"seq_points": "Calibration points",
|
||||
"seq_name": "Sequence name",
|
||||
"seq_note": "Note",
|
||||
"seq_summary": "6 points (6 enabled) · X 0.000-10.000 (10.000) · Y 0.000 · Z 12.400-13.400 (1.000) · about 2:24 (estimate)",
|
||||
"seq_fields": [
|
||||
("Surface mode", "Flat"), ("Pitch (mm)", "2.000"), ("Margin (mm)", "1.000"),
|
||||
("Radius (mm)", "—"), ("Clear height (mm)", "5.000"), ("Approach (mm/s)", "2.000"),
|
||||
@@ -232,12 +238,12 @@ STRINGS = {
|
||||
"seq_preview_hint": "After generation, check each point is on the surface, inside travel and confirmable by contact.",
|
||||
"seq_checks": ["On the surface", "Within travel", "Contact confirmable"],
|
||||
"seq_checks_pending": "No points generated yet.",
|
||||
"seq_buttons": [("Generate points", True), ("Simulate", True), ("Dry run (no contact)", True), ("Save sequence…", True), ("Load sequence…", True)],
|
||||
"point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "Status", "Sensitivity (last)", "Samples", "Actions"],
|
||||
"seq_buttons": [("Generate points", True), ("Simulate", True), ("Dry run (no contact)", True), ("Export sequence…", False), ("Import sequence…", False)],
|
||||
"point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "Status", "Sensitivity (last)", "Samples"],
|
||||
"point_rows": [
|
||||
["1", "0.000", "0.000", "12.400", "Pending", "—", "—", ""],
|
||||
["2", "2.000", "0.000", "12.400", "Pending", "—", "—", ""],
|
||||
["3", "4.000", "0.000", "12.400", "Pending", "—", "—", ""],
|
||||
["1", "0.000", "0.000", "12.400", "Pending", "—", "—"],
|
||||
["2", "2.000", "0.000", "12.400", "Pending", "—", "—"],
|
||||
["3", "4.000", "0.000", "12.400", "Pending", "—", "—"],
|
||||
],
|
||||
"points_hint": "Coordinates are generated and checked per point; they are not typed. A typed coordinate is one nothing has checked.",
|
||||
"sensor_note": "Device and source, live image, force curves",
|
||||
@@ -644,13 +650,22 @@ def draw_sequence_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect)
|
||||
profile = QRect(inner.x(), inner.y(), profile_w, top_h)
|
||||
p_inner = _panel(painter, profile, palette)
|
||||
_box_title(painter, profile, s["seq_profile"], palette)
|
||||
# the name and note the document carries
|
||||
y = p_inner.y() + 4
|
||||
_text(painter, QRect(p_inner.x(), y, 70, 22), s["seq_name"], palette, size=9, muted=True)
|
||||
_field(painter, QRect(p_inner.x() + 72, y, p_inner.width() - 78, 22), "5 点灵敏度扫描", palette)
|
||||
y += 26
|
||||
_text(painter, QRect(p_inner.x(), y, 70, 22), s["seq_note"], palette, size=9, muted=True)
|
||||
_field(painter, QRect(p_inner.x() + 72, y, p_inner.width() - 78, 22), "", palette)
|
||||
y += 28
|
||||
|
||||
column_w = p_inner.width() // 2
|
||||
for index, (label, value) in enumerate(s["seq_fields"]):
|
||||
row, side = divmod(index, 2)
|
||||
x = p_inner.x() + side * column_w
|
||||
y = p_inner.y() + 20 + row * 26
|
||||
_text(painter, QRect(x, y, 130, 22), label, palette, size=9, muted=True)
|
||||
_field(painter, QRect(x + 128, y, column_w - 140, 22), value, palette)
|
||||
row_y = y + row * 26
|
||||
_text(painter, QRect(x, row_y, 130, 22), label, palette, size=9, muted=True)
|
||||
_field(painter, QRect(x + 128, row_y, column_w - 140, 22), value, palette)
|
||||
|
||||
# --- preview
|
||||
preview = QRect(inner.x() + profile_w + gap, inner.y(), preview_w, top_h)
|
||||
@@ -699,7 +714,9 @@ def draw_sequence_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect)
|
||||
points = QRect(inner.x(), inner.y() + top_h + gap, inner.width(), inner.height() - top_h - gap)
|
||||
t_inner = _panel(painter, points, palette)
|
||||
_box_title(painter, points, s["seq_points"], palette)
|
||||
widths = (50, 150, 150, 150, 130, 190, 100)
|
||||
# Seven columns: no empty actions column, and the summary line carries the
|
||||
# count and span the operator reasons with.
|
||||
widths = (60, 150, 150, 150, 140, 190, 110)
|
||||
widths = widths + (t_inner.width() - sum(widths),)
|
||||
header_h = 28
|
||||
header = QRect(t_inner.x(), t_inner.y() + 4, t_inner.width(), header_h)
|
||||
@@ -719,7 +736,9 @@ def draw_sequence_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect)
|
||||
for value, width in zip(row, widths):
|
||||
_text(painter, QRect(x, row_y, width, row_h), value, palette, size=10, align=Qt.AlignmentFlag.AlignCenter)
|
||||
x += width
|
||||
y = header.bottom() + 1 + len(s["point_rows"]) * row_h + 10
|
||||
y = header.bottom() + 1 + len(s["point_rows"]) * row_h + 8
|
||||
_text(painter, QRect(t_inner.x(), y, t_inner.width(), 22), s["seq_summary"], palette, size=10)
|
||||
y += 24
|
||||
_text(painter, QRect(t_inner.x(), y, t_inner.width(), 34), s["points_hint"], palette, size=9, muted=True)
|
||||
|
||||
|
||||
@@ -917,13 +936,7 @@ def draw_run_bar(painter: QPainter, palette: Palette, s: dict, top: int) -> None
|
||||
x += painter.fontMetrics().horizontalAdvance(s["run_title"]) + 20
|
||||
_text(painter, QRect(x, top, 90, RUNBAR_H), s["run_phase"], palette, size=10, muted=True)
|
||||
x += 96
|
||||
_text(painter, QRect(x, top, 150, RUNBAR_H), s["run_step"], palette, size=10)
|
||||
x += 158
|
||||
|
||||
bar = QRect(x, top + 14, 300, 12)
|
||||
painter.fillRect(bar, palette.border)
|
||||
painter.fillRect(QRect(bar.x(), bar.y(), 3 * bar.width() // 4, bar.height()), palette.accent)
|
||||
_text(painter, QRect(bar.right() + 12, top, 260, RUNBAR_H), s["run_time"], palette, size=10, muted=True)
|
||||
_text(painter, QRect(x, top, 420, RUNBAR_H), s["run_idle_hint"], palette, size=9, muted=True)
|
||||
|
||||
_button(painter, QRect(PAGE_W - 330, top + 7, 96, 26), s["run_run"], palette, disabled=True)
|
||||
_button(painter, QRect(PAGE_W - 228, top + 7, 96, 26), s["run_pause"], palette, disabled=True)
|
||||
|
||||
@@ -8,8 +8,8 @@ inside parents, controls gated by connection and bus state, locales complete.
|
||||
python apps/autocal/tools/panel_check.py
|
||||
|
||||
No controller, no DLL and no display are needed. Mirrors
|
||||
``apps/studio/tools/ui_check.py``, but cannot import it: that module imports
|
||||
PyQt5, and AutoCal's environment has none.
|
||||
``apps/studio/tools/ui_check.py``, but cannot import it: that module pulls in
|
||||
``orisys_studio`` (the acquisition stack), which AutoCal's environment has none of.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -218,8 +218,8 @@ def main() -> int:
|
||||
"the sequence point list is empty",
|
||||
)
|
||||
checker.that(
|
||||
sequence.table.columnCount() == 8,
|
||||
f"the point list has {sequence.table.columnCount()} columns, expected 8",
|
||||
sequence.table.columnCount() == len(sequence.column_keys()),
|
||||
f"the point list has {sequence.table.columnCount()} columns, expected {len(sequence.column_keys())}",
|
||||
)
|
||||
|
||||
# The shell: one rail, a run bar, and a health chip.
|
||||
|
||||
Reference in New Issue
Block a user