Compare commits
82 Commits
b827c5ffee
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c1e6d21030 | |||
| 81063213f9 | |||
| 70b4341fd0 | |||
| 9d4563e929 | |||
| 768f560820 | |||
| e2cac0ae40 | |||
| ff5f089dc1 | |||
| 566f1bf390 | |||
| a225905b49 | |||
| 8a027bfcb9 | |||
| 570daae68f | |||
| d477a4d8b7 | |||
| 22658dbfc1 | |||
| c6c16df753 | |||
| 31750f0f84 | |||
| bc5dfc035d | |||
| a0c13c1dc6 | |||
| d89535236b | |||
| 55dafeb7fb | |||
| c37efd7a44 | |||
| 0998009ec3 | |||
| cf3e638176 | |||
| 9352d0c816 | |||
| fbf8c76bb5 | |||
| 433b76e43c | |||
| dcabd547fa | |||
| 9a3886b8cf | |||
| b4c5d65730 | |||
| 29bf672475 | |||
| 3dcca17d2e | |||
| e268a9dde3 | |||
| 797235c6a4 | |||
| 06e06126ca | |||
| 85d0b396e3 | |||
| 7b5ffa74f6 | |||
| 7607653c18 | |||
| 19426c3c4a | |||
| d7540f52ec | |||
| 2cec3d905b | |||
| 54e0b8006a | |||
| 31f6ef3881 | |||
| a3aeca1d12 | |||
| 2a154307fd | |||
| ad049d12e4 | |||
| bcc55a3b97 | |||
| 5339624ffa | |||
| 3f578c26c4 | |||
| dc5a6f8b5a | |||
| 1ca1bb739a | |||
| 812dd14715 | |||
| 02241813ce | |||
| 66c12a5c54 | |||
| 656e08482e | |||
| 7c6d84859d | |||
| 5a3ca51b29 | |||
| ee2b500cb7 | |||
| d06501d7d5 | |||
| 432f45325f | |||
| b200f3d55c | |||
| 161e64278b | |||
| 3304b4eda9 | |||
| 34a1d9b1f1 | |||
| 04adf17f60 | |||
| 7828bc1fe9 | |||
| 69c1322616 | |||
| dc7e290489 | |||
| 32dcecb270 | |||
| 12c2fb1c4b | |||
| 46c8c8cbf6 | |||
| 0e34cbfcdb | |||
| 6923cbf40c | |||
| c98390ff4a | |||
| 51accb9196 | |||
| c4663ef769 | |||
| f94b51e2de | |||
| 48de5b74e6 | |||
| cf86bd0702 | |||
| f5ba64e01a | |||
| 264275b1e6 | |||
| 768eeb2040 | |||
| d2122e60e1 | |||
| b15842ec00 |
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Optional pre-push gate for orisys-app — not installed automatically.
|
||||||
|
#
|
||||||
|
# git config core.hooksPath .githooks # turn it on
|
||||||
|
# git config --unset core.hooksPath # turn it off
|
||||||
|
#
|
||||||
|
# It runs the full merge gate (tools/gate.ps1: lint on the changed files, the studio
|
||||||
|
# suite, the shared suite, ui_check.py) and blocks the push when the gate fails. The
|
||||||
|
# gate needs Windows: it renders real Qt widgets offscreen with the project's
|
||||||
|
# interpreter. See tools/gate.ps1 and docs/test-repair-plan.zh.md §4.
|
||||||
|
#
|
||||||
|
# Windows PowerShell is used rather than pwsh so the hook works on a machine that only
|
||||||
|
# has the one PowerShell every Windows installation carries.
|
||||||
|
set -e
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
exec powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools/gate.ps1 -Task pre-push
|
||||||
@@ -12,7 +12,7 @@ Both shipped apps are **PySide6** — Qt's **LGPLv3** binding. PyQt5's free opti
|
|||||||
**GPLv3-or-commercial**, which is why the studio was migrated off it: a closed-source
|
**GPLv3-or-commercial**, which is why the studio was migrated off it: a closed-source
|
||||||
product would otherwise need a licence purchase. The rule is enforced, not just
|
product would otherwise need a licence purchase. The rule is enforced, not just
|
||||||
documented: neither app may import PyQt5/PyQt6/PySide2, and `shared/` (used by both) may
|
documented: neither app may import PyQt5/PyQt6/PySide2, and `shared/` (used by both) may
|
||||||
import PySide6 and nothing else. See `apps/studio/tests/test_binding_purity.py` and
|
import PySide6 and nothing else. See `apps/studio/tests/test_studio_binding_purity.py` and
|
||||||
`apps/autocal/tests/test_binding_purity.py`.
|
`apps/autocal/tests/test_binding_purity.py`.
|
||||||
|
|
||||||
One practical consequence: `pyqtgraph` binds to whichever toolkit it imports first, so a
|
One practical consequence: `pyqtgraph` binds to whichever toolkit it imports first, so a
|
||||||
@@ -109,6 +109,43 @@ mechanically enforced.
|
|||||||
why recording and calibration sampling no longer hang off the UI refresh timer,
|
why recording and calibration sampling no longer hang off the UI refresh timer,
|
||||||
and for the data-reliability fixes that came with it.
|
and for the data-reliability fixes that came with it.
|
||||||
|
|
||||||
|
### The merge gate
|
||||||
|
|
||||||
|
One command runs everything a change must not break, and it is the only check that
|
||||||
|
is meant to be unskippable:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File tools\gate.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
It runs `tools/lint_gate.py` over the changed files, then the full studio suite, the full
|
||||||
|
shared suite, and `ui_check.py` at the same time (`-Serial` runs those three one after
|
||||||
|
another with live output, for reading a failing stage); writes each one's output to
|
||||||
|
`.scratch/gate/gate-<run-id>/`; and holds the run to three contracts — an **empty**
|
||||||
|
`tools/known_failures.txt`, the skip ceilings and collection floors in
|
||||||
|
`tools/skip_budget.txt`, and zero findings anywhere. It exits 0 only when all of
|
||||||
|
that holds.
|
||||||
|
|
||||||
|
The lint step compares against a commit range, not only the dirty tree: `-LintBase <ref>`
|
||||||
|
defaults to `origin/main`, so a pre-push run checks every commit about to be pushed.
|
||||||
|
A ref that does not resolve is a configuration error (exit 2); the gate never quietly
|
||||||
|
falls back to linting uncommitted files only.
|
||||||
|
|
||||||
|
The **gate platform is Windows**: the suites render real Qt widgets offscreen, and two
|
||||||
|
checks are platform-sensitive ([`docs/test-repair-plan.zh.md`](docs/test-repair-plan.zh.md)
|
||||||
|
§3 #11 and #13), so a green Linux run is not the same statement.
|
||||||
|
|
||||||
|
An optional `pre-push` hook calls it, so a failing gate blocks the push. It is
|
||||||
|
deliberately not installed for you:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git config core.hooksPath .githooks # enable; `git config --unset core.hooksPath` disables
|
||||||
|
```
|
||||||
|
|
||||||
|
Day-to-day development still uses targeted test runs
|
||||||
|
([`docs/test-execution-plan.zh.md`](docs/test-execution-plan.zh.md)); the gate is what
|
||||||
|
runs before a change is committed or handed over.
|
||||||
|
|
||||||
## Branding
|
## Branding
|
||||||
|
|
||||||
Window / EXE icon: `shared/resources/branding/logo.png`
|
Window / EXE icon: `shared/resources/branding/logo.png`
|
||||||
|
|||||||
+33
-32
@@ -26,11 +26,22 @@ Replaces the vendor example program it was ported from:
|
|||||||
and RTEX parameters. Each family is enabled only when the controller reports
|
and RTEX parameters. Each family is enabled only when the controller reports
|
||||||
that bus type.
|
that bus type.
|
||||||
|
|
||||||
|
## Capture workflow
|
||||||
|
|
||||||
|
[Offline force-feedback scaffold](docs/force-control-scaffold.zh.md).
|
||||||
|
|
||||||
|
The 标定采集 page is one queue, not two panels — a single point is a queue with one
|
||||||
|
row. `Ready` lists the six facts a run needs and the **primary button follows the
|
||||||
|
step**, always stating why it is disabled. Alignment is only a request: the move
|
||||||
|
goes through the guarded pose-move path, and only a completed move unlocks capture.
|
||||||
|
Recording options (video, codec, lossless frames) are chosen before the session and
|
||||||
|
written once into `session.json`; an unavailable codec is disabled with its reason,
|
||||||
|
never silently substituted. [docs/workflow.md](docs/workflow.md) has the detail.
|
||||||
|
|
||||||
## Run it
|
## Run it
|
||||||
|
|
||||||
PySide6 only — Qt's LGPLv3 binding, so an internal closed-source tool needs no
|
PySide6 only — Qt's LGPLv3 binding, so an internal closed-source tool needs no
|
||||||
licence purchase. (PyQt5 is GPLv3-or-commercial; the studio app moved to PySide6
|
licence purchase (PyQt5 is GPLv3-or-commercial).
|
||||||
for the same reason and no longer uses it.)
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# with the environment that already has PySide6 (Anaconda py310, for example)
|
# with the environment that already has PySide6 (Anaconda py310, for example)
|
||||||
@@ -50,12 +61,9 @@ resolve to `apps/autocal/` rather than the studio's.
|
|||||||
|
|
||||||
### Controller library
|
### Controller library
|
||||||
|
|
||||||
The ZMotion runtime is vendored at `shared/resources/zmotion/x64/` (see the
|
The ZMotion runtime is vendored at `shared/resources/zmotion/x64/`. `ORISYS_ZMOTION_DLL_DIR` overrides it, and the
|
||||||
README beside it for provenance). Point `ORISYS_ZMOTION_DLL_DIR` at another
|
override is authoritative: a directory without `zauxdll.dll` makes the app refuse
|
||||||
directory to use a different build — on a motion controller, silently loading
|
to connect rather than quietly load another build.
|
||||||
the wrong runtime version is the kind of fault that looks like a working machine
|
|
||||||
until it moves, so the override is authoritative: if it does not contain
|
|
||||||
`zauxdll.dll`, the app refuses to connect instead of quietly falling back.
|
|
||||||
|
|
||||||
One contract in `shared/src/orisys_app_shared/zmotion/client.py` is worth
|
One contract in `shared/src/orisys_app_shared/zmotion/client.py` is worth
|
||||||
knowing before touching it: `ZAux_Execute` **must** be given a reply buffer.
|
knowing before touching it: `ZAux_Execute` **must** be given a reply buffer.
|
||||||
@@ -71,8 +79,7 @@ The vendor sequence, with the checks AutoCal's own state machine adds. P1 of
|
|||||||
|
|
||||||
1. **Connect** (`eth` + the controller's IP). On success the app adopts any BAS
|
1. **Connect** (`eth` + the controller's IP). On success the app adopts any BAS
|
||||||
program the controller is already running from a previous session instead of
|
program the controller is already running from a previous session instead of
|
||||||
claiming the bus is uninitialised; `BUS_TYPE` is read immediately, and
|
claiming the bus is uninitialised; `BUS_TYPE` is read immediately.
|
||||||
`Bus_InitStatus` on the first poll.
|
|
||||||
2. **Download only if the bus type is unknown.** `ECAT.bas` / `RTEX.bas` are
|
2. **Download only if the bus type is unknown.** `ECAT.bas` / `RTEX.bas` are
|
||||||
chosen from `config/bas/` (the vendor requires a non-Chinese path) and
|
chosen from `config/bas/` (the vendor requires a non-Chinese path) and
|
||||||
written to ROM. A controller that still has the program needs no download.
|
written to ROM. A controller that still has the program needs no download.
|
||||||
@@ -97,7 +104,7 @@ The vendor sequence, with the checks AutoCal's own state machine adds. P1 of
|
|||||||
| Range | Family | What the operator should do |
|
| Range | Family | What the operator should do |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `0` | success | — |
|
| `0` | success | — |
|
||||||
| `20000–29999` | PC-library fault (handshake, socket, address) | check the cable/segment and the IP, make sure nothing else holds the connection, then retry. Frequently transient: a first open on a cold route can fail and succeed on the next attempt, which is why an Ethernet connect makes up to 3 attempts before reporting. |
|
| `20000–29999` | PC-library fault (handshake, socket, address) | check the cable/segment and the IP, then retry; a first open on a cold route is often transient, which is why an Ethernet connect makes up to 3 attempts. |
|
||||||
| `30000+` | the auxiliary block `zauxdll2.h` documents | the message names it (`无应答`, `参数错误`, …) |
|
| `30000+` | the auxiliary block `zauxdll2.h` documents | the message names it (`无应答`, `参数错误`, …) |
|
||||||
| anything else | the controller's own code | look it up in ZDevelop's table |
|
| anything else | the controller's own code | look it up in ZDevelop's table |
|
||||||
|
|
||||||
@@ -158,6 +165,10 @@ apps/autocal/
|
|||||||
tests/
|
tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`capture_controller.py` is the capture page's Qt-free step machine (queue, `Ready`,
|
||||||
|
one primary action, remeasure/skip/stop, poll); `recording_options.py` turns the
|
||||||
|
video choice into a `RecordConfig` and probes which codecs this machine can write.
|
||||||
|
|
||||||
`motion/poller.py` is the only place a controller call can originate. It owns
|
`motion/poller.py` is the only place a controller call can originate. It owns
|
||||||
the DLL handle on its own thread, so the vendor library's blocking calls (a ROM
|
the DLL handle on its own thread, so the vendor library's blocking calls (a ROM
|
||||||
write, a bus scan) never freeze the window and the ctypes handle is never shared
|
write, a bus scan) never freeze the window and the ctypes handle is never shared
|
||||||
@@ -177,26 +188,18 @@ python -m pytest apps\autocal\tests # offscreen suite
|
|||||||
```
|
```
|
||||||
|
|
||||||
Connection and bus initialisation are used once per machine start-up, so they live
|
Connection and bus initialisation are used once per machine start-up, so they live
|
||||||
in a **modeless device-setup dialog** (`ui/device_setup.py`) rather than the rail:
|
in a **modeless device-setup dialog** (`ui/device_setup.py`), opened from the status
|
||||||
the rail carries what is read while working — the axis readout and the collapsed
|
strip, its chips, or 文件 → 设备设置. The rail carries what is read while working:
|
||||||
SDO/RTEX monitor. The dialog opens from the status strip's 设备设置 button, from the
|
the axis readout and the collapsed SDO/RTEX monitor.
|
||||||
connection/bus/init chips (each opens the tab that explains it) or from
|
|
||||||
文件 → 设备设置, and the strip keeps reporting the live state whether it is open or
|
|
||||||
shut.
|
|
||||||
|
|
||||||
`preview_snapshot.py` is the one tool that can photograph the **3D pane**, which
|
`preview_snapshot.py` is the only tool that can photograph the **3D pane** (the
|
||||||
`panel_snapshot.py` cannot: the offscreen platform has no OpenGL context at all, so
|
offscreen platform has no OpenGL context, so the pane is empty there). It needs
|
||||||
the pane is empty in those shots. It needs the real platform plugin and a visible
|
the real platform plugin and a visible window. See
|
||||||
window, and it writes either the pane alone or the whole page with the pane
|
|
||||||
composited in. What the view draws, and what each colour means, is in
|
|
||||||
[docs/finger-preview.md](docs/finger-preview.md).
|
[docs/finger-preview.md](docs/finger-preview.md).
|
||||||
|
|
||||||
`layout_mockup.py` is a **wireframe, not the application**: it paints the
|
`layout_mockup.py` is a **wireframe, not the application**: it paints the proposed
|
||||||
proposed single-page layout with the shared theme tokens and imports nothing
|
single-page layout with the shared theme tokens and imports nothing from the app,
|
||||||
from the app, so the layout can be reviewed and marked up without building the
|
so the layout can be reviewed without building the panels.
|
||||||
panels. It draws the full page plus previews of the other workspace tabs in one
|
|
||||||
canvas, and `--page setup` draws the device-setup dialog over the page it opens
|
|
||||||
from.
|
|
||||||
|
|
||||||
`panel_check.py` builds the real window in both themes at 1440×900 and
|
`panel_check.py` builds the real window in both themes at 1440×900 and
|
||||||
1920×1080, asserts that no widget escapes its parent, that motion is
|
1920×1080, asserts that no widget escapes its parent, that motion is
|
||||||
@@ -207,10 +210,8 @@ 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
|
are skipped rather than failed: a broken environment should not look like a
|
||||||
broken application. `tests/test_binding_purity.py` enforces the binding policy
|
broken application. `tests/test_binding_purity.py` enforces the binding policy
|
||||||
statically — no module may import PyQt5/PySide2/PyQt6, no module may use an
|
statically — no module may import PyQt5/PySide2/PyQt6, no module may use an
|
||||||
unscoped Qt enumerator (removed in PySide6), `shared/` may import PySide6 and no
|
unscoped Qt enumerator, `shared/` may import PySide6 and no other binding, and
|
||||||
other binding, and the controller interface must stay importable headless. The
|
the controller interface must stay importable headless.
|
||||||
studio app enforces the same rules for itself in
|
|
||||||
`apps/studio/tests/test_binding_purity.py`.
|
|
||||||
|
|
||||||
## Do not ship this app
|
## Do not ship this app
|
||||||
|
|
||||||
|
|||||||
@@ -43,20 +43,28 @@
|
|||||||
"role_pitch": "pitch (tilt)",
|
"role_pitch": "pitch (tilt)",
|
||||||
"role_axis": "Drive",
|
"role_axis": "Drive",
|
||||||
"axes_duplicate": "One drive is claimed by more than one direction: {detail}",
|
"axes_duplicate": "One drive is claimed by more than one direction: {detail}",
|
||||||
|
"tab_press": "Press zero",
|
||||||
|
"press_hint": "The solver reports the contact's geometric height above the table's origin plane, in millimetres. The drive is commanded in its own coordinate, counted from its own mechanical zero. One bench measurement relates the two. Until it is entered, Execute holds the press axis rather than passing a geometric height to the controller as a target.",
|
||||||
|
"press_zero": "Zero (reading with the tip on the origin plane)",
|
||||||
|
"press_rising": "A larger coordinate lifts the tip",
|
||||||
|
"press_calibrated": "Measured and confirmed on this machine",
|
||||||
|
"press_set": "Calibrated: press target = zero ± contact height.",
|
||||||
|
"press_unset": "Not calibrated: Execute holds the press axis.",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"chip_hint": "Open device setup — {name}"
|
"chip_hint": "Open device setup — {name}",
|
||||||
|
"tab_units": "Units"
|
||||||
},
|
},
|
||||||
"move": {
|
"move": {
|
||||||
"execute": "Execute",
|
"execute": "Execute",
|
||||||
"execute_hint": "Send the selected point's solved pose to the platform: yaw, then pitch, then table X/Y. The press axis Z is not moved.",
|
"execute_hint": "Send the selected point's solved pose to the platform: yaw, pitch, table X/Y and press axis Z move together.",
|
||||||
"stop": "Stop",
|
"stop": "Stop",
|
||||||
"stop_hint": "Stop every axis now (the same command as 全部停止 in the strip).",
|
"stop_hint": "Stop every axis now (the same command as 全部停止 in the strip).",
|
||||||
"return_initial": "Return to zero",
|
"return_initial": "Return to zero",
|
||||||
"return_hint": "Every axis back to the initial state, press axis Z first.",
|
"return_hint": "Yaw, pitch and press axis Z return to 0. The table parks at its initial position: X at 0 mm, Y at 0 mm.",
|
||||||
"confirm_title": "Confirm motion",
|
"confirm_title": "Confirm motion",
|
||||||
"confirm_execute": "Will move, in this order:\n\n{steps}\n\nCheck this against the machine before continuing.",
|
"confirm_execute": "Will move these axes together:\n\n{steps}\n\nCheck this against the machine before continuing.",
|
||||||
"confirm_return": "Will move every axis back to zero (press axis Z first):\n\n{steps}",
|
"confirm_return": "Will move these axes back to zero together:\n\n{steps}",
|
||||||
"hold_note": "Not moved: {roles} (the press axis is P7's business — contact detection and press depth)",
|
"hold_note": "Not moved: {roles} (these axes stay where they are and take no part in this move)",
|
||||||
"cancelled": "Cancelled; nothing was commanded",
|
"cancelled": "Cancelled; nothing was commanded",
|
||||||
"confirm_check": "Confirm before moving",
|
"confirm_check": "Confirm before moving",
|
||||||
"confirm_hint": "Unchecked, Execute and Return to zero are sent directly. Stop is never affected.",
|
"confirm_hint": "Unchecked, Execute and Return to zero are sent directly. Stop is never affected.",
|
||||||
@@ -157,7 +165,25 @@
|
|||||||
"home": "Home",
|
"home": "Home",
|
||||||
"controls": "Actions",
|
"controls": "Actions",
|
||||||
"mm_derived_tip": "Millimetres are derived from the pulse equivalent: a display value, not a calibrated length.",
|
"mm_derived_tip": "Millimetres are derived from the pulse equivalent: a display value, not a calibrated length.",
|
||||||
"enabled_count": "{on}/{total} axes enabled"
|
"enabled_count": "{on}/{total} axes enabled",
|
||||||
|
"units_open": "Units…",
|
||||||
|
"units_locked_tip": "Units are locked and read-only here. Use Device setup → Units and press Modify to change them.",
|
||||||
|
"events_title": "Alarms and events",
|
||||||
|
"events_clear": "Clear log",
|
||||||
|
"events_empty": "No events yet. Changes in alarms, enable, homing, read failures and unit mismatches are logged here.",
|
||||||
|
"ev_connected": "Controller connected",
|
||||||
|
"ev_disconnected": "Controller disconnected",
|
||||||
|
"ev_alarm_on": "Axis {axis} alarm",
|
||||||
|
"ev_alarm_off": "Axis {axis} alarm cleared",
|
||||||
|
"ev_enabled": "Axis {axis} enabled",
|
||||||
|
"ev_disabled": "Axis {axis} disabled",
|
||||||
|
"ev_read_failed": "Axis {axis} read failed: {detail}",
|
||||||
|
"ev_read_ok": "Axis {axis} readable again",
|
||||||
|
"ev_home_started": "Axis {axis} homing started",
|
||||||
|
"ev_home_done": "Axis {axis} homing complete",
|
||||||
|
"ev_home_incomplete": "Axis {axis} homing ended without reporting complete",
|
||||||
|
"ev_units_mismatch": "Axis {axis} UNITS mismatch: configured {expected}, read {observed}",
|
||||||
|
"ev_units_ok": "Axis {axis} UNITS agree again"
|
||||||
},
|
},
|
||||||
"busdata": {
|
"busdata": {
|
||||||
"title": "Bus data exchange",
|
"title": "Bus data exchange",
|
||||||
@@ -223,17 +249,186 @@
|
|||||||
"workspace": {
|
"workspace": {
|
||||||
"tab_axes": "Axis debug",
|
"tab_axes": "Axis debug",
|
||||||
"tab_sequence": "Test sequence",
|
"tab_sequence": "Test sequence",
|
||||||
"tab_finger": "Finger debug",
|
"tab_finger": "Geometry & mounting",
|
||||||
"tab_sensor": "Sensor",
|
"tab_sensor": "Calibration capture",
|
||||||
"tab_surface": "Surface map",
|
"tab_records": "Calibration records",
|
||||||
"tab_points": "Calibration points",
|
|
||||||
"not_built": "Not built yet",
|
"not_built": "Not built yet",
|
||||||
"reserved_sensor": "Sensor connection, live image and force curves will appear here: device and source selection, preview, and a compact version of the Studio curve view.",
|
"reserved_sensor": "Sensor connection, live image and force curves will appear here: device and source selection, preview, and a compact version of the Studio curve view."
|
||||||
"reserved_surface": "Multi-point calibration sensitivity will be drawn as a colour map on the sensor's 3D surface here: each point's sensitivity interpolated and coloured, with a legend and range.",
|
},
|
||||||
"reserved_points": "The calibration point table and capture controls will appear here: platform coordinates, reference load, sampling statistics and the resulting sensitivity."
|
"source": {
|
||||||
|
"kind": "Input source",
|
||||||
|
"kind_video": "Video file",
|
||||||
|
"kind_camera": "Live camera",
|
||||||
|
"video": "Finger video",
|
||||||
|
"camera_index": "Camera index",
|
||||||
|
"config": "Sensor config",
|
||||||
|
"browse": "Browse…",
|
||||||
|
"start": "Start input",
|
||||||
|
"stop": "Stop",
|
||||||
|
"idle": "Choose an input source and sensor.json",
|
||||||
|
"no_frame": "Waiting for processed frames",
|
||||||
|
"preview_only": "Choose a U/V site first, then check the live 2D UV image here and record the trial.",
|
||||||
|
"choose_video": "Choose Finger video",
|
||||||
|
"choose_config": "Choose sensor.json",
|
||||||
|
"video_filter": "Video files (*.mp4 *.avi *.mov *.mkv);;All files (*)",
|
||||||
|
"missing_input": "Choose an existing sensor.json and, for video mode, a video file",
|
||||||
|
"opening": "Opening input…",
|
||||||
|
"running": "Processing frames",
|
||||||
|
"stopping": "Stopping…",
|
||||||
|
"stopped": "Stopped",
|
||||||
|
"finished": "Video finished",
|
||||||
|
"disconnected": "Camera disconnected",
|
||||||
|
"failed": "Acquisition failed; check the input source",
|
||||||
|
"error": "Acquisition or processing failed: {detail}",
|
||||||
|
"basis_replay": "decoded now",
|
||||||
|
"basis_camera": "camera received",
|
||||||
|
"basis_unknown": "read time (source unverified)",
|
||||||
|
"frame": "Frame {frame} · source {source_frame} · {basis} {time} · dropped {dropped}"
|
||||||
|
},
|
||||||
|
"calibration": {
|
||||||
|
"sensor_model": "Product model",
|
||||||
|
"sensor_sn": "Sensor SN",
|
||||||
|
"output": "Output directory",
|
||||||
|
"site_view": "Select site",
|
||||||
|
"live_view": "Live 2D UV image",
|
||||||
|
"uv": "U / V (px)",
|
||||||
|
"position": "Mesh position",
|
||||||
|
"levels": "Load levels (N)",
|
||||||
|
"hold": "Hold per level",
|
||||||
|
"hold_tip": "Count down after arrival confirmation. End the hold window and prompt the next level; video continues. Time alone does not verify stability.",
|
||||||
|
"reference": "Manual reference force (N)",
|
||||||
|
"start": "Start this trial",
|
||||||
|
"start_tip": "Create a trial in this calibration session. Record initial zero, loading, unloading and recovery continuously; no platform motion is issued.",
|
||||||
|
"confirm": "Target reached: start hold",
|
||||||
|
"confirm_tip": "Reach the current target and enter the actual reference load. Keep the load unchanged until the countdown ends.",
|
||||||
|
"finish": "Complete this trial",
|
||||||
|
"finish_tip": "After initial zero and every loading/unloading hold, stop recording, check and save the trial.",
|
||||||
|
"abort": "End early (keep incomplete data)",
|
||||||
|
"abort_tip": "End this trial immediately and keep its incomplete data. A retry creates a new trial in the same calibration session.",
|
||||||
|
"load": "Load",
|
||||||
|
"unload": "Unload",
|
||||||
|
"next_step": "{branch} to {load:g} N; enter actual reference load and start hold",
|
||||||
|
"before_start": "Select a site and start input, then start the trial with initial zero",
|
||||||
|
"ready_to_finish": "All hold windows complete; complete this trial",
|
||||||
|
"no_step": "No step pending",
|
||||||
|
"progress": "Progress {done}/{total} · {status}",
|
||||||
|
"source_state": "Input: {status} · frame {frame} · capture drops {dropped}",
|
||||||
|
"replay_note": "Video replay is for debugging; manual reference loads cannot be synced to the original experiment.",
|
||||||
|
"idle": "Idle",
|
||||||
|
"recording": "Recording",
|
||||||
|
"closing": "Writing files",
|
||||||
|
"complete": "Complete",
|
||||||
|
"incomplete": "Incomplete",
|
||||||
|
"result": "{status}: {path}",
|
||||||
|
"incomplete_result": "This recording is incomplete; data written so far is at: {path}",
|
||||||
|
"error": "Action failed: {detail}",
|
||||||
|
"invalid_site": "Select a valid UV site first",
|
||||||
|
"start_input": "Start camera or video input first",
|
||||||
|
"reference_required": "Enter the actual reference load",
|
||||||
|
"output_required": "Choose an output directory",
|
||||||
|
"zero": "Initial zero",
|
||||||
|
"holding": "Hold {branch} {load:g} N · {seconds:.1f} s remaining; do not change load",
|
||||||
|
"close_session": "Finish calibration session",
|
||||||
|
"close_session_tip": "Close the session containing multiple sites and trial attempts. Incomplete trials keep the session incomplete. The next trial creates a new session.",
|
||||||
|
"session_none": "No calibration session yet; the first trial creates it.",
|
||||||
|
"session_active": "Calibration session: {sites} sites · {trials} attempts\n{path}",
|
||||||
|
"session_closed": "Calibration session closed ({status}): {path}",
|
||||||
|
"close_before_mapping": "Finish the calibration session before changing UV mapping or bundle",
|
||||||
|
"path": "Trial path (N): {path}",
|
||||||
|
"invalid_levels": "Enter strictly increasing levels greater than 0 and at most 30 N"
|
||||||
|
},
|
||||||
|
"multipoint": {
|
||||||
|
"tab": "02 multi-point",
|
||||||
|
"hint": "A point comes from the position chosen on Select point. Adding it solves the platform pose so the surface normal faces the tool. Align normal asks before moving the five axes. If the controller is not connected, nothing moves and the reason is shown on this page. Simulation does not move the platform. A weight reference is typed. A six-axis reference stores Fx, Fy and Fz, then projects them with the rotation you enter. One raw axis is not the normal. Confirm the indenter has left before the next point.",
|
||||||
|
"site_id": "Point id",
|
||||||
|
"coordinates": "Position X / Y / Z",
|
||||||
|
"levels": "Load levels for this point (N), excluding baseline",
|
||||||
|
"hold": "Hold time",
|
||||||
|
"repeats": "Repeats",
|
||||||
|
"frame": "Coordinate frame",
|
||||||
|
"frame_placeholder": "Name the frame; it is stored and not converted",
|
||||||
|
"min_force": "Minimum force (N)",
|
||||||
|
"min_placeholder": "Blank means 0; the unloaded baseline is always recorded",
|
||||||
|
"max_force": "Maximum force (N)",
|
||||||
|
"force_placeholder": "Required; nothing is assumed",
|
||||||
|
"force_required": "Enter the maximum force; it is not assumed",
|
||||||
|
"add": "Add point",
|
||||||
|
"update": "Update selected",
|
||||||
|
"remove": "Delete selected",
|
||||||
|
"up": "Move up",
|
||||||
|
"down": "Move down",
|
||||||
|
"align": "Align normal",
|
||||||
|
"reacquire": "Record selected again",
|
||||||
|
"source": "Reference source",
|
||||||
|
"source_weight": "Weights",
|
||||||
|
"source_wrench": "Six-axis",
|
||||||
|
"fx": "Fx",
|
||||||
|
"fy": "Fy",
|
||||||
|
"fz": "Fz",
|
||||||
|
"rotation": "Force sensor to tool rotation",
|
||||||
|
"rotation_placeholder": "9 numbers, row major. Required before a normal component can be computed",
|
||||||
|
"reference": "Measured reference (N)",
|
||||||
|
"simulate": "Simulate acquisition",
|
||||||
|
"start": "Start acquisition",
|
||||||
|
"confirm": "At target force, start hold",
|
||||||
|
"liftoff": "Confirm the indenter has left the sensor",
|
||||||
|
"remeasure": "Measure this step again",
|
||||||
|
"remeasure_ready": "This step was discarded and progress stays on it. Reconnect the sensor, wait until the trace is back near its previous baseline, type this step's reference force, then click At target force, start hold.",
|
||||||
|
"stop": "Stop and keep data",
|
||||||
|
"stopped_reason": "operator stopped",
|
||||||
|
"finger_busy": "A single-point trial is running; finish it before multi-point acquisition",
|
||||||
|
"camera_required": "Start the camera or video input before live acquisition. Use simulation when there is no image; simulation does not open a camera or move the platform.",
|
||||||
|
"liftoff_wait": "The previous point is unloaded. Next point is {site}. Lift the indenter, click Align normal, and confirm before the platform moves so this normal faces up. Then click Confirm the indenter has left the sensor. Progress {done}/{total}",
|
||||||
|
"progress": "Point {site} ({site_index}/{site_count}) · {branch} {load:g} N · repeat {repeat}/{repeats} · progress {done}/{total} · {seconds:.1f} s left",
|
||||||
|
"idle": "Not started. Pick a position on Select point, add it, then simulate or start.",
|
||||||
|
"pose": "Solved pose: table X {x:.3f} mm, Y {y:.3f} mm, yaw {yaw:.3f}°, pitch {pitch:.3f}°, Z {z:.3f} mm, residual {residual:.3f}°. Align normal asks before the platform moves.",
|
||||||
|
"no_pose": "This point has no normal. Pick a valid position on Select point, then click Add point or Update selected.",
|
||||||
|
"no_map": "Load a sensor bundle so Select point shows the surface.",
|
||||||
|
"no_solver": "The pose solver is not connected.",
|
||||||
|
"align_select": "Select the destination point in the table, then click Align normal.",
|
||||||
|
"align_busy": "This run is still recording: wait for it to finish before aligning another point, because moving the platform now would invalidate it.",
|
||||||
|
"pose_refused": "This pose cannot be commanded: {reason}",
|
||||||
|
"point_moved": "This point's recorded position no longer matches the current map (off by {mm} mm). Pick it again on Select point and confirm it; do not move the platform to the old record.",
|
||||||
|
"normal_moved": "This point's recorded normal no longer matches the current map (off by {deg}°). Pick it again on Select point and confirm it.",
|
||||||
|
"point_unmapped": "This point has no usable source coordinate: no UV mapping and not a sensor-frame position. Pick it again on Select point.",
|
||||||
|
"no_finger": "The finger page is not connected.",
|
||||||
|
"no_finger_points": "The finger page has no picked points.",
|
||||||
|
"branch_baseline": "baseline",
|
||||||
|
"branch_load": "load",
|
||||||
|
"branch_unload": "unload",
|
||||||
|
"mesh_required": "Pick the point with U/V on the right so its mesh coordinate is shown, then add it.",
|
||||||
|
"right_confirm": "This step uses the right side: type the weight in Manual reference. For a six-axis reading, type Fx, Fy and Fz on the left. Then click At target force, start hold.",
|
||||||
|
"save": "Save status: {status}\n{path}",
|
||||||
|
"save_idle": "Nothing saved yet",
|
||||||
|
"save_writing": "Writing",
|
||||||
|
"save_kept": "Stopped; data already collected is kept",
|
||||||
|
"save_done": "Session complete",
|
||||||
|
"col_id": "Id",
|
||||||
|
"col_x": "X",
|
||||||
|
"col_y": "Y",
|
||||||
|
"col_z": "Z",
|
||||||
|
"col_levels": "Levels (N)",
|
||||||
|
"col_hold": "Hold (s)",
|
||||||
|
"col_repeats": "Repeats",
|
||||||
|
"col_done": "Done",
|
||||||
|
"capture": "Recording: {status} · {frames} frames · {dropped} dropped · {reason}",
|
||||||
|
"capture_idle": "Not ready",
|
||||||
|
"capture_recording": "Recording",
|
||||||
|
"capture_closing": "Closing",
|
||||||
|
"capture_complete": "Complete",
|
||||||
|
"capture_incomplete": "Evidence incomplete",
|
||||||
|
"capture_reason_restarted": "image source restarted",
|
||||||
|
"capture_reason_zero_frames": "no frame was written",
|
||||||
|
"capture_reason_write_error": "disk write failed",
|
||||||
|
"capture_reason_video_missing": "no video frame was kept",
|
||||||
|
"capture_reason_video_undecodable": "the video cannot be decoded",
|
||||||
|
"capture_reason_dropped": "frames were dropped",
|
||||||
|
"capture_reason_segments": "unexpected number of video segments",
|
||||||
|
"capture_reason_timeout": "closing timed out",
|
||||||
|
"capture_wait": "recording is not ready; the hold cannot be confirmed yet"
|
||||||
},
|
},
|
||||||
"finger": {
|
"finger": {
|
||||||
"title": "Finger calibration-point debug",
|
"title": "Geometry & mounting",
|
||||||
"bundle_path": "Export bundle",
|
"bundle_path": "Export bundle",
|
||||||
"load": "Load bundle…",
|
"load": "Load bundle…",
|
||||||
"reset_view": "Reset view",
|
"reset_view": "Reset view",
|
||||||
@@ -244,30 +439,38 @@
|
|||||||
"flat_title": "Flat image (click to pick)",
|
"flat_title": "Flat image (click to pick)",
|
||||||
"readout_title": "Readouts",
|
"readout_title": "Readouts",
|
||||||
"preview_title": "3D preview",
|
"preview_title": "3D preview",
|
||||||
"points_title": "Picked points",
|
"select_hint": "Click a point on the flat map to try that point's pose.",
|
||||||
"select_hint": "Click a point on the flat image to pick a calibration point.",
|
|
||||||
"no_bundle": "No export bundle loaded.",
|
"no_bundle": "No export bundle loaded.",
|
||||||
"no_surface": "Pixel ({x}, {y}) is not on the sensing surface (UV gap).",
|
"no_surface": "Pixel ({x}, {y}) is not on the sensing surface (UV gap).",
|
||||||
"row_sensor_point": "Sensor point (mm)",
|
"row_sensor_point": "Sensor point (mm)",
|
||||||
"row_sensor_normal": "Surface normal",
|
"row_sensor_normal": "Surface normal",
|
||||||
"row_platform_point": "Platform point (mm)",
|
"row_platform_point": "Platform point (mm)",
|
||||||
"row_pose": "Pose X / Y / yaw / pitch",
|
"row_pose": "Pose X / Y / yaw / pitch",
|
||||||
|
"row_branch": "Chosen pose and why",
|
||||||
"row_table_move": "Table move X / Y",
|
"row_table_move": "Table move X / Y",
|
||||||
|
"row_press": "Press-axis target",
|
||||||
"row_residual": "Alignment residual",
|
"row_residual": "Alignment residual",
|
||||||
|
"row_match": "Point match",
|
||||||
|
"match_none": "Pick a point on the flat image first",
|
||||||
|
"match_offline": "Platform not connected",
|
||||||
|
"match_value": "Selected X {sx} Y {sy} mm, yaw {syaw}°, pitch {spitch}°. Actual X {ax} Y {ay} mm, yaw {ayaw}°, pitch {apitch}°. Difference X {dx} Y {dy} mm, yaw {dyaw}°, pitch {dpitch}°",
|
||||||
"row_frames": "Frame mapping",
|
"row_frames": "Frame mapping",
|
||||||
|
"branch_none": "—",
|
||||||
|
"branch_value": "{reason} (this pose {travel}° total, the other exact pose {other}°)",
|
||||||
|
"branch_only": "only exact pose",
|
||||||
|
"branch_alignment": "smallest residual",
|
||||||
|
"branch_in_range": "inside travel",
|
||||||
|
"branch_upright": "face still up",
|
||||||
|
"branch_smallest": "smallest turn",
|
||||||
|
"branch_crown": "already facing up; translate only",
|
||||||
|
"press_unset": "Zero not measured: this move holds the press axis (enter it in Device setup → Press zero)",
|
||||||
|
"press_target": "{target} mm (geometric contact height {height} mm)",
|
||||||
"tool_xy": "Tool X / Y",
|
"tool_xy": "Tool X / Y",
|
||||||
"tool_hint": "Where the tool is in the table frame, straight above the table origin by default. The table move is what carries the contact to this point. Measure it on the real setup.",
|
"tool_hint": "Where the tool is in the table frame. Each point's table move is measured from this position.",
|
||||||
"sensor_xy": "Sensor X / Y",
|
"sensor_xy": "Sensor X / Y",
|
||||||
"sensor_zy": "Sensor Z / yaw",
|
"sensor_zy": "Sensor Z / yaw",
|
||||||
"setup_hint": "How the sensor actually sits on the stack with the machine at zero: X/Y/Z translation and a yaw about Z. Z is the mounting plate, shims included — there is no separate plate field. Measure it on the real setup.",
|
"setup_hint": "How the sensor actually sits on the stack with the machine at zero: X/Y/Z translation and a yaw about Z. Z is the mounting-plate thickness. The plate on this bench is 8 mm.",
|
||||||
"clear_points": "Clear points",
|
|
||||||
"remove_selected": "Remove selected",
|
|
||||||
"move_up": "Move up",
|
|
||||||
"move_down": "Move down",
|
|
||||||
"selected_count": "{count} selected",
|
|
||||||
"table_hint": "Tick a row to select it, double-click a sensor coordinate or normal to edit it. The pose columns are solved from those, so they update themselves.",
|
|
||||||
"edit_not_a_number": "That cell needs a number; the previous value was kept.",
|
"edit_not_a_number": "That cell needs a number; the previous value was kept.",
|
||||||
"point_count": "{count} points",
|
|
||||||
"residual_value": "{residual}° — {verdict}",
|
"residual_value": "{residual}° — {verdict}",
|
||||||
"reachable": "this normal can be aligned with Z",
|
"reachable": "this normal can be aligned with Z",
|
||||||
"unreachable": "this normal cannot be aligned with Z (yaw travel limit)",
|
"unreachable": "this normal cannot be aligned with Z (yaw travel limit)",
|
||||||
@@ -289,13 +492,20 @@
|
|||||||
"preview_no_gl": "No OpenGL context: {error}",
|
"preview_no_gl": "No OpenGL context: {error}",
|
||||||
"preview_draw_failed": "Drawing failed: {error}",
|
"preview_draw_failed": "Drawing failed: {error}",
|
||||||
"preview_solve_failed": "Solve failed: {error}",
|
"preview_solve_failed": "Solve failed: {error}",
|
||||||
"col_x": "x", "col_y": "y", "col_z": "z",
|
|
||||||
"col_nx": "nx", "col_ny": "ny", "col_nz": "nz",
|
|
||||||
"col_X": "X", "col_Y": "Y", "col_Z": "Z",
|
|
||||||
"col_yaw": "yaw", "col_pitch": "pitch", "col_residual": "residual(°)",
|
|
||||||
"loaded": "Bundle loaded: {vertices} vertices · {triangles} triangles · {extent} mm · {scale} mm per unit",
|
"loaded": "Bundle loaded: {vertices} vertices · {triangles} triangles · {extent} mm · {scale} mm per unit",
|
||||||
"load_failed": "Load failed: {error}",
|
"load_failed": "Load failed: {error}",
|
||||||
"picked": "Picked: residual {residual}° · yaw {yaw}° · pitch {pitch}°"
|
"picked": "Picked: residual {residual}° · yaw {yaw}° · pitch {pitch}°",
|
||||||
|
"bg_label": "Map",
|
||||||
|
"colour_label": "Dots",
|
||||||
|
"bg_original": "Original",
|
||||||
|
"bg_gray": "Grey",
|
||||||
|
"bg_dim": "Dimmed",
|
||||||
|
"bg_outline": "Outline",
|
||||||
|
"colour_yellow": "Yellow",
|
||||||
|
"colour_white": "White",
|
||||||
|
"colour_cyan": "Cyan",
|
||||||
|
"colour_orange": "Orange",
|
||||||
|
"colour_grey": "Grey"
|
||||||
},
|
},
|
||||||
"sequence": {
|
"sequence": {
|
||||||
"title": "Test sequence design",
|
"title": "Test sequence design",
|
||||||
@@ -357,7 +567,19 @@
|
|||||||
"imported": "Sequence imported: {name} ({count} points)",
|
"imported": "Sequence imported: {name} ({count} points)",
|
||||||
"import_warnings": "Import reported {count} warning(s)",
|
"import_warnings": "Import reported {count} warning(s)",
|
||||||
"confirm_discard": "The current sequence has not been exported. Importing replaces it. Continue?",
|
"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."
|
"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.",
|
||||||
|
"col_u": "U (px)",
|
||||||
|
"col_v": "V (px)",
|
||||||
|
"btn_send": "Send to capture ({count})",
|
||||||
|
"send_tip": "Add the selected points to the capture page's queue by U/V and switch to it.",
|
||||||
|
"send_none": "Select rows that have U/V first.",
|
||||||
|
"btn_add_picked": "Add picked point",
|
||||||
|
"add_picked_tip": "Add the point just picked on the 2D map to the sequence, with its U/V.",
|
||||||
|
"btn_remove": "Remove selected",
|
||||||
|
"add_picked_none": "Pick a point on the 2D map first.",
|
||||||
|
"add_picked_duplicate": "That point is already row {row} of the sequence.",
|
||||||
|
"tab_array": "Array",
|
||||||
|
"tab_motion": "Motion"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"ok": "Done",
|
"ok": "Done",
|
||||||
@@ -373,5 +595,436 @@
|
|||||||
"bas_loaded": "BAS loaded",
|
"bas_loaded": "BAS loaded",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"no": "No"
|
"no": "No"
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"waiting": "Waiting for force samples",
|
||||||
|
"uncalibrated": "N unavailable",
|
||||||
|
"stopped": "Input stopped; showing the last frame, values are no longer live.",
|
||||||
|
"stale": "No new frame yet or no update for over 1 s; check the input.",
|
||||||
|
"invalid": "Current force sample is invalid: {reason}",
|
||||||
|
"raw_note": "Live raw force signals (last 60 s); raw is not N. Enter the manual reference force separately.",
|
||||||
|
"overflow": "Preview omitted {count} frames; check acquisition drop status",
|
||||||
|
"raw_axis": "Raw force signal (raw)",
|
||||||
|
"time_axis": "Acquisition time (s)",
|
||||||
|
"background": "Real background",
|
||||||
|
"background_tip": "Checked: real UV image. Unchecked: solid background. Depth colours can overlay either background.",
|
||||||
|
"arrows": "Displacement arrows",
|
||||||
|
"depth": "Depth",
|
||||||
|
"depth_tip": "False colour of algorithm depth/deformation, not calibrated millimetres.",
|
||||||
|
"depth_scale": "Adaptive deformation range for this frame: {low:.4g}–{high:.4g} (algorithm units, not mm)",
|
||||||
|
"depth_missing": "No valid depth/deformation for this frame",
|
||||||
|
"flow_missing": "No valid displacement for this frame"
|
||||||
|
},
|
||||||
|
"overview": {
|
||||||
|
"capture_in_progress": "In progress",
|
||||||
|
"saved": "Analysis saved; hover for its location.",
|
||||||
|
"saved_summary": "Review saved and summary published: {path} → {summary}",
|
||||||
|
"open_session": "Open calibration session…",
|
||||||
|
"empty": "Choose the session folder containing session.json to view collected sites, normal response and sensitivity. You can also open current data from Calibration capture.",
|
||||||
|
"title": "Calibration record overview",
|
||||||
|
"notice": "Inspect acquired sites and normal response; click UV points or table rows. Circles: confirmed windows; squares: candidates. Trial shades differ. The fit uses all eligible loading data for the site and is not refitted by the trial filter.",
|
||||||
|
"coverage": "Acquisition status",
|
||||||
|
"sensitivity": "Normal sensitivity",
|
||||||
|
"import_plan": "Import planned-point CSV…",
|
||||||
|
"clear_plan": "Clear plan comparison",
|
||||||
|
"load": "Load saved analysis…",
|
||||||
|
"save": "Save analysis results",
|
||||||
|
"summary": "Collected {collected} / planned {planned}; missing {missing}; with incomplete attempts {incomplete}; unchecked {pending}; checked {reviewed}; extra {extra}.",
|
||||||
|
"no_plan": "Import a plan to identify missing sites.",
|
||||||
|
"uncollected": "Not acquired",
|
||||||
|
"incomplete": "Has incomplete attempts",
|
||||||
|
"pending": "Awaiting check",
|
||||||
|
"reviewed": "Stable windows checked",
|
||||||
|
"confirmed": "Checked",
|
||||||
|
"candidate": "Unchecked",
|
||||||
|
"coverage_map": "Red: incomplete attempts; amber: unchecked; green: checked; grey: uncollected",
|
||||||
|
"sensitivity_map": "Confirmed sensitivity: blue→red {low:.4g}→{high:.4g} raw/N<br/>Grey: no confirmed fit",
|
||||||
|
"details": "Check hold windows…",
|
||||||
|
"trial_filter": "Displayed trial",
|
||||||
|
"all_trials": "All trials",
|
||||||
|
"site_fit": "Site loading fit (all eligible trials)",
|
||||||
|
"response_title": "Normal response: reference force → raw signal",
|
||||||
|
"selected_fit": "Sensitivity {sensitivity} raw/N · {basis}. Fit uses all eligible loading windows at this site; range and trial count are in the site tooltip.",
|
||||||
|
"no_windows": "No eligible complete windows; recorded signal and stop diagnostics remain available.",
|
||||||
|
"col_order": "Plan order",
|
||||||
|
"col_site": "Acquired site",
|
||||||
|
"col_uv": "U / V (px)",
|
||||||
|
"col_xyz": "Sensor XYZ (mm)",
|
||||||
|
"col_status": "Analysis readiness",
|
||||||
|
"col_trials": "Complete / all trials",
|
||||||
|
"col_windows": "Confirmed / complete windows",
|
||||||
|
"col_levels": "Confirmed loading levels (N)",
|
||||||
|
"col_sensitivity": "Sensitivity (raw/N) / basis",
|
||||||
|
"col_range": "Fit range (N)",
|
||||||
|
"col_fit_trials": "Fit trials",
|
||||||
|
"tools": "Plan / saved analysis",
|
||||||
|
"identity": "Model: {model} SN: {sn} Acquired: {date} Source: {source} Capture: {status}",
|
||||||
|
"source_video": "Video replay (debug only)",
|
||||||
|
"source_camera": "Camera",
|
||||||
|
"timeline": "Time signal / diagnostics",
|
||||||
|
"response": "Loading / unloading response",
|
||||||
|
"time_axis": "Time from each trial’s first valid sample (s)",
|
||||||
|
"reference_axis": "Declared reference (N)",
|
||||||
|
"timeline_title": "Blue: FN_raw; orange: declared hold reference (not live FT)",
|
||||||
|
"no_trace": "No readable valid signal; inspect diagnostics below",
|
||||||
|
"outline_snapshot": "Grey: sensor boundary; blue: colour patch boundary (recorded snapshot)",
|
||||||
|
"outline_surface_only": "Sensor mapping verified; no recorded colour patch outline is available.",
|
||||||
|
"outline_unavailable": "Recorded UV outline unavailable; showing sites only.",
|
||||||
|
"outline_mismatch": "UV outline does not match the recorded mapping; overlay disabled.",
|
||||||
|
"analysis_reviewed": "Stable windows checked",
|
||||||
|
"analysis_pending": "Check stable windows",
|
||||||
|
"analysis_no_analysis": "No eligible windows",
|
||||||
|
"site_details": "XYZ (mm): {xyz}\nComplete / all attempts: {attempts}\nFit range (N): {range}; contributing trials: {trials}",
|
||||||
|
"inspect_incomplete": "Inspect incomplete attempt",
|
||||||
|
"diagnostic": "{trial} · {status} | Valid samples {samples}; invalid / unjoined {invalid}; recording drops {dropped}; largest frame gap {gap} s\nStop reason: {reason}",
|
||||||
|
"read_errors": "Missing or unreadable: {detail}",
|
||||||
|
"choose_trial": "Select one trial for integrity, drop and stop diagnostics. All-trial traces use separate time origins.",
|
||||||
|
"unsaved": "Window or plan changes have not been saved.",
|
||||||
|
"save_hint": "Confirming stability does not certify calibration accuracy.",
|
||||||
|
"guide_missing": "1 Check identity → 2 Select site / trial → 3 Check stable windows → 4 Save\nNext: this site has no attempt; choose a collected site or acquire it.",
|
||||||
|
"guide_incomplete": "1 Check identity → 2 Select site / trial → 3 Check stable windows → 4 Save\nNext: inspect the stop reason and recorded signal. Incomplete attempts are excluded from fitting.",
|
||||||
|
"guide_check": "1 Check identity → 2 Select site / trial → 3 Check stable windows → 4 Save\nNext: select a trial and Check hold windows; adjust the time interval if needed.",
|
||||||
|
"guide_save": "1 Check identity → 2 Select site / trial → 3 Check stable windows → 4 Save\nNext: inspect remaining holds or save current analysis.",
|
||||||
|
"video": "View recording",
|
||||||
|
"video_not_recorded": "Video not recorded, per the session settings"
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"open": "Repeatability and validation…",
|
||||||
|
"title": "Site quality analysis",
|
||||||
|
"notice": "Only operator-confirmed valid windows are used. Results remain exploratory. Replay, stability review and trial holdout do not establish physical accuracy or reference synchronisation.",
|
||||||
|
"repeatability": "Repeatability",
|
||||||
|
"repeatability_note": "For the same branch and exact declared reference force, average within each trial, then report range and sample standard deviation across trials. At least two trials are required; this differs from within-hold variation.",
|
||||||
|
"hysteresis": "Loading/unloading difference",
|
||||||
|
"hysteresis_note": "Pair nonzero loading and unloading windows at exactly the same declared reference force within one trial, without interpolation. Difference is unloading minus loading, in raw units; unmatched levels produce no result.",
|
||||||
|
"zero_return": "Zero return",
|
||||||
|
"zero_return_note": "Final unloaded zero minus initial zero in the same trial, in raw units. Only confirmed windows declaring 0 N are paired.",
|
||||||
|
"validation": "Held-out trial validation",
|
||||||
|
"validation_note": "Hold out each entire trial, fit zero/loading windows from other trials, then predict all confirmed windows in the held-out trial. Windows have equal weight. Cross-validation does not replace another independent experiment.",
|
||||||
|
"validation_summary": "Evaluated windows: {count}; RMSE {rmse} N; MAE {mae} N; maximum absolute error {maximum} N; outside training raw range: {outside}.",
|
||||||
|
"no_data": "No eligible data yet; review the required windows first.",
|
||||||
|
"insufficient_training": "Insufficient training data in other trials",
|
||||||
|
"outside_range": "Outside training raw range",
|
||||||
|
"in_range": "Within training raw range",
|
||||||
|
"col_branch": "Phase",
|
||||||
|
"col_load": "Manual reference (N)",
|
||||||
|
"col_trials": "Trials",
|
||||||
|
"col_range": "Between-trial range (raw)",
|
||||||
|
"col_std": "Between-trial sample std (raw)",
|
||||||
|
"col_trial": "Trial",
|
||||||
|
"col_load_raw": "Loading mean (raw)",
|
||||||
|
"col_unload_raw": "Unloading mean (raw)",
|
||||||
|
"col_delta_raw": "Difference (raw)",
|
||||||
|
"col_initial_zero": "Initial zero (raw)",
|
||||||
|
"col_final_zero": "Final zero (raw)",
|
||||||
|
"col_held_out": "Held-out trial",
|
||||||
|
"col_training": "Training trials",
|
||||||
|
"col_predicted": "Predicted force (N)",
|
||||||
|
"col_error": "Prediction minus reference (N)",
|
||||||
|
"col_range_status": "Range/status"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"checking": "Reading and verifying recordings in the background…",
|
||||||
|
"load_selection": "Load saved analysis…",
|
||||||
|
"confirm_stable": "This interval was checked for stability",
|
||||||
|
"apply_window": "Confirm interval and recompute",
|
||||||
|
"start_offset": "Start",
|
||||||
|
"end_offset": "End",
|
||||||
|
"verified": "Stability confirmed by operator",
|
||||||
|
"confirmed_fit": "Fit uses operator-confirmed windows; independent validation is still required.",
|
||||||
|
"candidate_fit": "Fit uses candidate windows; insufficient confirmed data.",
|
||||||
|
"valid_samples": "Valid raw samples",
|
||||||
|
"hold_time_axis": "Time since hold start (s)",
|
||||||
|
"trace_title": "Selected hold signal; vertical lines mark the analysis window",
|
||||||
|
"selection_unsaved": "Window applied; save the analysis to preserve this review.",
|
||||||
|
"open": "Review calibration data…",
|
||||||
|
"title": "Hold window inspection",
|
||||||
|
"notice": "Select a hold → inspect signal, standard deviation and drift → adjust the interval → confirm stability and recompute → save. Operator confirmation is not accuracy acceptance.",
|
||||||
|
"save": "Save analysis results",
|
||||||
|
"no_sites": "No sites in this session",
|
||||||
|
"candidate": "Candidate window",
|
||||||
|
"insufficient_samples": "Insufficient valid samples",
|
||||||
|
"no_frame_source": "This repeat has no recorded frame source: the reference window exists, but no raw normal signal can be computed from it",
|
||||||
|
"time_basis_not_comparable": "Time base is not comparable with the hold window (the recording is not live capture on this host); it was left out of the calculation",
|
||||||
|
"replay": "Replay debug",
|
||||||
|
"unverified": "Stability unverified",
|
||||||
|
"fit": "Exploratory loading fit: sensitivity {sensitivity_raw_per_n:.6g} raw/N; gain {gain_n_per_raw:.6g} N/raw; offset {offset_n:.6g} N; fit RMSE {rmse_n:.4g} N (not independent validation error)",
|
||||||
|
"no_fit": "Insufficient load levels or raw-signal variation for a fit.",
|
||||||
|
"fit_line": "Exploratory loading fit",
|
||||||
|
"raw_axis": "Raw normal signal (raw)",
|
||||||
|
"force_axis": "Manual reference force (N)",
|
||||||
|
"chart_title": "Hold-window means: loading vs unloading",
|
||||||
|
"uv_title": "Site sensitivity: blue→red {low:.4g}→{high:.4g} raw/N; grey has no fit",
|
||||||
|
"issues": "{count} incomplete attempts excluded from fitting. Inspect raw signals for stability.",
|
||||||
|
"saved": "Saved new review: {path}",
|
||||||
|
"saved_summary": "Review saved and summary published: {path} → {summary}",
|
||||||
|
"col_trial": "Trial",
|
||||||
|
"col_branch": "Phase",
|
||||||
|
"col_load": "Reference (N)",
|
||||||
|
"col_samples": "Valid samples",
|
||||||
|
"col_mean": "Raw mean",
|
||||||
|
"col_std": "Raw std",
|
||||||
|
"col_duration": "Hold (s)",
|
||||||
|
"col_quality": "Quality",
|
||||||
|
"col_drift": "Drift (raw/s)"
|
||||||
|
},
|
||||||
|
"video": {
|
||||||
|
"title": "Video review",
|
||||||
|
"header": "{trial} · step {step} ({branch} {load} N) · review {status} · {samples} valid samples",
|
||||||
|
"position": "Frame {index}/{total} · id {frame} · segment {segment} frame {segment_frame} · basis {basis}",
|
||||||
|
"play": "Play",
|
||||||
|
"pause": "Pause",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"frame_missing": "This frame was not written to the video (dropped from the queue)",
|
||||||
|
"reason_missing": "Cannot open the video: {detail}",
|
||||||
|
"reason_no_frame_source": "This recording has no usable video evidence (no camera, or no frame was written)",
|
||||||
|
"reason_no_video_frame": "No valid frame of this hold was written to the video",
|
||||||
|
"reason_trial_not_found": "This trial is not in the record",
|
||||||
|
"reason_window_not_found": "This hold window is not in the trial",
|
||||||
|
"reason_video_undecodable": "The video file cannot be decoded",
|
||||||
|
"reason_decode": "Decoding this frame failed",
|
||||||
|
"cursor": "Current frame",
|
||||||
|
"reference": "Actual reference",
|
||||||
|
"curve_title": "Raw force curve for this hold",
|
||||||
|
"basis_unknown": "not recorded",
|
||||||
|
"open": "View recording"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"failed": "Summary export failed: {detail}. The recording is unchanged; you can retry.",
|
||||||
|
"published": "Summary published: {path}",
|
||||||
|
"retry": "Publish summary again",
|
||||||
|
"done": "Summary published again: {path}",
|
||||||
|
"reason_hold_not_found": "This hold attempt is not in the record",
|
||||||
|
"reason_hold_not_complete": "This hold attempt was discarded by a remeasure",
|
||||||
|
"copy_failed": "The summary was published, but the readable copy was not written: {detail}. The pointer still names the published summary and the recording is unchanged."
|
||||||
|
},
|
||||||
|
"cap": {
|
||||||
|
"check": {
|
||||||
|
"bundle": "Calibration bundle",
|
||||||
|
"frames": "Input frames",
|
||||||
|
"bus": "Bus and axis mapping",
|
||||||
|
"output": "Output folder writable",
|
||||||
|
"sn": "Sensor SN",
|
||||||
|
"max_force": "Maximum force"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"start": "Start capture",
|
||||||
|
"align": "Align this point",
|
||||||
|
"confirm_target": "At the target — begin the hold",
|
||||||
|
"confirm_hold": "Hold finished — record this level",
|
||||||
|
"confirm_liftoff": "Confirm the probe has left the sensor",
|
||||||
|
"finish": "Finish the session",
|
||||||
|
"remeasure": "Measure this level again",
|
||||||
|
"skip": "Skip this point",
|
||||||
|
"stop": "Stop and keep the data",
|
||||||
|
"open_session": "Open session folder",
|
||||||
|
"open_summary": "Open summary",
|
||||||
|
"copy_path": "Copy path",
|
||||||
|
"settings": "Change input / bundle…"
|
||||||
|
},
|
||||||
|
"block": {
|
||||||
|
"closing": "Still writing to disk — wait for the close to finish",
|
||||||
|
"align_while_recording": "Recording: the platform cannot move to align another point",
|
||||||
|
"skip_live": "This level is already being recorded and cannot be skipped",
|
||||||
|
"no_directory": "There is no session folder yet",
|
||||||
|
"no_summary": "No summary has been published yet",
|
||||||
|
"not_ready": "Some checks are still unmet — see the Ready list above",
|
||||||
|
"no_sites": "The queue has no points yet",
|
||||||
|
"no_session": "The session settings are incomplete (output folder, model, SN, …)",
|
||||||
|
"no_solver": "No pose solver is connected, so the platform cannot be aligned",
|
||||||
|
"no_pose": "This point has no usable solved pose",
|
||||||
|
"hold_running": "The hold time has not elapsed",
|
||||||
|
"remeasure_unavailable": "No level can be measured again right now",
|
||||||
|
"stop_unavailable": "There is no running session to stop",
|
||||||
|
"finish_unavailable": "The session is not finished yet",
|
||||||
|
"skip_unavailable": "This build has no way to skip the current point, so it cannot be skipped",
|
||||||
|
"busy": "This action is not available in the current step",
|
||||||
|
"no_bundle": "No calibration bundle (export) is loaded",
|
||||||
|
"no_uv": "Click a position on the UV map first",
|
||||||
|
"no_output": "Choose the output directory first",
|
||||||
|
"no_sn": "Enter the sensor SN first",
|
||||||
|
"no_max_force": "Enter the maximum force (N) first",
|
||||||
|
"queue_busy": "The session is running; the point queue cannot be edited",
|
||||||
|
"bundle_busy": "The session is running; the calibration bundle cannot be changed",
|
||||||
|
"no_worker": "The input is not running, so recording cannot start",
|
||||||
|
"bad_levels": "Load levels must be strictly increasing, above zero and within the maximum force",
|
||||||
|
"bad_reference": "The measured reference force is not a valid number",
|
||||||
|
"align_running": "An alignment move is still running; wait for it to finish",
|
||||||
|
"pose_unreachable": "This point cannot be reached under the current mounting and travel, so aligning is disabled"
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"active": "Session: {sites} site(s) · {done}/{total} holds\n{path}",
|
||||||
|
"none": "No session yet. Align the first point, then press Start capture.",
|
||||||
|
"settings_hint": "Input source, bundle, session parameters and recording options are in \"Change input / bundle…\"; starting a capture does not switch pages."
|
||||||
|
},
|
||||||
|
"queue": {
|
||||||
|
"title": "Point queue",
|
||||||
|
"empty": "The queue is empty: click a position on the UV map, set the levels, then press Add current point.",
|
||||||
|
"add": "Add current point",
|
||||||
|
"update": "Update selected",
|
||||||
|
"remove": "Remove selected",
|
||||||
|
"up": "Move up",
|
||||||
|
"down": "Move down",
|
||||||
|
"site_id": "Site id",
|
||||||
|
"repeats": "Repeats",
|
||||||
|
"current_uv": "Current UV",
|
||||||
|
"col_id": "Site",
|
||||||
|
"col_position": "Sensor position (mm)",
|
||||||
|
"col_repeats": "Repeats",
|
||||||
|
"col_reachable": "Reachable",
|
||||||
|
"col_state": "State",
|
||||||
|
"reachable": "reachable",
|
||||||
|
"unreachable": "unreachable",
|
||||||
|
"state_pending": "pending",
|
||||||
|
"state_partial": "in progress",
|
||||||
|
"state_complete": "complete",
|
||||||
|
"state_skipped": "skipped",
|
||||||
|
"selected": "Selected: {site}",
|
||||||
|
"update_short": "Update",
|
||||||
|
"remove_short": "Remove",
|
||||||
|
"export": "Export point sheet…",
|
||||||
|
"export_tip": "Exports U/V, sensor coordinates, platform targets and the mapping identity in queue order; refuses when a coordinate no longer matches the mapping.",
|
||||||
|
"export_saved": "Point sheet exported: {path}",
|
||||||
|
"export_no_points": "The queue has no points yet",
|
||||||
|
"export_no_map": "No UV map is loaded, so the point sheet cannot be exported",
|
||||||
|
"export_missing_uv": "Point {order} has no UV coordinates, so the point sheet cannot be exported",
|
||||||
|
"export_no_pose": "Point {order} has no solved pose, so the point sheet cannot be exported",
|
||||||
|
"export_edited_point": "Point {order}'s sensor coordinates no longer match the current UV map; take the point again before exporting."
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"title": "Current point",
|
||||||
|
"no_point": "The queue is empty: add a point first.",
|
||||||
|
"no_progress": "Progress appears once the session starts.",
|
||||||
|
"next_site": "Next point: {site}"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"align_point": "1. Align the current point",
|
||||||
|
"start_session": "2. Start capture",
|
||||||
|
"confirm_target": "3. Target reached - start the hold",
|
||||||
|
"confirm_hold": "4. Hold finished - record this step",
|
||||||
|
"confirm_liftoff": "5. Confirm liftoff, go to the next point",
|
||||||
|
"finish_session": "6. Finish the session",
|
||||||
|
"closing": "Writing to disk"
|
||||||
|
},
|
||||||
|
"hint": {
|
||||||
|
"align_point": "Align first: the primary button asks for the move plan; Start capture only appears after the move has been confirmed complete.",
|
||||||
|
"start_session": "This point is aligned. The primary button opens the session and records the first point step by step.",
|
||||||
|
"not_ready": "The ✗ checks must pass before starting; hover them to see why.",
|
||||||
|
"confirm_target": "Load to the current target, enter the measured reference force (or the six-axis components), then press the primary button.",
|
||||||
|
"confirm_hold": "Holding: {seconds:.1f} s left; do not change the load.",
|
||||||
|
"confirm_liftoff": "This repeat is recorded. Lift the indenter, confirm it has left the sensor, then press the primary button to go to the next point.",
|
||||||
|
"finish_session": "Every record is written. Press the primary button to finish the session and publish the summary.",
|
||||||
|
"closing": "Closing the recording and checking it; nothing else runs until it finishes."
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"aligning": "Alignment requested for {site}. Start capture stays unavailable until the move completes and is confirmed.",
|
||||||
|
"aligned": "Alignment confirmed: {site}",
|
||||||
|
"align_failed": "Alignment did not finish: {detail}. The platform is not confirmed in place; align again.",
|
||||||
|
"started": "Session started: {path}",
|
||||||
|
"attach_failed": "Could not start recording: {detail}",
|
||||||
|
"queue_added": "Point added: {site}",
|
||||||
|
"queue_removed": "Point removed: {site}",
|
||||||
|
"skipped": "Point skipped: {site}",
|
||||||
|
"stopped": "Stopped; the data already written is kept.",
|
||||||
|
"done": "Session finished: {path}",
|
||||||
|
"queue_added_many": "{added} point(s) added to the capture queue, {refused} could not be added."
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"title": "Recording",
|
||||||
|
"video": "Record video",
|
||||||
|
"codec": "Video codec",
|
||||||
|
"lossless": "Also store lossless frames",
|
||||||
|
"unavailable": "{codec}: unavailable on this machine, disabled",
|
||||||
|
"probe_failed": "The codec probe did not run: {detail}",
|
||||||
|
"off": "Video off, per the session settings",
|
||||||
|
"estimate": "Estimated {size} ({seconds}); {free} free on disk",
|
||||||
|
"disk_short": "Less than twice the estimated size is free on disk",
|
||||||
|
"no_plan": "The queue is empty, so no size can be estimated yet",
|
||||||
|
"locked": "Recording options are locked once the session has started",
|
||||||
|
"summary": "Recording: {codec}"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Capture settings",
|
||||||
|
"input": "Input source",
|
||||||
|
"bundle": "Calibration bundle (export)",
|
||||||
|
"bundle_none": "not loaded",
|
||||||
|
"bundle_load": "Load export bundle…",
|
||||||
|
"session": "Session parameters",
|
||||||
|
"simulate": "Simulate (no camera, no platform motion)",
|
||||||
|
"close": "Close",
|
||||||
|
"max_required": "Required; the program does not assume a value"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"units": {
|
||||||
|
"hint": "One place to check each direction's drive, its pulse equivalent (UNITS) and the resulting scale. Locked and read-only by default; press Modify to change a value, and the change is listed for confirmation before anything is written.",
|
||||||
|
"col_role": "Direction",
|
||||||
|
"col_axis": "Drive",
|
||||||
|
"col_kind": "Type",
|
||||||
|
"col_configured": "Configured UNITS",
|
||||||
|
"col_observed": "Controller read-back",
|
||||||
|
"col_scale": "Scale",
|
||||||
|
"col_check": "Check",
|
||||||
|
"kind_linear": "Linear (mm)",
|
||||||
|
"kind_rotary": "Rotary (°)",
|
||||||
|
"scale_linear": "{mm} mm/unit · {per}/mm",
|
||||||
|
"scale_rotary": "{deg}°/unit · {per}/°",
|
||||||
|
"status_ok": "Agree",
|
||||||
|
"status_unread": "Not read",
|
||||||
|
"status_mismatch": "Mismatch",
|
||||||
|
"status_uncalibrated": "No angle scale",
|
||||||
|
"status_invalid": "Invalid",
|
||||||
|
"status_unmapped": "Unmapped",
|
||||||
|
"tip_unread": "Not connected, or this axis's UNITS has not been read back yet.",
|
||||||
|
"tip_mismatch": "The controller reads a different UNITS from the configured one: pose moves are refused and targets are not rescaled.",
|
||||||
|
"tip_uncalibrated": "The rotary angle scale was only measured at the calibration equivalent; any other value needs a new calibration.",
|
||||||
|
"tip_invalid": "UNITS must be a finite number above 0.",
|
||||||
|
"tip_unmapped": "No drive is assigned to this direction; see Axis mapping.",
|
||||||
|
"summary_ok": "Unit check: all {total} directions agree",
|
||||||
|
"summary_bad": "Unit check: {bad} of {total} directions need attention",
|
||||||
|
"summary_unread": "Unit check: not read yet (not connected)",
|
||||||
|
"constants": "Fixed calibration constants (measured on the bench, not editable here): linear axes {ppm} pulses/mm; rotary axes {rotary}; all measured at UNITS={cal}. Millimetres and degrees are displayed values derived from these constants, not new measurements.",
|
||||||
|
"rotary_item": "axis {axis}: {units} user units = 90°",
|
||||||
|
"modify": "Modify",
|
||||||
|
"apply": "Apply to controller",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"modify_offline": "Not connected to a controller; nothing can be modified.",
|
||||||
|
"locked_note": "Locked: read-only.",
|
||||||
|
"unlocked_note": "Unlocked: enter new values in the Configured UNITS column, then press Apply to controller.",
|
||||||
|
"mapping_changed": "The axis mapping changed; unapplied edits were dropped and the page is locked again.",
|
||||||
|
"invalid": "UNITS for {role} is not valid: enter a number above 0.",
|
||||||
|
"no_change": "No change; locked again.",
|
||||||
|
"applied": "Submitted UNITS for {count} drive(s); the read-back will show after the next refresh.",
|
||||||
|
"change_line": "{role} · axis {axis}: {old} → {new}",
|
||||||
|
"confirm_title": "Write UNITS",
|
||||||
|
"confirm_body": "The following UNITS will be written to the controller:\n{changes}\n\nAfterwards every millimetre and degree reading, and every pose-move target, is recomputed with the new equivalent. Confirm that the platform is stopped and the mechanical ratio has been checked.",
|
||||||
|
"confirm_rotary": "Note: UNITS for {roles} is not the calibration equivalent {cal}; the rotary axis has no angle scale at that value, so pose moves are refused until it is recalibrated."
|
||||||
|
},
|
||||||
|
"array": {
|
||||||
|
"mode": "Type",
|
||||||
|
"margin": "Margin (px)",
|
||||||
|
"rows": "Rows",
|
||||||
|
"cols": "Columns",
|
||||||
|
"u_pitch": "U pitch (px)",
|
||||||
|
"v_pitch": "V pitch (px)",
|
||||||
|
"centre_u": "Centre U",
|
||||||
|
"centre_v": "Centre V",
|
||||||
|
"preset": "Stage",
|
||||||
|
"mode_grid": "Regular grid",
|
||||||
|
"mode_preset": "Staged preset",
|
||||||
|
"serpentine": "Serpentine order",
|
||||||
|
"centre_from_pick": "Centre on pick",
|
||||||
|
"centre_from_pick_tip": "Use the point just picked on the flat map as the grid centre.",
|
||||||
|
"show_preview": "Preview on the map",
|
||||||
|
"btn_add": "Add to sequence ({count})",
|
||||||
|
"none": "No candidate points.",
|
||||||
|
"all_ok": "{total} candidates, all on the surface.",
|
||||||
|
"some_bad": "{total} candidates: {usable} usable, {off} off the surface, {edge} too near the edge. Only usable points can be added.",
|
||||||
|
"preset_hint": "Staged presets are the suggested starting layout, taken from the planning figure: confirm the hardware with stage 0, then add more. Every point is checked against the current surface; ones off it or inside the margin get a red cross and are not added. A point already in the sequence is not added twice.",
|
||||||
|
"preset_stage0": "Stage 0 · hardware check (1 point)",
|
||||||
|
"preset_stage1": "Stage 1 · calibration 3×3 (9 points)",
|
||||||
|
"preset_stage1_check": "Stage 1 · independent checks (4 points)",
|
||||||
|
"preset_stage2": "Stage 2 · more calibration (8 points)",
|
||||||
|
"preset_stage2_check": "Stage 2 · more checks (4 points)",
|
||||||
|
"added": "{added} point(s) added, {repeated} already in the sequence, {failed} could not be solved."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,20 +43,28 @@
|
|||||||
"role_pitch": "pitch(俯仰)",
|
"role_pitch": "pitch(俯仰)",
|
||||||
"role_axis": "驱动器",
|
"role_axis": "驱动器",
|
||||||
"axes_duplicate": "同一驱动器被分配给多个方向:{detail}",
|
"axes_duplicate": "同一驱动器被分配给多个方向:{detail}",
|
||||||
|
"tab_press": "压轴零位",
|
||||||
|
"press_hint": "解算给出的是接触点在台面原点平面之上的几何高度(mm);驱动器收到的是它自己的坐标,从它自己的机械零位算起。两者只差一个实机测量值。没有标定之前,执行动作会保持压轴不动,而不会把几何高度当成控制器目标直接下发。",
|
||||||
|
"press_zero": "零点(压头触到台面原点平面时的读数)",
|
||||||
|
"press_rising": "坐标增大时压头上升",
|
||||||
|
"press_calibrated": "已在本机测量并确认",
|
||||||
|
"press_set": "已标定:压轴目标 = 零点 ± 接触高度。",
|
||||||
|
"press_unset": "未标定:执行动作会保持压轴不动。",
|
||||||
"close": "关闭",
|
"close": "关闭",
|
||||||
"chip_hint": "点击打开设备设置 — {name}"
|
"chip_hint": "点击打开设备设置 — {name}",
|
||||||
|
"tab_units": "单位与换算"
|
||||||
},
|
},
|
||||||
"move": {
|
"move": {
|
||||||
"execute": "执行动作",
|
"execute": "执行动作",
|
||||||
"execute_hint": "把选中点解算出的位姿送到平台:先回转、再俯仰,最后工作台 X/Y。压轴 Z 不动作。",
|
"execute_hint": "把选中点解算出的位姿送到平台:回转、俯仰、工作台 X/Y、压轴 Z 同时移动。",
|
||||||
"stop": "停止",
|
"stop": "停止",
|
||||||
"stop_hint": "立即停止所有轴(与顶栏「全部停止」相同)。",
|
"stop_hint": "立即停止所有轴(与顶栏「全部停止」相同)。",
|
||||||
"return_initial": "回初始位",
|
"return_initial": "回初始位",
|
||||||
"return_hint": "五个轴全部回到零点,先退压轴 Z。",
|
"return_hint": "回转、俯仰、压轴 Z 回到 0。工作台停在初始位置:X 为 0 mm,Y 为 0 mm。",
|
||||||
"confirm_title": "确认运动",
|
"confirm_title": "确认运动",
|
||||||
"confirm_execute": "将按以下顺序移动:\n\n{steps}\n\n请确认仿真与机床一致。",
|
"confirm_execute": "将同时移动:\n\n{steps}\n\n请确认仿真与机床一致。",
|
||||||
"confirm_return": "将把五个轴全部移回零点(先退压轴 Z):\n\n{steps}",
|
"confirm_return": "将同时移回零点:\n\n{steps}\n\n请确认机床上没有干涉。",
|
||||||
"hold_note": "不动作:{roles}(压轴由 P7 的接触检测与压深另外管)",
|
"hold_note": "不动作:{roles}(这些轴保持现状,不参与本次动作)",
|
||||||
"cancelled": "已取消,未发出任何运动",
|
"cancelled": "已取消,未发出任何运动",
|
||||||
"confirm_check": "移动前确认",
|
"confirm_check": "移动前确认",
|
||||||
"confirm_hint": "取消勾选后,执行动作与回初始位将直接发出(停止不受影响)。",
|
"confirm_hint": "取消勾选后,执行动作与回初始位将直接发出(停止不受影响)。",
|
||||||
@@ -157,7 +165,25 @@
|
|||||||
"home": "启动回零",
|
"home": "启动回零",
|
||||||
"controls": "操作",
|
"controls": "操作",
|
||||||
"mm_derived_tip": "毫米值由脉冲当量换算得出,是显示值,不是标定长度。",
|
"mm_derived_tip": "毫米值由脉冲当量换算得出,是显示值,不是标定长度。",
|
||||||
"enabled_count": "已使能 {on}/{total} 轴"
|
"enabled_count": "已使能 {on}/{total} 轴",
|
||||||
|
"units_open": "单位与换算…",
|
||||||
|
"units_locked_tip": "单位已锁定,这里只读。到“设备设置 → 单位与换算”点“修改”才能更改。",
|
||||||
|
"events_title": "报警与事件",
|
||||||
|
"events_clear": "清空记录",
|
||||||
|
"events_empty": "暂无事件。报警、使能、回零、读取失败和单位不一致发生变化时,会记录在这里。",
|
||||||
|
"ev_connected": "控制器已连接",
|
||||||
|
"ev_disconnected": "控制器已断开",
|
||||||
|
"ev_alarm_on": "轴 {axis} 报警",
|
||||||
|
"ev_alarm_off": "轴 {axis} 报警已解除",
|
||||||
|
"ev_enabled": "轴 {axis} 已使能",
|
||||||
|
"ev_disabled": "轴 {axis} 已关闭使能",
|
||||||
|
"ev_read_failed": "轴 {axis} 读取失败:{detail}",
|
||||||
|
"ev_read_ok": "轴 {axis} 读取恢复",
|
||||||
|
"ev_home_started": "轴 {axis} 开始回零",
|
||||||
|
"ev_home_done": "轴 {axis} 回零完成",
|
||||||
|
"ev_home_incomplete": "轴 {axis} 回零结束,但未报告完成",
|
||||||
|
"ev_units_mismatch": "轴 {axis} UNITS 不一致:配置 {expected},读回 {observed}",
|
||||||
|
"ev_units_ok": "轴 {axis} UNITS 已一致"
|
||||||
},
|
},
|
||||||
"busdata": {
|
"busdata": {
|
||||||
"title": "总线数据交互",
|
"title": "总线数据交互",
|
||||||
@@ -223,17 +249,186 @@
|
|||||||
"workspace": {
|
"workspace": {
|
||||||
"tab_axes": "轴调试",
|
"tab_axes": "轴调试",
|
||||||
"tab_sequence": "测试序列",
|
"tab_sequence": "测试序列",
|
||||||
"tab_finger": "手指调试",
|
"tab_finger": "几何与安装",
|
||||||
"tab_sensor": "传感器",
|
"tab_sensor": "标定采集",
|
||||||
"tab_surface": "表面分布",
|
"tab_records": "标定记录",
|
||||||
"tab_points": "标定点",
|
|
||||||
"not_built": "尚未实现",
|
"not_built": "尚未实现",
|
||||||
"reserved_sensor": "传感器连接、实时图像与力曲线将在此显示:设备与信号源选择、预览、以及和 Studio 类似的曲线视图(占用空间更小)。",
|
"reserved_sensor": "传感器连接、实时图像与力曲线将在此显示:设备与信号源选择、预览、以及和 Studio 类似的曲线视图(占用空间更小)。"
|
||||||
"reserved_surface": "多点标定的灵敏度将在此以色图叠加在传感器三维表面上:每个标定点的灵敏度经插值着色,并给出色标与范围。",
|
},
|
||||||
"reserved_points": "标定点列表与采集控制将在此显示:平台坐标、参考载荷、采样统计与灵敏度结果。"
|
"source": {
|
||||||
|
"kind": "输入来源",
|
||||||
|
"kind_video": "视频文件",
|
||||||
|
"kind_camera": "实时相机",
|
||||||
|
"video": "Finger 视频",
|
||||||
|
"camera_index": "相机编号",
|
||||||
|
"config": "传感器配置",
|
||||||
|
"browse": "选择…",
|
||||||
|
"start": "启动输入",
|
||||||
|
"stop": "停止",
|
||||||
|
"idle": "请选择输入来源和 sensor.json",
|
||||||
|
"no_frame": "等待处理后的画面",
|
||||||
|
"preview_only": "先在选择测点页确定 U/V,再在此检查实时二维 UV 图像并开始试验记录。",
|
||||||
|
"choose_video": "选择 Finger 视频",
|
||||||
|
"choose_config": "选择 sensor.json",
|
||||||
|
"video_filter": "视频文件 (*.mp4 *.avi *.mov *.mkv);;所有文件 (*)",
|
||||||
|
"missing_input": "请选择存在的 sensor.json;视频模式还需选择视频文件",
|
||||||
|
"opening": "正在打开输入…",
|
||||||
|
"running": "正在处理画面",
|
||||||
|
"stopping": "正在停止…",
|
||||||
|
"stopped": "已停止",
|
||||||
|
"finished": "视频播放完毕",
|
||||||
|
"disconnected": "相机连接中断",
|
||||||
|
"failed": "采集失败,请检查输入来源",
|
||||||
|
"error": "采集或处理失败:{detail}",
|
||||||
|
"basis_replay": "本次解码",
|
||||||
|
"basis_camera": "相机接收",
|
||||||
|
"basis_unknown": "读取时间(来源未确认)",
|
||||||
|
"frame": "帧 {frame} · 源帧 {source_frame} · {basis} {time} · 丢帧 {dropped}"
|
||||||
|
},
|
||||||
|
"calibration": {
|
||||||
|
"sensor_model": "产品型号",
|
||||||
|
"sensor_sn": "传感器 SN",
|
||||||
|
"output": "保存目录",
|
||||||
|
"site_view": "选择测点",
|
||||||
|
"live_view": "实时二维 UV 图像",
|
||||||
|
"uv": "U / V (px)",
|
||||||
|
"position": "网格坐标",
|
||||||
|
"levels": "加载档位 (N)",
|
||||||
|
"hold": "每档保持时间",
|
||||||
|
"hold_tip": "到达目标力并确认后倒计时;到时结束该档保持窗口并提示下一档,录像持续。计时不代表已验证稳定。",
|
||||||
|
"reference": "人工参考力 (N)",
|
||||||
|
"start": "开始本次试验",
|
||||||
|
"start_tip": "在本场标定下新建一次测点试验,连续记录初始零载、加载、卸载和恢复。不会移动平台。",
|
||||||
|
"confirm": "已到目标力,开始保持",
|
||||||
|
"confirm_tip": "达到当前目标并填写实际参考力后点击。保持期间不要换档;倒计时结束后再按提示调整。",
|
||||||
|
"finish": "完成本次试验",
|
||||||
|
"finish_tip": "初始零载及全部加载、卸载保持完成后停止录像,检查并保存本次结果。",
|
||||||
|
"abort": "提前结束(保留未完成数据)",
|
||||||
|
"abort_tip": "立即结束本次试验并保留未完成数据。重试创建新的 trial,仍归入本场标定。",
|
||||||
|
"load": "加载",
|
||||||
|
"unload": "卸载",
|
||||||
|
"next_step": "当前目标:{branch} {load:g} N;到达后填写实际参考力并开始保持",
|
||||||
|
"before_start": "先选测点并启动输入,再开始试验;先记录初始零载",
|
||||||
|
"ready_to_finish": "所有保持窗口已完成,请完成本次试验",
|
||||||
|
"no_step": "无待确认档位",
|
||||||
|
"progress": "进度 {done}/{total} · {status}",
|
||||||
|
"source_state": "输入:{status} · 帧 {frame} · 采集丢帧 {dropped}",
|
||||||
|
"replay_note": "视频重放仅用于调试;本次人工参考力不能与原实验时间同步。",
|
||||||
|
"idle": "未开始",
|
||||||
|
"recording": "录制中",
|
||||||
|
"closing": "正在写盘",
|
||||||
|
"complete": "已完成",
|
||||||
|
"incomplete": "未完成",
|
||||||
|
"result": "{status}:{path}",
|
||||||
|
"incomplete_result": "本次录制未完成;已写入的数据在:{path}",
|
||||||
|
"error": "操作失败:{detail}",
|
||||||
|
"invalid_site": "请先选择有效的 UV 测点",
|
||||||
|
"start_input": "请先启动相机或视频输入",
|
||||||
|
"reference_required": "请输入实际参考力",
|
||||||
|
"output_required": "请选择保存目录",
|
||||||
|
"zero": "初始零载",
|
||||||
|
"holding": "正在保持:{branch} {load:g} N · 剩余 {seconds:.1f} s;请勿换档",
|
||||||
|
"close_session": "结束本场标定",
|
||||||
|
"close_session_tip": "关闭包含多个测点和重复试验的会话;有未完成试验则保留未完成状态。之后开始试验会新建一场。",
|
||||||
|
"session_none": "尚未建立本场标定;首次开始试验时创建。",
|
||||||
|
"session_active": "本场标定:{sites} 个测点 · {trials} 次试验\n{path}",
|
||||||
|
"session_closed": "本场标定已结束({status}):{path}",
|
||||||
|
"close_before_mapping": "请先结束本场标定,再更换 UV 映射或导出包",
|
||||||
|
"path": "本次路径 (N):{path}",
|
||||||
|
"invalid_levels": "请输入严格递增的加载档位,每档大于 0 且不超过 30 N"
|
||||||
|
},
|
||||||
|
"multipoint": {
|
||||||
|
"tab": "02 多点采集",
|
||||||
|
"hint": "测点直接用标定采集「选择测点」里点中的位置。添加时会用法向解出平台姿态,使该点法向朝上。点「对齐法向」会先询问再移动五个轴;未连接控制器时不会动,原因写在本页下方。模拟采集不移动平台。砝码参考由人工填写。六维力参考记录三个分量,再按你填写的旋转投影到刀具轴,不会把某一轴直接当成法向。换点前确认压头已脱离。",
|
||||||
|
"site_id": "测点编号",
|
||||||
|
"coordinates": "位置 X / Y / Z",
|
||||||
|
"levels": "该点加载档位 (N),不含空载",
|
||||||
|
"hold": "保持时间",
|
||||||
|
"repeats": "重复次数",
|
||||||
|
"frame": "坐标所在坐标系",
|
||||||
|
"frame_placeholder": "填写坐标系名称,程序不解释、不换算",
|
||||||
|
"min_force": "最小力 (N)",
|
||||||
|
"min_placeholder": "空着表示 0;空载基线总会记录",
|
||||||
|
"max_force": "最大力 (N)",
|
||||||
|
"force_placeholder": "必须填写,程序不预设",
|
||||||
|
"force_required": "请填写最大力,程序不会替你假设",
|
||||||
|
"add": "添加测点",
|
||||||
|
"update": "更新所选",
|
||||||
|
"remove": "删除所选",
|
||||||
|
"up": "上移",
|
||||||
|
"down": "下移",
|
||||||
|
"align": "对齐法向",
|
||||||
|
"reacquire": "补采所选",
|
||||||
|
"source": "参考力来源",
|
||||||
|
"source_weight": "砝码",
|
||||||
|
"source_wrench": "六维力",
|
||||||
|
"fx": "Fx",
|
||||||
|
"fy": "Fy",
|
||||||
|
"fz": "Fz",
|
||||||
|
"rotation": "力传感器到刀具的旋转",
|
||||||
|
"rotation_placeholder": "9 个数,行优先。不填不能计算法向力",
|
||||||
|
"reference": "实测参考力 (N)",
|
||||||
|
"simulate": "模拟采集",
|
||||||
|
"start": "开始采集",
|
||||||
|
"confirm": "已到目标力,开始保持",
|
||||||
|
"liftoff": "确认压头已脱离传感器",
|
||||||
|
"remeasure": "重测本档",
|
||||||
|
"remeasure_ready": "这一档已作废,进度停在同一档。接上传感器,等曲线回到断线前靠近零的位置,人工参考力填这一档的力,再点「已到目标力,开始保持」。",
|
||||||
|
"stop": "停止并保留数据",
|
||||||
|
"stopped_reason": "操作员停止",
|
||||||
|
"finger_busy": "单点试验进行中,请先结束再开始多点采集",
|
||||||
|
"camera_required": "开始采集前请先启动相机或视频输入。没有图像时用模拟采集,模拟不会打开相机,也不会移动平台。",
|
||||||
|
"liftoff_wait": "上一点已卸载。下一点是 {site}。先抬起接触头,再点「对齐法向」并确认,平台才移到这个点、法向朝上。到位后再点「确认压头已脱离传感器」。程序不会自动移动。总进度 {done}/{total}",
|
||||||
|
"progress": "测点 {site}({site_index}/{site_count})· {branch} {load:g} N · 重复 {repeat}/{repeats} · 总进度 {done}/{total} · 剩余 {seconds:.1f} s",
|
||||||
|
"idle": "尚未开始。先在「选择测点」里点一个位置,再添加测点,然后模拟采集或开始采集。",
|
||||||
|
"pose": "求解姿态:工作台 X {x:.3f} mm,Y {y:.3f} mm,yaw {yaw:.3f}°,pitch {pitch:.3f}°,Z {z:.3f} mm,残差 {residual:.3f}°。点「对齐法向」后才会询问并移动平台。",
|
||||||
|
"no_pose": "这个测点还没有法向。请在「选择测点」里点一个有效位置,再点「添加测点」或「更新所选」。",
|
||||||
|
"no_map": "请先载入传感器导出包,让「选择测点」出现曲面。",
|
||||||
|
"no_solver": "姿态求解还没有接上。",
|
||||||
|
"align_select": "请先在左边表里点中要去的测点,再点「对齐法向」。",
|
||||||
|
"align_busy": "这一轮还在采集:等它结束再对准其它测点,中途移动平台会让这次记录作废。",
|
||||||
|
"pose_refused": "这个点的姿态不能执行:{reason}",
|
||||||
|
"point_moved": "这个点记录的坐标与当前映射不符(相差 {mm} mm)。请重新在「选择测点」里取这个点并确认,不要用旧记录移动平台。",
|
||||||
|
"normal_moved": "这个点记录的法向与当前映射不符(相差 {deg}°)。请重新在「选择测点」里取这个点并确认。",
|
||||||
|
"point_unmapped": "这个点没有可用的来源坐标(既没有 UV 映射,也不是传感器坐标)。请重新在「选择测点」里取这个点。",
|
||||||
|
"no_finger": "手指调试页面还没有接上。",
|
||||||
|
"no_finger_points": "手指调试里还没有选点。",
|
||||||
|
"branch_baseline": "空载",
|
||||||
|
"branch_load": "加载",
|
||||||
|
"branch_unload": "卸载",
|
||||||
|
"mesh_required": "请先在右侧用 U/V 选出测点,出现网格坐标后再添加。",
|
||||||
|
"right_confirm": "这一档用右侧:砝码填写人工参考力;六维力填写左侧 Fx、Fy、Fz。然后点「已到目标力,开始保持」。",
|
||||||
|
"save": "保存状态:{status}\n{path}",
|
||||||
|
"save_idle": "尚未保存",
|
||||||
|
"save_writing": "正在写入",
|
||||||
|
"save_kept": "已停止,已经采集的数据保留在目录中",
|
||||||
|
"save_done": "本场已完成",
|
||||||
|
"col_id": "编号",
|
||||||
|
"col_x": "X",
|
||||||
|
"col_y": "Y",
|
||||||
|
"col_z": "Z",
|
||||||
|
"col_levels": "档位 (N)",
|
||||||
|
"col_hold": "保持 (s)",
|
||||||
|
"col_repeats": "重复",
|
||||||
|
"col_done": "完成",
|
||||||
|
"capture": "录像:{status} · 帧 {frames} · 丢帧 {dropped} · {reason}",
|
||||||
|
"capture_idle": "未就绪",
|
||||||
|
"capture_recording": "录制中",
|
||||||
|
"capture_closing": "收尾中",
|
||||||
|
"capture_complete": "完整",
|
||||||
|
"capture_incomplete": "证据不完整",
|
||||||
|
"capture_reason_restarted": "图像来源已重启",
|
||||||
|
"capture_reason_zero_frames": "没有写入任何帧",
|
||||||
|
"capture_reason_write_error": "写盘失败",
|
||||||
|
"capture_reason_video_missing": "没有录像帧",
|
||||||
|
"capture_reason_video_undecodable": "录像无法解码",
|
||||||
|
"capture_reason_dropped": "队列丢帧",
|
||||||
|
"capture_reason_segments": "录像分段数不符",
|
||||||
|
"capture_reason_timeout": "收尾超时",
|
||||||
|
"capture_wait": "录像未就绪,暂时不能确认保持"
|
||||||
},
|
},
|
||||||
"finger": {
|
"finger": {
|
||||||
"title": "手指标定点调试",
|
"title": "几何与安装",
|
||||||
"bundle_path": "导出包",
|
"bundle_path": "导出包",
|
||||||
"load": "载入导出包…",
|
"load": "载入导出包…",
|
||||||
"reset_view": "复位视角",
|
"reset_view": "复位视角",
|
||||||
@@ -244,30 +439,38 @@
|
|||||||
"flat_title": "展开平面图(点击选点)",
|
"flat_title": "展开平面图(点击选点)",
|
||||||
"readout_title": "读数",
|
"readout_title": "读数",
|
||||||
"preview_title": "三维预览",
|
"preview_title": "三维预览",
|
||||||
"points_title": "已选标定点",
|
"select_hint": "在展开图上点击一点以试算该点的姿态。",
|
||||||
"select_hint": "在展开图上点击一点以选取标定点。",
|
|
||||||
"no_bundle": "尚未载入导出包。",
|
"no_bundle": "尚未载入导出包。",
|
||||||
"no_surface": "像素 ({x}, {y}) 不在传感面上(UV 空隙)。",
|
"no_surface": "像素 ({x}, {y}) 不在传感面上(UV 空隙)。",
|
||||||
"row_sensor_point": "传感器坐标 (mm)",
|
"row_sensor_point": "传感器坐标 (mm)",
|
||||||
"row_sensor_normal": "曲面法向",
|
"row_sensor_normal": "曲面法向",
|
||||||
"row_platform_point": "平台坐标 (mm)",
|
"row_platform_point": "平台坐标 (mm)",
|
||||||
"row_pose": "位姿 X / Y / yaw / pitch",
|
"row_pose": "位姿 X / Y / yaw / pitch",
|
||||||
|
"row_branch": "所选姿态与理由",
|
||||||
"row_table_move": "工作台移动 X / Y",
|
"row_table_move": "工作台移动 X / Y",
|
||||||
|
"row_press": "压轴目标",
|
||||||
"row_residual": "对齐残差",
|
"row_residual": "对齐残差",
|
||||||
|
"row_match": "点位匹配",
|
||||||
|
"match_none": "先在展开图上选一个点",
|
||||||
|
"match_offline": "未连接平台",
|
||||||
|
"match_value": "选择 X {sx} Y {sy} mm,回转 {syaw}°,俯仰 {spitch}°。实际 X {ax} Y {ay} mm,回转 {ayaw}°,俯仰 {apitch}°。差 X {dx} Y {dy} mm,回转 {dyaw}°,俯仰 {dpitch}°",
|
||||||
"row_frames": "坐标映射",
|
"row_frames": "坐标映射",
|
||||||
|
"branch_none": "—",
|
||||||
|
"branch_value": "{reason}(本解合计 {travel}°,另一精确解 {other}°)",
|
||||||
|
"branch_only": "唯一精确解",
|
||||||
|
"branch_alignment": "残差最小",
|
||||||
|
"branch_in_range": "行程内",
|
||||||
|
"branch_upright": "传感面仍朝上",
|
||||||
|
"branch_smallest": "转动量最小",
|
||||||
|
"branch_crown": "已朝上,只平移",
|
||||||
|
"press_unset": "未标定零位:本动作保持压轴不动(在 设备设置 → 压轴零位 填入实测零位)",
|
||||||
|
"press_target": "{target} mm(几何接触高度 {height} mm)",
|
||||||
"tool_xy": "刀具 X / Y",
|
"tool_xy": "刀具 X / Y",
|
||||||
"tool_hint": "刀具在工作台坐标系中的位置,默认在原点正上方;工作台的移动量就是把接触点送到此处所需的位移。请按实际安装测量填写。",
|
"tool_hint": "刀具在工作台坐标系中的位置。每个测点的工作台移动都相对这个位置计算。",
|
||||||
"sensor_xy": "传感器 X / Y",
|
"sensor_xy": "传感器 X / Y",
|
||||||
"sensor_zy": "传感器 Z / yaw",
|
"sensor_zy": "传感器 Z / yaw",
|
||||||
"setup_hint": "传感器在转台零位时相对工作台原点的实际安装位姿:X/Y/Z 平移与绕 Z 的偏摆。Z 即安装板厚度(含垫片),无需另填。请按实际安装测量填写。",
|
"setup_hint": "传感器在转台零位时相对工作台原点的实际安装位姿:X/Y/Z 平移与绕 Z 的偏摆。Z 为安装板厚度,当前安装台是 8 mm。",
|
||||||
"clear_points": "清空标定点",
|
|
||||||
"remove_selected": "删除所选",
|
|
||||||
"move_up": "上移",
|
|
||||||
"move_down": "下移",
|
|
||||||
"selected_count": "已选 {count} 点",
|
|
||||||
"table_hint": "勾选复选框选中该点;双击传感器坐标或法向即可修改。位姿列由这些数值解算得出,会自动更新。",
|
|
||||||
"edit_not_a_number": "该单元格需要数字,已保留原值。",
|
"edit_not_a_number": "该单元格需要数字,已保留原值。",
|
||||||
"point_count": "共 {count} 点",
|
|
||||||
"residual_value": "{residual}° — {verdict}",
|
"residual_value": "{residual}° — {verdict}",
|
||||||
"reachable": "该点法向可与 Z 轴对齐",
|
"reachable": "该点法向可与 Z 轴对齐",
|
||||||
"unreachable": "该点法向无法与 Z 轴对齐(受 yaw 行程限制)",
|
"unreachable": "该点法向无法与 Z 轴对齐(受 yaw 行程限制)",
|
||||||
@@ -289,13 +492,20 @@
|
|||||||
"preview_no_gl": "无 OpenGL 上下文:{error}",
|
"preview_no_gl": "无 OpenGL 上下文:{error}",
|
||||||
"preview_draw_failed": "绘制失败:{error}",
|
"preview_draw_failed": "绘制失败:{error}",
|
||||||
"preview_solve_failed": "求解失败:{error}",
|
"preview_solve_failed": "求解失败:{error}",
|
||||||
"col_x": "x", "col_y": "y", "col_z": "z",
|
|
||||||
"col_nx": "nx", "col_ny": "ny", "col_nz": "nz",
|
|
||||||
"col_X": "X", "col_Y": "Y", "col_Z": "Z",
|
|
||||||
"col_yaw": "yaw", "col_pitch": "pitch", "col_residual": "残差(°)",
|
|
||||||
"loaded": "已载入导出包:{vertices} 顶点 · {triangles} 三角面 · {extent} mm · 每单位 {scale} mm",
|
"loaded": "已载入导出包:{vertices} 顶点 · {triangles} 三角面 · {extent} mm · 每单位 {scale} mm",
|
||||||
"load_failed": "载入失败: {error}",
|
"load_failed": "载入失败: {error}",
|
||||||
"picked": "已选点:残差 {residual}° · yaw {yaw}° · pitch {pitch}°"
|
"picked": "已选点:残差 {residual}° · yaw {yaw}° · pitch {pitch}°",
|
||||||
|
"bg_label": "底图",
|
||||||
|
"colour_label": "点色",
|
||||||
|
"bg_original": "原图",
|
||||||
|
"bg_gray": "灰度",
|
||||||
|
"bg_dim": "变暗",
|
||||||
|
"bg_outline": "仅轮廓",
|
||||||
|
"colour_yellow": "黄",
|
||||||
|
"colour_white": "白",
|
||||||
|
"colour_cyan": "青",
|
||||||
|
"colour_orange": "橙",
|
||||||
|
"colour_grey": "灰"
|
||||||
},
|
},
|
||||||
"sequence": {
|
"sequence": {
|
||||||
"title": "测试序列设计",
|
"title": "测试序列设计",
|
||||||
@@ -357,7 +567,19 @@
|
|||||||
"imported": "已导入序列: {name}({count} 点)",
|
"imported": "已导入序列: {name}({count} 点)",
|
||||||
"import_warnings": "导入有 {count} 项提示",
|
"import_warnings": "导入有 {count} 项提示",
|
||||||
"confirm_discard": "当前序列尚未导出,导入将替换它。是否继续?",
|
"confirm_discard": "当前序列尚未导出,导入将替换它。是否继续?",
|
||||||
"points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。"
|
"points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。",
|
||||||
|
"col_u": "U (px)",
|
||||||
|
"col_v": "V (px)",
|
||||||
|
"btn_send": "送到标定页({count})",
|
||||||
|
"send_tip": "把选中的点按 U/V 加入标定采集页的点队列,并切换到该页。",
|
||||||
|
"send_none": "先选中带 U/V 的行。",
|
||||||
|
"btn_add_picked": "加入所选点",
|
||||||
|
"add_picked_tip": "把平面图上刚点选的点加入序列(带 U/V)。",
|
||||||
|
"btn_remove": "删除所选",
|
||||||
|
"add_picked_none": "先在平面图上点选一个点。",
|
||||||
|
"add_picked_duplicate": "该点已在序列第 {row} 行。",
|
||||||
|
"tab_array": "阵列",
|
||||||
|
"tab_motion": "运动参数"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"ok": "完成",
|
"ok": "完成",
|
||||||
@@ -373,5 +595,436 @@
|
|||||||
"bas_loaded": "BAS 已加载",
|
"bas_loaded": "BAS 已加载",
|
||||||
"yes": "是",
|
"yes": "是",
|
||||||
"no": "否"
|
"no": "否"
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"waiting": "等待力信号",
|
||||||
|
"uncalibrated": "无 N 值",
|
||||||
|
"stopped": "输入已停止;显示最后一帧,数值不再更新。",
|
||||||
|
"stale": "尚无新帧或超过 1 秒未更新,请检查输入。",
|
||||||
|
"invalid": "当前力信号无效:{reason}",
|
||||||
|
"raw_note": "实时原始力信号(最近 60 秒);raw 不能直接当作 N。人工参考力由操作者另行填写。",
|
||||||
|
"overflow": "预览漏显 {count} 帧;请检查采集记录的丢帧状态",
|
||||||
|
"raw_axis": "原始力信号 (raw)",
|
||||||
|
"time_axis": "采集时间 (s)",
|
||||||
|
"background": "真实背景",
|
||||||
|
"background_tip": "勾选显示真实 UV 图像;取消显示纯色背景。深度颜色可叠加在两种背景上。",
|
||||||
|
"arrows": "位移箭头",
|
||||||
|
"depth": "深度",
|
||||||
|
"depth_tip": "显示算法深度/形变信号的伪彩色,不表示已标定的毫米深度。",
|
||||||
|
"depth_scale": "形变色阶自动适应本帧:{low:.4g}–{high:.4g}(算法单位,非 mm)",
|
||||||
|
"depth_missing": "本帧无有效深度/形变信号",
|
||||||
|
"flow_missing": "本帧无有效位移信号"
|
||||||
|
},
|
||||||
|
"overview": {
|
||||||
|
"capture_in_progress": "采集中",
|
||||||
|
"saved": "分析结果已保存;保存位置见提示。",
|
||||||
|
"saved_summary": "已保存复核并发布汇总:{path} → {summary}",
|
||||||
|
"open_session": "打开标定会话…",
|
||||||
|
"empty": "请选择包含 session.json 的会话文件夹,查看已采集测点、法向响应和灵敏度;也可从标定采集页打开本场数据。",
|
||||||
|
"title": "标定记录总览",
|
||||||
|
"notice": "查看实际采集覆盖与法向响应;点击 UV 点或表格联动。圆点为已确认窗口,方点为候选窗口;各 trial 用不同明暗度。拟合线为本测点全部符合条件的加载数据,筛选 trial 不会重新拟合。",
|
||||||
|
"coverage": "采集状态",
|
||||||
|
"sensitivity": "法向灵敏度",
|
||||||
|
"import_plan": "导入计划测点 CSV…",
|
||||||
|
"clear_plan": "清除计划对照",
|
||||||
|
"load": "载入历史分析…",
|
||||||
|
"save": "保存分析结果",
|
||||||
|
"summary": "已采 {collected} 点/计划 {planned} 点;漏采 {missing};有未完成试验 {incomplete};待检查 {pending};已检查 {reviewed};计划外 {extra}。",
|
||||||
|
"no_plan": "未导入计划,暂不能判断漏采点。",
|
||||||
|
"uncollected": "尚未采集",
|
||||||
|
"incomplete": "有未完成试验",
|
||||||
|
"pending": "待检查",
|
||||||
|
"reviewed": "稳定段已检查",
|
||||||
|
"confirmed": "已检查",
|
||||||
|
"candidate": "待检查",
|
||||||
|
"coverage_map": "红:有未完成试验;橙:待检查;绿:已检查;灰:未采",
|
||||||
|
"sensitivity_map": "已确认灵敏度:蓝→红 {low:.4g}→{high:.4g} raw/N<br/>灰:无已确认拟合",
|
||||||
|
"details": "检查保持段…",
|
||||||
|
"trial_filter": "显示试验",
|
||||||
|
"all_trials": "全部 trial",
|
||||||
|
"site_fit": "测点加载拟合(全部符合条件的 trial)",
|
||||||
|
"response_title": "法向响应:参考力 → 原始信号",
|
||||||
|
"selected_fit": "灵敏度 {sensitivity} raw/N · {basis}。拟合按本点全部合格加载窗口计算;范围及试验数见测点提示。",
|
||||||
|
"no_windows": "所选试验没有可分析的完整窗口;可查看已有原始信号和结束原因。",
|
||||||
|
"col_order": "计划序号",
|
||||||
|
"col_site": "采集测点",
|
||||||
|
"col_uv": "U / V (px)",
|
||||||
|
"col_xyz": "传感器 XYZ (mm)",
|
||||||
|
"col_status": "分析准备状态",
|
||||||
|
"col_trials": "完成/全部 trial",
|
||||||
|
"col_windows": "确认/完整窗口",
|
||||||
|
"col_levels": "已确认加载档位 (N)",
|
||||||
|
"col_sensitivity": "灵敏度 (raw/N)/依据",
|
||||||
|
"col_range": "拟合范围 (N)",
|
||||||
|
"col_fit_trials": "拟合 trial 数",
|
||||||
|
"tools": "计划/历史分析",
|
||||||
|
"identity": "型号:{model} SN:{sn} 采集时间:{date} 来源:{source} 采集:{status}",
|
||||||
|
"source_video": "视频重放(仅调试)",
|
||||||
|
"source_camera": "相机",
|
||||||
|
"timeline": "时间曲线/记录诊断",
|
||||||
|
"response": "加载/卸载响应",
|
||||||
|
"time_axis": "距各试验首个有效样本 (s)",
|
||||||
|
"reference_axis": "人工参考力 (N)",
|
||||||
|
"timeline_title": "蓝:FN_raw;橙:人工保持参考力(不是实时 FT)",
|
||||||
|
"no_trace": "没有可读的有效信号;请查看下方记录诊断",
|
||||||
|
"outline_snapshot": "灰:传感面轮廓;蓝:彩色 patch 区域轮廓(采集快照)",
|
||||||
|
"outline_surface_only": "传感面映射已核对;未保存彩色 patch 轮廓,无法恢复。",
|
||||||
|
"outline_unavailable": "找不到记录对应的 UV 轮廓;仅显示测点。",
|
||||||
|
"outline_mismatch": "UV 轮廓与记录映射不一致,已停用叠加。",
|
||||||
|
"analysis_reviewed": "稳定段已检查",
|
||||||
|
"analysis_pending": "待检查稳定段",
|
||||||
|
"analysis_no_analysis": "无可分析窗口",
|
||||||
|
"site_details": "XYZ (mm):{xyz}\n完成/全部试验:{attempts}\n拟合范围 (N):{range};拟合试验数:{trials}",
|
||||||
|
"inspect_incomplete": "查看中止记录",
|
||||||
|
"diagnostic": "{trial} · {status}|有效样本 {samples};无效/未关联 {invalid};录像丢帧 {dropped};最大帧间隔 {gap} s\n结束原因:{reason}",
|
||||||
|
"read_errors": "缺失或无法读取:{detail}",
|
||||||
|
"choose_trial": "请选择单次试验查看完整性、丢帧及结束原因;全部试验曲线按各自起点对齐。",
|
||||||
|
"unsaved": "窗口或计划已修改,尚未保存。",
|
||||||
|
"save_hint": "确认稳定只表示检查过信号,不代表标定精度合格。",
|
||||||
|
"guide_missing": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:此点尚未采集,请选择已采点或安排补采。",
|
||||||
|
"guide_incomplete": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:查看结束原因及已录曲线,决定补采;未完成试验不参与拟合。",
|
||||||
|
"guide_check": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:选择单次试验,点击“检查保持段…”;必要时调整时间区间。",
|
||||||
|
"guide_save": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:继续检查剩余保持段,或保存当前分析结果。",
|
||||||
|
"video": "查看录像",
|
||||||
|
"video_not_recorded": "按设置未录视频"
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"open": "重复性与验证…",
|
||||||
|
"title": "测点质量分析",
|
||||||
|
"notice": "仅使用人工已确认的有效窗口;结果仍为初步分析。视频调试、人工稳定确认及 trial 留出验证均不能证明实物准确度或参考力同步。",
|
||||||
|
"repeatability": "重复性",
|
||||||
|
"repeatability_note": "相同分支、相同人工参考力下,先计算各 trial 的均值,再计算 trial 间极差和样本标准差。至少两次 trial 才有重复性统计;这不同于保持段内波动。",
|
||||||
|
"hysteresis": "加载/卸载差异",
|
||||||
|
"hysteresis_note": "只配对同一 trial 内人工参考力完全相同的非零加载与卸载窗口,不插值。差值为卸载减加载,单位 raw;缺少匹配档位时不生成结果。",
|
||||||
|
"zero_return": "零载恢复",
|
||||||
|
"zero_return_note": "同一 trial 的最终卸载零载减初始零载,单位 raw;只配对人工参考力为 0 N 的已确认窗口。",
|
||||||
|
"validation": "独立 trial 验证",
|
||||||
|
"validation_note": "逐次留出一个完整 trial,用其余 trial 的零载/加载窗口拟合,再预测留出 trial 的全部已确认窗口。每窗口等权;这是交叉验证,不能代替另一场独立实验。",
|
||||||
|
"validation_summary": "验证窗口 {count} 个;RMSE {rmse} N;MAE {mae} N;最大绝对误差 {maximum} N;超出训练 raw 范围 {outside} 个。",
|
||||||
|
"no_data": "尚无可用数据,请先复核所需窗口。",
|
||||||
|
"insufficient_training": "其余 trial 的训练数据不足",
|
||||||
|
"outside_range": "超出训练 raw 范围",
|
||||||
|
"in_range": "训练 raw 范围内",
|
||||||
|
"col_branch": "阶段",
|
||||||
|
"col_load": "人工参考力 (N)",
|
||||||
|
"col_trials": "trial 数",
|
||||||
|
"col_range": "trial 间极差 (raw)",
|
||||||
|
"col_std": "trial 间样本标准差 (raw)",
|
||||||
|
"col_trial": "trial",
|
||||||
|
"col_load_raw": "加载均值 (raw)",
|
||||||
|
"col_unload_raw": "卸载均值 (raw)",
|
||||||
|
"col_delta_raw": "差值 (raw)",
|
||||||
|
"col_initial_zero": "初始零载 (raw)",
|
||||||
|
"col_final_zero": "最终零载 (raw)",
|
||||||
|
"col_held_out": "留出验证 trial",
|
||||||
|
"col_training": "训练 trial",
|
||||||
|
"col_predicted": "预测力 (N)",
|
||||||
|
"col_error": "预测减参考 (N)",
|
||||||
|
"col_range_status": "范围/状态"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"checking": "正在后台读取并校验记录,请稍候…",
|
||||||
|
"load_selection": "载入历史分析…",
|
||||||
|
"confirm_stable": "此时间段已检查稳定",
|
||||||
|
"apply_window": "确认选中时间段并重算",
|
||||||
|
"start_offset": "起点",
|
||||||
|
"end_offset": "终点",
|
||||||
|
"verified": "人工已确认稳定",
|
||||||
|
"confirmed_fit": "拟合使用人工确认窗口;仍需独立验证。",
|
||||||
|
"candidate_fit": "拟合使用候选窗口;人工确认数据尚不足。",
|
||||||
|
"valid_samples": "有效原始样本",
|
||||||
|
"hold_time_axis": "距保持开始 (s)",
|
||||||
|
"trace_title": "选中保持段原始信号;竖线为分析窗口边界",
|
||||||
|
"selection_unsaved": "窗口已应用;请另存分析结果以保留复核。",
|
||||||
|
"open": "查看标定数据…",
|
||||||
|
"title": "保持段检查",
|
||||||
|
"notice": "选择保持段 → 检查曲线、标准差及漂移 → 调整起止时间 → 确认稳定并重算 → 保存。人工确认不是精度验收。",
|
||||||
|
"save": "保存分析结果",
|
||||||
|
"no_sites": "会话没有测点",
|
||||||
|
"candidate": "候选窗口",
|
||||||
|
"insufficient_samples": "有效样本不足",
|
||||||
|
"no_frame_source": "本次循环没有录像帧来源:参考窗口已记录,但没有可用于计算的原始法向信号",
|
||||||
|
"time_basis_not_comparable": "时间基准与保持窗口不可比(录像不是本机实时采集),未参与计算",
|
||||||
|
"replay": "视频调试",
|
||||||
|
"unverified": "稳定性未复核",
|
||||||
|
"fit": "加载分支初步拟合:灵敏度 {sensitivity_raw_per_n:.6g} raw/N;增益 {gain_n_per_raw:.6g} N/raw;偏置 {offset_n:.6g} N;拟合 RMSE {rmse_n:.4g} N(不是独立验证误差)",
|
||||||
|
"no_fit": "有效载荷或原始信号变化不足,无法拟合。",
|
||||||
|
"fit_line": "加载初步拟合",
|
||||||
|
"raw_axis": "原始法向信号 (raw)",
|
||||||
|
"force_axis": "人工参考力 (N)",
|
||||||
|
"chart_title": "各保持窗口均值:加载/卸载对照",
|
||||||
|
"uv_title": "测点初步灵敏度:蓝→红 {low:.4g}→{high:.4g} raw/N;灰色无拟合",
|
||||||
|
"issues": "{count} 次未完成试验不参与拟合;请按原始曲线检查稳定性。",
|
||||||
|
"saved": "已另存:{path}",
|
||||||
|
"saved_summary": "已保存复核并发布汇总:{path} → {summary}",
|
||||||
|
"col_trial": "试验",
|
||||||
|
"col_branch": "阶段",
|
||||||
|
"col_load": "参考力 (N)",
|
||||||
|
"col_samples": "有效样本",
|
||||||
|
"col_mean": "原始均值",
|
||||||
|
"col_std": "原始标准差",
|
||||||
|
"col_duration": "保持 (s)",
|
||||||
|
"col_quality": "质量说明",
|
||||||
|
"col_drift": "漂移 (raw/s)"
|
||||||
|
},
|
||||||
|
"video": {
|
||||||
|
"title": "录像复核",
|
||||||
|
"header": "{trial} · 第 {step} 步({branch} {load} N)· 复核状态 {status} · 有效样本 {samples}",
|
||||||
|
"position": "第 {index}/{total} 帧 · 帧号 {frame} · 分段 {segment} 第 {segment_frame} 帧 · 时间基准 {basis}",
|
||||||
|
"play": "播放",
|
||||||
|
"pause": "暂停",
|
||||||
|
"previous": "上一帧",
|
||||||
|
"next": "下一帧",
|
||||||
|
"frame_missing": "该帧没有写入录像(队列丢帧)",
|
||||||
|
"reason_missing": "无法打开录像:{detail}",
|
||||||
|
"reason_no_frame_source": "这段录像没有可用的视频证据(未接相机或没有写入帧)",
|
||||||
|
"reason_no_video_frame": "该保持窗口内没有已写入视频的有效帧",
|
||||||
|
"reason_trial_not_found": "记录中找不到这次试验",
|
||||||
|
"reason_window_not_found": "在该试验中找不到这个保持窗口",
|
||||||
|
"reason_video_undecodable": "录像文件无法解码",
|
||||||
|
"reason_decode": "解码这一帧失败",
|
||||||
|
"cursor": "当前帧",
|
||||||
|
"reference": "实际参考",
|
||||||
|
"curve_title": "原始力曲线与该保持窗口",
|
||||||
|
"basis_unknown": "未记录",
|
||||||
|
"open": "查看录像"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"failed": "汇总导出失败:{detail}。采集结果没有改变,可以重试。",
|
||||||
|
"published": "汇总已发布:{path}",
|
||||||
|
"retry": "重新导出汇总",
|
||||||
|
"done": "汇总已重新发布:{path}",
|
||||||
|
"reason_hold_not_found": "记录中找不到这个保持尝试",
|
||||||
|
"reason_hold_not_complete": "这个保持尝试已被重测废弃,没有可复核的画面",
|
||||||
|
"copy_failed": "汇总已发布,但可读副本没有写成:{detail}。指针仍指向已发布的汇总,采集结果未改变。"
|
||||||
|
},
|
||||||
|
"cap": {
|
||||||
|
"check": {
|
||||||
|
"bundle": "标定包",
|
||||||
|
"frames": "输入出帧",
|
||||||
|
"bus": "总线与轴映射",
|
||||||
|
"output": "输出目录可写",
|
||||||
|
"sn": "传感器 SN",
|
||||||
|
"max_force": "最大力"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"start": "开始采集",
|
||||||
|
"align": "对准当前点",
|
||||||
|
"confirm_target": "已到目标力,开始保持",
|
||||||
|
"confirm_hold": "保持完成,记录本档",
|
||||||
|
"confirm_liftoff": "确认压头已脱离",
|
||||||
|
"finish": "结束会话",
|
||||||
|
"remeasure": "重测本档",
|
||||||
|
"skip": "跳过本点",
|
||||||
|
"stop": "停止并保留数据",
|
||||||
|
"open_session": "打开会话文件夹",
|
||||||
|
"open_summary": "打开汇总",
|
||||||
|
"copy_path": "复制路径",
|
||||||
|
"settings": "更改输入/标定包…"
|
||||||
|
},
|
||||||
|
"block": {
|
||||||
|
"closing": "正在写盘,等待收尾完成",
|
||||||
|
"align_while_recording": "正在录制,不能移动平台去对准其它点",
|
||||||
|
"skip_live": "这一档已经开始录制,不能跳过",
|
||||||
|
"no_directory": "还没有会话目录",
|
||||||
|
"no_summary": "还没有汇总;结束本场标定后自动发布",
|
||||||
|
"not_ready": "还有未满足的检查项,见上方 Ready 列表",
|
||||||
|
"no_sites": "队列里还没有测点",
|
||||||
|
"no_session": "还没有配置会话参数(输出目录、型号、SN 等)",
|
||||||
|
"no_solver": "没有接上姿态求解器,不能对准",
|
||||||
|
"no_pose": "这个点还没有解出可用的姿态",
|
||||||
|
"hold_running": "保持时间还没到",
|
||||||
|
"remeasure_unavailable": "当前没有可重测的档位",
|
||||||
|
"stop_unavailable": "没有进行中的会话可停止",
|
||||||
|
"finish_unavailable": "会话还没有结束,不能点结束",
|
||||||
|
"skip_unavailable": "程序没有提供跳过当前点的推进接口,本版不能跳过",
|
||||||
|
"busy": "当前阶段不能执行这个动作",
|
||||||
|
"no_bundle": "还没有载入标定包(导出包)",
|
||||||
|
"no_uv": "先在 UV 图上点选位置",
|
||||||
|
"no_output": "先选择保存目录",
|
||||||
|
"no_sn": "先填写传感器 SN",
|
||||||
|
"no_max_force": "先填写最大力 (N)",
|
||||||
|
"queue_busy": "会话进行中,不能修改测点队列",
|
||||||
|
"bundle_busy": "会话进行中,不能更换标定包",
|
||||||
|
"no_worker": "输入没有运行,无法开始录像",
|
||||||
|
"bad_levels": "加载档位需要严格递增、每档大于 0 且不超过最大力",
|
||||||
|
"bad_reference": "人工参考力不是有效数字",
|
||||||
|
"align_running": "对准移动还在进行,请等它完成",
|
||||||
|
"pose_unreachable": "当前安装或行程下这个点不可达,已禁止对准"
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"active": "本场标定:{sites} 个测点 · 进度 {done}/{total} 档\n{path}",
|
||||||
|
"none": "尚未建立本场标定;对准第一点后点「开始采集」创建。",
|
||||||
|
"settings_hint": "输入源、标定包、会话参数与录像选项都在「更改输入/标定包…」里;开始试验不会切换页面。"
|
||||||
|
},
|
||||||
|
"queue": {
|
||||||
|
"title": "测点队列",
|
||||||
|
"empty": "队列里还没有测点:在 UV 图上点选位置,填好档位后点「添加当前点」。",
|
||||||
|
"add": "添加当前点",
|
||||||
|
"update": "更新选中",
|
||||||
|
"remove": "删除选中",
|
||||||
|
"up": "上移",
|
||||||
|
"down": "下移",
|
||||||
|
"site_id": "测点编号",
|
||||||
|
"repeats": "重复次数",
|
||||||
|
"current_uv": "当前 UV",
|
||||||
|
"col_id": "测点",
|
||||||
|
"col_position": "传感器位置 (mm)",
|
||||||
|
"col_repeats": "重复进度",
|
||||||
|
"col_reachable": "可达性",
|
||||||
|
"col_state": "状态",
|
||||||
|
"reachable": "可达",
|
||||||
|
"unreachable": "不可达",
|
||||||
|
"state_pending": "待采",
|
||||||
|
"state_partial": "进行中",
|
||||||
|
"state_complete": "已完成",
|
||||||
|
"state_skipped": "已跳过",
|
||||||
|
"selected": "当前选中:{site}",
|
||||||
|
"update_short": "更新",
|
||||||
|
"remove_short": "删除",
|
||||||
|
"export": "导出测点操作表…",
|
||||||
|
"export_tip": "按当前队列顺序导出 U/V、传感器坐标、平台目标与映射身份;坐标与映射不符时拒绝导出。",
|
||||||
|
"export_saved": "已导出操作表:{path}",
|
||||||
|
"export_no_points": "队列里还没有测点",
|
||||||
|
"export_no_map": "还没有载入 UV 映射,无法导出操作表",
|
||||||
|
"export_missing_uv": "第 {order} 点没有 UV 坐标,无法导出操作表",
|
||||||
|
"export_no_pose": "第 {order} 点还没有解出姿态,无法导出操作表",
|
||||||
|
"export_edited_point": "第 {order} 点的传感器坐标与当前 UV 映射不符;请重新取点后再导出。"
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"title": "当前点",
|
||||||
|
"no_point": "队列是空的:先添加测点。",
|
||||||
|
"no_progress": "开始后显示进度。",
|
||||||
|
"next_site": "下一测点:{site}"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"align_point": "① 对准当前点",
|
||||||
|
"start_session": "② 开始采集",
|
||||||
|
"confirm_target": "③ 到达目标力,开始保持",
|
||||||
|
"confirm_hold": "④ 保持完成,记录本档",
|
||||||
|
"confirm_liftoff": "⑤ 确认离开,换到下一个点",
|
||||||
|
"finish_session": "⑥ 结束会话",
|
||||||
|
"closing": "正在写盘"
|
||||||
|
},
|
||||||
|
"hint": {
|
||||||
|
"align_point": "先对准:点主按钮确认移动计划,平台到位并确认后才会出现「开始采集」。",
|
||||||
|
"start_session": "本点已对准。点主按钮建立本场标定,并从第一个点开始逐档记录。",
|
||||||
|
"not_ready": "标记 ✗ 的检查项满足后才能开始;鼠标停在标记上可看原因。",
|
||||||
|
"confirm_target": "按当前档位加载到目标力,填写人工参考力(或六维力分量)后点主按钮。",
|
||||||
|
"confirm_hold": "保持中:剩余 {seconds:.1f} s;请勿换档。",
|
||||||
|
"confirm_liftoff": "这一轮记录完成。抬起接触头、确认压头已脱离传感器,再点主按钮换到下一个点。",
|
||||||
|
"finish_session": "全部记录已写入。点主按钮结束本场标定并发布汇总。",
|
||||||
|
"closing": "正在写盘并检查录像;收尾完成前不能继续。"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"aligning": "已发出对准请求:{site}。移动完成并确认后才会允许开始采集。",
|
||||||
|
"aligned": "已确认对准:{site}",
|
||||||
|
"align_failed": "对准未完成:{detail}。平台没有确认到位,请重新对准。",
|
||||||
|
"started": "已开始本场标定:{path}",
|
||||||
|
"attach_failed": "无法开始录像:{detail}",
|
||||||
|
"queue_added": "已添加测点 {site}",
|
||||||
|
"queue_removed": "已删除测点 {site}",
|
||||||
|
"skipped": "已跳过测点 {site}",
|
||||||
|
"stopped": "已停止,已写入的数据保留。",
|
||||||
|
"done": "本场标定已结束:{path}",
|
||||||
|
"queue_added_many": "已加入 {added} 个点到标定队列,{refused} 个未能加入。"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"title": "录像",
|
||||||
|
"video": "录制视频",
|
||||||
|
"codec": "视频编码",
|
||||||
|
"lossless": "同时保存无损帧",
|
||||||
|
"unavailable": "{codec}:本机不可用,已禁用",
|
||||||
|
"probe_failed": "编码探测没有运行:{detail}",
|
||||||
|
"off": "按设置不录视频",
|
||||||
|
"estimate": "预计占用 {size}({seconds});磁盘剩余 {free}",
|
||||||
|
"disk_short": "磁盘剩余不足估算体积的两倍",
|
||||||
|
"no_plan": "队列为空,暂时无法估算体积",
|
||||||
|
"locked": "会话建立后录像选项锁定",
|
||||||
|
"summary": "录像:{codec}"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "采集设置",
|
||||||
|
"input": "输入源",
|
||||||
|
"bundle": "标定包(导出包)",
|
||||||
|
"bundle_none": "未载入",
|
||||||
|
"bundle_load": "载入导出包…",
|
||||||
|
"session": "会话参数",
|
||||||
|
"simulate": "模拟运行(不接相机、不移动平台)",
|
||||||
|
"close": "关闭",
|
||||||
|
"max_required": "必须填写,程序不预设"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"units": {
|
||||||
|
"hint": "集中检查每个方向用的驱动器、脉冲当量(UNITS)和换算。默认锁定为只读;需要改动时点“修改”,写入前会列出改动并要求确认。",
|
||||||
|
"col_role": "方向",
|
||||||
|
"col_axis": "驱动器",
|
||||||
|
"col_kind": "类型",
|
||||||
|
"col_configured": "配置 UNITS",
|
||||||
|
"col_observed": "控制器读回",
|
||||||
|
"col_scale": "换算",
|
||||||
|
"col_check": "检查",
|
||||||
|
"kind_linear": "线性 (mm)",
|
||||||
|
"kind_rotary": "旋转 (°)",
|
||||||
|
"scale_linear": "{mm} mm/单位 · {per}/mm",
|
||||||
|
"scale_rotary": "{deg}°/单位 · {per}/°",
|
||||||
|
"status_ok": "一致",
|
||||||
|
"status_unread": "未读回",
|
||||||
|
"status_mismatch": "不一致",
|
||||||
|
"status_uncalibrated": "无角度标定",
|
||||||
|
"status_invalid": "配置无效",
|
||||||
|
"status_unmapped": "未映射",
|
||||||
|
"tip_unread": "未连接,或该轴的 UNITS 尚未成功读回。",
|
||||||
|
"tip_mismatch": "控制器读回的 UNITS 与配置不同:姿态移动会被拒绝,不会重新换算目标。",
|
||||||
|
"tip_uncalibrated": "旋转轴的角度比例只在标定当量下测量过;其他值需要重新标定。",
|
||||||
|
"tip_invalid": "UNITS 必须是大于 0 的有限数。",
|
||||||
|
"tip_unmapped": "该方向没有分配驱动器,见“轴映射”。",
|
||||||
|
"summary_ok": "单位检查:{total} 个方向全部一致",
|
||||||
|
"summary_bad": "单位检查:{bad}/{total} 个方向需要处理",
|
||||||
|
"summary_unread": "单位检查:尚未读回(未连接)",
|
||||||
|
"constants": "固定标定常数(实机测得,不能在此修改)\n线性轴:{ppm} 脉冲/mm\n旋转轴:{rotary}\n以上均在 UNITS={cal} 下测得;毫米与角度是由这些常数换算的显示值,不是新的测量。",
|
||||||
|
"rotary_item": "轴 {axis}:{units} 用户单位 = 90°",
|
||||||
|
"modify": "修改",
|
||||||
|
"apply": "应用到控制器",
|
||||||
|
"cancel": "取消",
|
||||||
|
"modify_offline": "未连接控制器,无法修改。",
|
||||||
|
"locked_note": "已锁定:只读显示。",
|
||||||
|
"unlocked_note": "已解锁:在“配置 UNITS”列输入新值,然后点“应用到控制器”。",
|
||||||
|
"mapping_changed": "轴映射已改变,未应用的修改已放弃并重新锁定。",
|
||||||
|
"invalid": "{role} 的 UNITS 无效:需要大于 0 的数字。",
|
||||||
|
"no_change": "没有改动,已重新锁定。",
|
||||||
|
"applied": "已提交 {count} 个驱动器的 UNITS;读回结果将在下一次刷新后显示。",
|
||||||
|
"change_line": "{role} · 轴 {axis}:{old} → {new}",
|
||||||
|
"confirm_title": "写入 UNITS",
|
||||||
|
"confirm_body": "将向控制器写入以下 UNITS:\n{changes}\n\n写入后,全部毫米和角度读数以及姿态移动的目标都按新当量重新计算。请确认平台已停止,并已核对机械传动比。",
|
||||||
|
"confirm_rotary": "注意:{roles} 的 UNITS 不是标定当量 {cal},旋转轴在该当量下没有角度标定,姿态移动会被拒绝,直到重新标定。"
|
||||||
|
},
|
||||||
|
"array": {
|
||||||
|
"mode": "类型",
|
||||||
|
"margin": "边距 (px)",
|
||||||
|
"rows": "行数",
|
||||||
|
"cols": "列数",
|
||||||
|
"u_pitch": "U 间距 (px)",
|
||||||
|
"v_pitch": "V 间距 (px)",
|
||||||
|
"centre_u": "中心 U",
|
||||||
|
"centre_v": "中心 V",
|
||||||
|
"preset": "阶段",
|
||||||
|
"mode_grid": "规则网格",
|
||||||
|
"mode_preset": "阶段预设",
|
||||||
|
"serpentine": "蛇形遍历",
|
||||||
|
"centre_from_pick": "取选点为中心",
|
||||||
|
"centre_from_pick_tip": "把平面图上刚点选的点作为网格中心。",
|
||||||
|
"show_preview": "在平面图上预览",
|
||||||
|
"btn_add": "加入序列({count})",
|
||||||
|
"none": "没有候选点。",
|
||||||
|
"all_ok": "{total} 个候选点,全部落在曲面内。",
|
||||||
|
"some_bad": "{total} 个候选点:可用 {usable},曲面外 {off},太靠边 {edge}。只有可用的点能加入。",
|
||||||
|
"preset_hint": "阶段预设是建议的起始布点,坐标取自规划图;先用阶段 0 确认硬件,再逐级加密。每个点都会按当前曲面检查,落在曲面外或边距内的会标红叉,不会被加入。已在序列里的点不会重复添加。",
|
||||||
|
"preset_stage0": "阶段 0 · 硬件自检(1 点)",
|
||||||
|
"preset_stage1": "阶段 1 · 标定 3×3(9 点)",
|
||||||
|
"preset_stage1_check": "阶段 1 · 独立验证(4 点)",
|
||||||
|
"preset_stage2": "阶段 2 · 标定加点(8 点)",
|
||||||
|
"preset_stage2_check": "阶段 2 · 验证加点(4 点)",
|
||||||
|
"added": "已加入 {added} 个点,{repeated} 个已在序列里,{failed} 个求解失败。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,18 +16,28 @@
|
|||||||
"sdo_last": { "node": 0, "index": 24704, "subindex": 0, "type": 0 },
|
"sdo_last": { "node": 0, "index": 24704, "subindex": 0, "type": 0 },
|
||||||
"rtex_last": { "axis": 0, "parameter": 0 },
|
"rtex_last": { "axis": 0, "parameter": 0 },
|
||||||
"show_diagnostics": false,
|
"show_diagnostics": false,
|
||||||
|
"finger_video_path": "",
|
||||||
|
"finger_config_ref": "",
|
||||||
|
"finger_source_kind": "video",
|
||||||
|
"finger_camera_index": 0,
|
||||||
"safety_limits": {
|
"safety_limits": {
|
||||||
"max_travel_mm": 100.0,
|
"max_travel_mm": 100.0,
|
||||||
"max_descent_mm": 10.0,
|
"max_descent_mm": 10.0,
|
||||||
"max_press_depth_mm": 1.0,
|
"max_press_depth_mm": 1.0,
|
||||||
|
"max_yaw_deg": 180.0,
|
||||||
|
"max_pitch_deg": 90.0,
|
||||||
"follow_error_mm": 0.5,
|
"follow_error_mm": 0.5,
|
||||||
"contact_search_mm": 3.0
|
"contact_search_mm": 3.0
|
||||||
},
|
},
|
||||||
|
"press_axis": {
|
||||||
|
"zero_mm": 0.0,
|
||||||
|
"rising": true,
|
||||||
|
"calibrated": false
|
||||||
|
},
|
||||||
"sensor_setup": {
|
"sensor_setup": {
|
||||||
"sensor_type": "box",
|
"sensor_type": "box",
|
||||||
"config_ref": "",
|
"config_ref": "",
|
||||||
"geometry_id": "",
|
"geometry_id": "",
|
||||||
"press_axis": 2,
|
|
||||||
"probe_tip_offset_mm": [0.0, 0.0, 0.0],
|
"probe_tip_offset_mm": [0.0, 0.0, 0.0],
|
||||||
"probe_tip_set": false,
|
"probe_tip_set": false,
|
||||||
"sensor_to_platform": [],
|
"sensor_to_platform": [],
|
||||||
@@ -47,5 +57,10 @@
|
|||||||
"settle_window_s": 0.5,
|
"settle_window_s": 0.5,
|
||||||
"repeats": 3,
|
"repeats": 3,
|
||||||
"order": "serpentine"
|
"order": "serpentine"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"video": true,
|
||||||
|
"codec": "mp4v",
|
||||||
|
"lossless_frames": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Autocal 标定操作与数据格式草稿
|
||||||
|
|
||||||
|
状态:2026-10-11 修订,仅 finger,待实机验收。手动采集、记录总览及稳定段检查已接通;FT 同步及正式系数待实现。
|
||||||
|
|
||||||
|
## 范围与坐标
|
||||||
|
|
||||||
|
| 项目 | 约定 |
|
||||||
|
|---|---|
|
||||||
|
| 载荷 | 法向压缩 0–30 N;剪切及组合载荷后续再做 |
|
||||||
|
| 压头 | 直径 6 mm、球面半径 3 mm 的半球压头 |
|
||||||
|
| 参考 | 当前人工填写实际参考力;计划使用砝码或 50 N/2 N·m FT sensor |
|
||||||
|
| 图像 | 每 trial 保存一个连续视频,保留初始零载、换档、加载、卸载及恢复 |
|
||||||
|
| UV | 整数 pixel;原点为最靠近图像中线的有效列底部像素;U 向右、V 向上,允许负 V |
|
||||||
|
| 三维 | 传感器坐标用 mm;平台坐标经过安装和姿态转换,须明确区分 |
|
||||||
|
| 映射身份 | 保存原点、栅格尺寸和 `mapping_sha256`;哈希覆盖实际位置查找表及有效掩膜 |
|
||||||
|
| 层级 | `session → site → trial → step → samples`;同点重复及中止重试均创建新 trial |
|
||||||
|
|
||||||
|
[LF606 规格书](references/lf606-d40-h18-4x-spec.pdf)量程表包含 50 N/2 N·m 档位,但图签为 200 N/8 N·m;实机以铭牌及标定证书核对。设备准备、运动边界和停机沿用 [workflow.md](workflow.md);本页不自动移动平台。
|
||||||
|
|
||||||
|
## 操作员逐步流程
|
||||||
|
|
||||||
|
| 步骤 | 操作 | 软件行为 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1. 准备 | 填型号、SN、保存位置,载入 finger 导出包和配置 | 首次开始试验时建立本场 session;型号、SN、配置和映射在本场固定 |
|
||||||
|
| 2. 预览 | 选视频或相机编号,启动输入并检查二维 UV 图像 | 两种来源共用 finger worker;输入预览可贯穿多个 trial |
|
||||||
|
| 3. 选点 | 点击 UV 粗选,再修改 U/V 到精确像素 | 显示传感器三维位置;拒绝掩膜外点 |
|
||||||
|
| 4. 设循环 | 输入严格递增的正载荷档位及每档 10–30 s 保持 | 展示完整加载与卸载路径,顺序即记录顺序 |
|
||||||
|
| 5. 开始 | 无载时点击“开始本次试验” | 新建 trial 并连续录像;先提示初始零载 |
|
||||||
|
| 6. 保持 | 到目标后输入实际参考力,点击“已到目标力,开始保持” | 当前目标不变,倒计时期间禁止再次确认或完成试验 |
|
||||||
|
| 7. 换档 | 倒计时结束后按提示手动调整至下一档 | 自动结束本档窗口;换档继续录像,但不沿用上一档恒定参考力 |
|
||||||
|
| 8. 完成 | 最终卸载至零并完成保持,点击“完成本次试验” | 停止本 trial 录像,排空写入并检查完整性;输入预览继续 |
|
||||||
|
| 9. 重复/换点 | 重复同点或选下一点,再开始试验 | 同场目录内新增 trial;自动关联 site 和重复编号 |
|
||||||
|
| 10. 结束整场 | 所有试验结束后点击“结束本场标定” | 所有 trial 完整才标 complete;有中止或失败则保留 incomplete |
|
||||||
|
|
||||||
|
“提前结束(保留未完成数据)”立即停止本次试验;不删除数据,不续录旧 trial。视频播放结束或写入停止会中止当前试验。关闭程序时结束当前试验并关闭会话;异常退出留下的 `in_progress` 不代表已完成。目前不能恢复旧会话继续采集。
|
||||||
|
|
||||||
|
倒计时只结束档位窗口,完成或提前结束才停录像。到时不等于稳定;`stability_verified=0`,仍须复核。初始和最终零载保持时间相同,后续按恢复实测调整。
|
||||||
|
|
||||||
|
## 操作表导出
|
||||||
|
|
||||||
|
模拟页和采集页共用 UV 映射。在采集页队列点“导出测点操作表…”得到 UTF-8 CSV;操作员按 U/V 手动复现,不自动执行。缺 UV、缺已记录姿态,或三维位置与原像素不一致时拒绝导出,须重新选点。
|
||||||
|
|
||||||
|
| 操作表字段 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `order`、`site_id` | 表内顺序及测点编号;会话另行分配 site_id,以 U/V 与映射身份核对 |
|
||||||
|
| `u_px`、`v_px` | 与标定页相同原点、方向和单位 |
|
||||||
|
| `x_sensor_mm`/`y_sensor_mm`/`z_sensor_mm` | 传感器三维位置 |
|
||||||
|
| `x_platform_mm`/`y_platform_mm`/`z_platform_mm`、`yaw_deg`、`pitch_deg` | 该点已记录姿态给出的平台目标及角度 |
|
||||||
|
| `origin_raster_x_px`/`origin_raster_y_px`、`raster_width_px`/`raster_height_px`、`u_positive`/`v_positive`、`mapping_sha256` | 像素坐标复现所需的映射身份 |
|
||||||
|
|
||||||
|
列定义只有 `ui/site_export.py` 一处。序列页的 [`.oryseq`](sequence-format.md)仍为平台毫米坐标的几何/运动计划;它与操作表分别导出,不代表已接通载荷执行。
|
||||||
|
|
||||||
|
## 目录与文件
|
||||||
|
|
||||||
|
目录名为 `<产品型号>_<SN>_<UTC时间>_<唯一标识>`,身份保存在 manifest。一个 trial 对应同点一次循环;档位用编号和时间区间区分,不拆视频。
|
||||||
|
|
||||||
|
```text
|
||||||
|
finger_F001_<时间>_<标识>/
|
||||||
|
session.json
|
||||||
|
trials/
|
||||||
|
trial-001/
|
||||||
|
session.json
|
||||||
|
raw_0001.mp4
|
||||||
|
frames.csv
|
||||||
|
forces.csv
|
||||||
|
reference.csv
|
||||||
|
events.csv
|
||||||
|
trial-002/
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
10 点各做 3 次共 30 个 trial;中止和重试各占一次,档位数不增加目录数。
|
||||||
|
|
||||||
|
| 文件 | 内容及写入时机 |
|
||||||
|
|---|---|
|
||||||
|
| 根 `session.json` | `format_version=autocal-session-1.0`、session_id、型号/SN、配置快照及哈希、UV 定义和 uv_outline、sites、trials、状态、`recording` 块(`video`/`codec`/`lossless_frames`,建立时写一次,旧会话缺块按 `video=true` 读);开始、保持结束、trial 结束和关闭时更新 |
|
||||||
|
| trial `session.json` | 共享录像元数据;另有 `calibration_session_id`、`trial_id`、`site_id`、`repeat_index`、site、plan、来源、配置、`trial_status`、`trial_reason`、`capture_status`、`capture_reason`、`capture_report`、steps;采集 session_id 是独立的整数编号 |
|
||||||
|
| `raw_0001.mp4` | 保存每个已交付图像帧;容器帧率仅供播放,实际时序用 frames.csv;不等于来源视频 |
|
||||||
|
| `frames.csv` | 帧身份、电脑采集时间、视频位置及丢帧状态;每帧写入 |
|
||||||
|
| `forces.csv` | 图像算法原始及处理后输出;不是独立真实力 |
|
||||||
|
| `reference.csv` | 每个实际开始的保持窗口一行;完整窗口在计时终点关闭,中断窗口在停止时关闭 |
|
||||||
|
| `derived/review_<时间>_<标识>/review.json` | 另存初步分析,包含窗口均值、标准差、拟合和质量说明;不覆盖采集数据 |
|
||||||
|
| 同目录 `overview.json` | 总览版本、session_id、计划 CSV 内容及来源哈希、测点覆盖和拟合摘要;载入时按原始数据及复核窗口重算 |
|
||||||
|
| `derived/summary_*/`、`summary-latest.json` | 汇总四件文件与最后成功发布的指针;字段见[多点格式](multipoint-data-format.zh.md) |
|
||||||
|
| `derived/review_<时间>_<标识>/baseline_<trial>.png` | 已确认初始零载窗口的第一张有效录像帧;作为检查基准,非内部参考状态;无视频时在分析结果中标记 unavailable |
|
||||||
|
| `events.csv` | `trial_started`、`step_reached`、`hold_started`、`hold_ended`、`trial_finished`/`trial_aborted`;保存电脑事件时间及 step 信息 |
|
||||||
|
|
||||||
|
| 参数/字段 | 定义 |
|
||||||
|
|---|---|
|
||||||
|
| site | `site_id`、`uv_xy_px`、`position_sensor_mm`、`face_index`、栅格像素、几何路径及哈希 |
|
||||||
|
| trial 计划 | `levels_n`、`hold_s`、有序 steps;step_index 从 0 开始,branch 为 zero/load/unload |
|
||||||
|
| trial 状态 | 先写 incomplete,排空录像并通过完整性检查才写 complete;根清单同时保存结果和原因 |
|
||||||
|
| 执行 steps | 目标和实际参考力、保持起止单调时间、complete/incomplete、稳定验证标志 |
|
||||||
|
| `reference.csv` | `step_index`、`branch`、`target_load_n`、`t_start_host_mono_ns`、`t_end_host_mono_ns`、`F_ref_normal_N`、预留 XYZ、`force_frame`、`source`、`stability_verified`、`hold_complete` |
|
||||||
|
| 参考有效性 | 人工声明压缩为正;未测 XYZ 和坐标系留空,不补零,不直接把法向值当平台 Z;hold_complete=0 的窗口不能用于正式拟合 |
|
||||||
|
| `frames.csv` | `frame_id`、`source_frame_id`、`t_rel_s`、`t_host_mono_ns`、`t_host_utc_ns`、`acquired_at`、`time_basis`、`media_time_ms`、`segment`、`segment_frame`、`in_video`、`dropped`、`capture_dropped` |
|
||||||
|
|
||||||
|
参考窗口起点包含、终点不包含;换档无恒定力标签,视频与算法输出照常保存。最终状态以排空后的 manifest 为准;保存失败不算成功,重试不覆盖原文件。
|
||||||
|
|
||||||
|
录像完整性由 `capture_guard.capture_verdict()` 统一判定:写入帧数、录像帧数、单分段、队列丢帧、
|
||||||
|
视频可解码、未中止、采集中途来源会话未变,全部通过才写 complete,否则 incomplete 并保留原因码
|
||||||
|
(如 `zero_frames`、`video_undecodable`、`source_restarted`、`closing_timeout`);排空期间界面
|
||||||
|
轮询 `poll()` 而不阻塞。汇总见[多点格式的汇总一节](multipoint-data-format.zh.md):同一套
|
||||||
|
`summary_export` 写 `derived/summary_*/` 与 `summary-latest.json`,按 `hold_id`(`reference.csv`
|
||||||
|
数据行序号)区分重测,不自动标记稳定,不把 raw 与 N 混用。
|
||||||
|
|
||||||
|
见[录像工作流](recording-workflow.zh.md)。
|
||||||
|
|
||||||
|
### 多点会话格式
|
||||||
|
|
||||||
|
多点标定按测点组织(`sites/<site_id>/repeat-00N/`),复核输入见[多点标定会话格式](multipoint-data-format.zh.md)。
|
||||||
|
|
||||||
|
## 软件数据流及后续
|
||||||
|
|
||||||
|
| 输入 → 处理 → 输出 | 所有者 |
|
||||||
|
|---|---|
|
||||||
|
| UV 像素 → 固定映射 → 传感器毫米坐标/模拟平台姿态 → 操作表或 site | Autocal sensor 几何与模拟界面 |
|
||||||
|
| 视频/相机 → 帧采集时间 → finger 处理 → `SdkPacket` | SDK capture、shared acquisition |
|
||||||
|
| 手工参考 + 保持状态 → 有效候选窗口及事件 | Autocal trial |
|
||||||
|
| session/site/trial 关联 → 根清单;数据包 → 视频及 CSV | Autocal 会话;shared recorder 负责文件写入 |
|
||||||
|
| 完整 trial + 帧身份/电脑时间 → 人工复核窗口 → 加载/卸载、重复性、trial 留出验证及 UV 着色 | Autocal 初步分析;实机验收后置 |
|
||||||
|
|
||||||
|
主时间是电脑取得该帧时绑定的单调时间,UTC 仅供追溯,处理完成时间不能代替采集时间。FT/ECAT 后续按同一接收时间关联,不按行号配对。`host_replay` 只是本次解码时间,不能恢复原实验同步。
|
||||||
|
|
||||||
|
“标定记录”页打开会话,核对型号/SN、来源和采集状态;选点及单次试验先看时间曲线,再“检查保持段…”,最后保存分析结果。采集页“查看标定数据…”也跳转至此。
|
||||||
|
|
||||||
|
| 总览 | 规则 |
|
||||||
|
|---|---|
|
||||||
|
| 计划对照 | 导入模拟页 CSV;核对映射、原点、轴向、尺寸及三维位置,按 U/V 匹配;标记漏采和计划外点,同点重复序号合并 |
|
||||||
|
| 状态 | 采集完整性与稳定段检查分别展示;未完成 trial 可查看曲线、结束原因及缺失文件,不参与拟合 |
|
||||||
|
| UV 轮廓 | 新采集的 uv_outline 保存映射身份及传感面/彩色 patch 外轮廓;旧记录仅恢复映射验证一致的几何轮廓,缺失或不匹配时提示;显示保持像素等比例 |
|
||||||
|
| 时间曲线 | FN_raw 及人工保持参考力;显示有效样本、无效/未关联数、录像丢帧、最大帧间隔;各 trial 独立时间原点,不代表 FT 同步 |
|
||||||
|
| 法向响应 | 横轴实际参考 N,纵轴 FN_raw 均值;区分加载/卸载及候选/已确认,可筛选 trial;拟合仍基于本点全部合格加载窗口 |
|
||||||
|
| 灵敏度图 | 仅已确认拟合按 raw/N 着色,其余灰色;不插值,表中另列拟合范围及 trial 数 |
|
||||||
|
| 保存恢复 | 保存计划及窗口快照;载入 review.json 恢复总览,无 overview.json 时仅恢复窗口;未保存修改有提示 |
|
||||||
|
|
||||||
|
拟合使用初始零载及加载窗口均值,各窗口等权;卸载仅作对照。缺样本、档位或信号变化时不生成系数。拟合 RMSE 不是独立验证误差。
|
||||||
|
|
||||||
|
采集页显示 FN/FX/FY raw 及最近 60 秒曲线,已标定轴另显示 N;无效帧断开,停止或超过 1 秒无新帧时提示。人工参考力不是实时 FT。
|
||||||
|
|
||||||
|
实时 UV 图可切换真实背景、位移箭头与深度;深度按当前帧有效范围自适应,单位不是毫米;跨帧同色不代表同值,缺失或过期字段不绘制。
|
||||||
|
|
||||||
|
检查保持段曲线、样本数、标准差和漂移;起止以保持开始为零,只能在原段内调整。勾选“已检查稳定”后重算并保存。至少两个不同载荷的已确认零载/加载窗口才有确认拟合;确认稳定不代表精度合格。
|
||||||
|
|
||||||
|
分析保存 selections、拟合、样本轨迹、输入 SHA256 与版本;确认的初始零载段另存基准图像、帧身份与哈希。输入变化时拒绝载入与保存,结果不改 reference.csv。
|
||||||
|
|
||||||
|
“重复性与验证…”使用已确认窗口,site.quality 保存跨 trial 同参考力的极差与标准差、卸载减加载差及最终减初始零载;参考力须完全相同,不插值,不足两次不算重复性。
|
||||||
|
|
||||||
|
验证逐次留出完整 trial,用其余 trial 拟合预测留出窗口,报告 RMSE/MAE/最大误差及超出训练范围数量,训练不足时说明原因。交叉验证不能代替独立实验或正式系数。
|
||||||
|
|
||||||
|
跟踪器基准与算法历史尚未完整快照,录像不能保证从全新进程精确复算。相机、FT、实际平台反馈和容量/延迟须实机联调;自动加载、剪切、空间外推及实时部署留待后续。见[实施计划](implementation-plan.zh.md)。
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# Autocal 标定采集页重构实施计划
|
||||||
|
|
||||||
|
状态:2026-10-18 计划,P0–P6 已实施并通过独立验收复核。**仅软件验证**:V10 实机验收未执行,需用户指定设备、运动与载荷范围及停机条件;各项检查的实际口径见文末[实施结果](#实施结果2026-10-18)。目标是把“选点 → 对准 → 逐档加载 → 记录”收成一条流程:一个测点队列、一个不依赖 Qt 的状态控制器、一个随步骤变化的主按钮;同时加入录像选项、打开文件夹和平铺汇总。执行分工见[任务分工](capture-workflow-tasks.zh.md)。本计划不改变运动安全规则、不自动加载、不写回 SDK 系数。
|
||||||
|
|
||||||
|
## 基线与问题
|
||||||
|
|
||||||
|
调查基线:app `main`、HEAD `7607653`(实施时实际 HEAD 为 `7b5ffa7`,以 `.scratch/autocal-capture-workflow/baseline.md` 为准);工作树已有 `motion/service.py`、`record_diagnostics.py` 改动,实施时不得覆盖。结论来自源码、界面截图和 `D:/orisys_ws/data/dd02-gl`,未运行程序或硬件。
|
||||||
|
|
||||||
|
| 位置(相对 `apps/autocal/src/orisys_autocal/`) | 问题 |
|
||||||
|
|---|---|
|
||||||
|
| `ui/calibration_panel.py: _confirm/_sync` | “到达目标”按钮与参考力输入框按隐藏的 `campaign.phase` 在单点与多点之间切换 |
|
||||||
|
| `ui/multipoint_panel.py: _levels/_hold` | 多点读取右栏载荷档位,又自带 Min/Max force,存在两套载荷设置 |
|
||||||
|
| `ui/calibration_panel.py` 子页 | UV 图与实时图互斥;输入源配置藏在实时图子页;开始试验会自动切页 |
|
||||||
|
| `ui/multipoint_panel.py: bind_finger` | 求解器借用 Finger debug 的 `stack`;该页另有测点表、导出和执行,测点有两份 |
|
||||||
|
| 两个面板共 10 条状态文字 | 报错落点取决于代码路径,禁用按钮没有原因 |
|
||||||
|
| `trial.py:157`、`multipoint.py:1476` | `RecordConfig` 默认 `mp4v`,实测约 27 Mbit/s;同画面 H.264 样例约 5 Mbit/s |
|
||||||
|
| `capture_guard.capture_verdict`、`summary.py:264` | 无视频必判 `video_missing`,多点汇总还要求帧在视频内,因此无法安全提供“不录视频” |
|
||||||
|
| `summary_export.publish_summary` | 汇总藏在 `derived/summary_<UTC>_<id>/`;用户手工拼出 dd02-gl,已出现 site_id 重名 |
|
||||||
|
|
||||||
|
## 需先确认的决定
|
||||||
|
|
||||||
|
| 编号 | 决定 | 建议默认 | 阻塞阶段 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D1 | 新采集的单点是否改为“一个点的多点会话”(`sites/` 格式) | 是;旧 `trials/` 会话继续可读,`ManualTrial` 暂留不删 | P3 |
|
||||||
|
| D2 | 默认视频编码及等效门槛 | 保持 `mp4v` 直到 P2b 证明 H.264 重放差异可接受;门槛由用户给出 | P2 默认值 |
|
||||||
|
| D3 | Test sequence 页与测点队列的关系 | 本计划不改该页 | 无 |
|
||||||
|
| D4 | 只录保持窗口前后 | 不做:违背“每 trial 连续录像”契约 | 无 |
|
||||||
|
|
||||||
|
未确认的决定由主代理暂停相应阶段,不得自行选择。
|
||||||
|
|
||||||
|
## 阶段
|
||||||
|
|
||||||
|
每阶段的入口条件不满足就不开始;出口条件全部有证据才算完成。
|
||||||
|
|
||||||
|
### P0 基线(主代理)
|
||||||
|
|
||||||
|
- 入口:本计划及 D1、D2 已确认。
|
||||||
|
- 内容:记录 app 与工作区根的分支、HEAD、index、工作树;建立 `.scratch/autocal-capture-workflow/` 和 `.active`;跑基线全量测试与 `panel_check.py`,记录已知失败(finger 页 `QDoubleSpinBox` 高度 2 项)。
|
||||||
|
- 出口:基线命令与结果写入 scratch;之后任何失败须与基线对比后才能归因。
|
||||||
|
|
||||||
|
### P1 打开文件夹与平铺汇总
|
||||||
|
|
||||||
|
- 入口:P0。
|
||||||
|
- 内容:
|
||||||
|
1. 将 `ui/window.py: _reveal` 提取为 `ui/reveal.py: reveal(path, select=None)`,Windows 用 `explorer /select,<文件>`;菜单与采集页共用。
|
||||||
|
2. 采集页会话行加“打开会话文件夹”“打开汇总”“复制路径”;无目录时禁用并写明原因。
|
||||||
|
3. `publish_summary` 在原子替换 `summary-latest.json` 之后,把同一份四件文件写到会话根 `summary/`:先写临时目录再替换。副本失败只报告,不回滚指针、不改采集结果。
|
||||||
|
- 出口:`summary/` 文件哈希与指针所指版本一致;副本失败时旧 `summary/` 和指针保持不变;`reveal` 参数由测试模拟 `subprocess` 断言;两份格式文档更新目录说明。
|
||||||
|
|
||||||
|
### P2 录像选项
|
||||||
|
|
||||||
|
- 入口:P0;D2 已确认。
|
||||||
|
- 内容:
|
||||||
|
1. 新增 `recording_options.py`:`video: bool`、`codec`、`lossless_frames: bool`;默认值进 `preferences.py` 和 `config/preferences.default.json`。
|
||||||
|
2. 用一个工厂 `make_record_config(options, fps)` 替换 `trial.py` 与 `multipoint.py` 两处 `RecordConfig(...)`;不改 shared 默认值(Studio 也在用)。
|
||||||
|
3. 选项写入根 `session.json` 的 `recording` 块,会话建立后锁定。
|
||||||
|
4. `capture_verdict` 增加 `video_expected`;为 False 时跳过视频相关判定。`summary.py` 和 `session_review.py` 从 `recording` 块读取;缺该块的旧会话按 `video_expected=True` 处理。
|
||||||
|
5. 启动时用 cv2 向 scratch 写 3 帧探测编码器;不可用的编码在界面上禁用并说明,绝不静默回退。
|
||||||
|
6. 界面显示按码率与计划时长估算的体积和磁盘剩余空间(`shutil.disk_usage`),不足两倍估算时警告。
|
||||||
|
- 出口:关视频时 trial 为 complete、汇总行 eligible、复核显示“按设置未录视频”;旧会话结果不变;编码不可用在开始前拒绝。
|
||||||
|
|
||||||
|
### P2b 编码等效验证(只出报告,不改代码)
|
||||||
|
|
||||||
|
- 入口:P0。
|
||||||
|
- 内容:同一视频文件作为输入,分别用 `mp4v` 与 H.264 录制,各自经 SDK 重放,按 `frame_id` 比较 `FN_raw`/`FX_raw`/`FY_raw` 的逐帧差和各保持窗口均值差。脚本与结果只放 scratch。
|
||||||
|
- 出口:报告给出差值相对 1 N 档阶跃响应的比例,交用户按 D2 决定是否切换默认编码。
|
||||||
|
|
||||||
|
### P3 采集状态控制器
|
||||||
|
|
||||||
|
- 入口:P0;D1 已确认。
|
||||||
|
- 内容:新增 `capture_controller.py`,不导入 Qt 或运动服务,内部包一个 `Campaign`。
|
||||||
|
1. 队列即 `campaign.sites`;单点就是只有一行的队列。
|
||||||
|
2. `readiness()` 返回检查项:标定包、输入出帧、总线已初始化且轴映射无问题、输出目录可写、SN、最大力。
|
||||||
|
3. `primary_action()` 返回 `(key, label_key, enabled, blocked_reason)`;`perform(key, **inputs)` 执行;`secondary_actions()` 返回重测、跳过、停止;`poll()` 推进保持与收尾。
|
||||||
|
4. 对准只发出请求,仍走 `window._on_align_multipoint → plan_pose_move`。
|
||||||
|
5. 求解器由构造参数注入 `PlatformStack`,不再取 Finger 面板。
|
||||||
|
- 出口:表驱动测试覆盖完整加载阶梯、确认离开、换点、重测、复采、任意状态停止;每个禁用的主动作都有非空原因;录制中拒绝对准;直接驱动 `Campaign` 与经由控制器写出的 `session.json`、`reference.csv` 结构一致;`test_binding_purity.py` 通过。
|
||||||
|
|
||||||
|
### P4 新采集页界面
|
||||||
|
|
||||||
|
- 入口:P1、P3 完成(P2 可并行合入)。
|
||||||
|
- 内容:按设计稿重排 `CalibrationPanel`:
|
||||||
|
1. 会话行和 Ready 标签行;输入源与标定包放入“更改输入/标定包…”对话框。
|
||||||
|
2. 左侧 UV 图与实时图并排,下方是基于控制器模型的队列表。
|
||||||
|
3. 右侧当前点卡片(步骤标签、提示、参考力或六维力输入、主按钮、次要按钮)和力曲线;底部一行消息。
|
||||||
|
4. 控件只渲染控制器给出的内容,不读 `campaign` 内部字段;中英文 locale 同步。
|
||||||
|
- 出口:`panel_check.py` 在 1440×900、1920×1080 下无新增失败;在模拟模式下用界面测试走完两个点;用 grep 证明 `ui/` 中没有 `campaign.phase` 判断;离屏截图存 scratch 供人工查看。
|
||||||
|
|
||||||
|
### P5 合并 Finger debug
|
||||||
|
|
||||||
|
- 入口:P4。
|
||||||
|
- 内容:Finger debug 改名为“几何与安装”,保留安装偏置、工具 XY、行程限制、3D 预览和单点试选;删除其测点表,“导出测点操作表”迁到队列的导入/导出;`stackChanged` 触发控制器重新求解,不可达行标出;删除未使用的 `multipoint.import_points` 文案或实现它。
|
||||||
|
- 出口:修改安装偏置后队列位姿更新、不可达行禁止对准;导出的操作表字段与[数据格式](calibration-data-format.zh.md)一致;`test_finger_panel.py` 更新后通过。
|
||||||
|
|
||||||
|
### P6 收尾与可选的跨会话导出
|
||||||
|
|
||||||
|
- 入口:P5。
|
||||||
|
- 内容:更新[数据格式](calibration-data-format.zh.md)、[多点格式](multipoint-data-format.zh.md)、[汇总契约](recording-summary-contract.zh.md)和 README 的操作流程。可选:Records 页“导出批次…”,把多个会话合并成一个 dd02-gl 式文件夹,每行带 `session_id`、`trial_id`,不按 site_id 合并。
|
||||||
|
- 出口:文档门禁通过;如做批次导出,用两会话同名 site_id 夹具证明不混行。
|
||||||
|
|
||||||
|
## 统一验证
|
||||||
|
|
||||||
|
所有命令在 `D:\orisys_ws\orisys-app` 下执行,`$pythonExe` 指 `.venv-build\Scripts\python.exe`,basetemp 每次新建。
|
||||||
|
|
||||||
|
| 编号 | 检查 | 何时 | 通过条件 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| V1 | 各阶段新增测试 | 每个任务交付 | 先在旧行为上失败、修复后通过(可行时) |
|
||||||
|
| V2 | `pytest apps/autocal/tests` | 每阶段出口 | 0 失败;跳过项须解释 |
|
||||||
|
| V3 | `tools/lint_gate.py` | 每阶段出口 | `status: clean` 或仅既有抑制 |
|
||||||
|
| V4 | `apps/autocal/tools/panel_check.py` | P1、P4、P5 | 与 P0 基线相比无新增失败 |
|
||||||
|
| V5 | `test_binding_purity.py`、`test_locales_autocal.py` | P3、P4 | 通过 |
|
||||||
|
| V6 | shared 测试 | 仅当改到 `shared/` | 记录器、生命周期、回放测试通过 |
|
||||||
|
| V7 | `D:\orisys_ws\scripts\doc_lint.py` | 改文档时 | 无新错误 |
|
||||||
|
| V8 | 离屏截图人工检查 | P4、P5 | 用户或验收代理确认 |
|
||||||
|
| V9 | P2b 编码报告 | 切换默认编码前 | 用户按 D2 接受 |
|
||||||
|
| V10 | 实机验收 | 全部完成后 | 需指定设备、运动与载荷范围、停机条件;代理不执行 |
|
||||||
|
|
||||||
|
V1–V9 是软件证据,不代表实机可用;V10 未执行前状态只能写“仅软件验证”。
|
||||||
|
|
||||||
|
## 实施结果(2026-10-18)
|
||||||
|
|
||||||
|
证据都在 `D:\orisys_ws\orisys-app\.scratch\autocal-capture-workflow\`:
|
||||||
|
|
||||||
|
| 阶段 | 结果 | 证据 |
|
||||||
|
|---|---|---|
|
||||||
|
| P0 | 基线 572 项测试通过;`panel_check` 2/260 既有失败 | `baseline.md` |
|
||||||
|
| P1、P2 | 打开文件夹、会话根 `summary/` 副本、录像选项与 `recording` 块 | `a/handoff-patches.zh.md`、`a/logs/` |
|
||||||
|
| P2b | 逐帧口径三轴均超 5% 门槛;保持窗口口径 FN、FX 基本在门槛内、FY 不在;本机 `cv2` 写不了 H.264 | `d/p2b-codec-equivalence.zh.md` |
|
||||||
|
| P3 | 控制器:队列、六项 Ready、单一主动作带原因、重测/跳过/停止 | `b/notes.md` |
|
||||||
|
| P4 | 新采集页只经控制器渲染,`ui/` 中 `campaign.phase` 零命中 | `c/verification.md`、`c/screens/` |
|
||||||
|
| P5 | 「几何与安装」页去掉测点表,操作表迁到队列;`panel_check` 由 2/260 失败变为 260 全过 | `c/verification-p5.md` |
|
||||||
|
| P6 | 三份格式/契约文档、`workflow.md`、README | 本目录各文档 |
|
||||||
|
| 验收 | 无阻断;2 项重要 + 1 项次要,修复后复核全部 PASS | `v/acceptance-report.zh.md` |
|
||||||
|
|
||||||
|
当前 `pytest apps/autocal/tests` 668 项全过,`panel_check.py` 260 项全过,被改文档的 `doc_lint` 为 clean。
|
||||||
|
|
||||||
|
各检查的实际口径:V2、V4、V5、V7 通过;V3 只在本任务范围内 clean,全仓门禁另有并行外部会话引入的 findings;
|
||||||
|
V6 不适用(未改 `shared/`);V8 截图已备、待人工确认;V9 报告已交,是否切换默认编码待用户按 D2 决定。
|
||||||
|
|
||||||
|
## 验收后修正(2026-10-18,审查 F1、F3、F5)
|
||||||
|
|
||||||
|
- 会话结束(完成或停止)后,主按钮“结束会话”把页面带回空闲:队列可编辑、可开始新会话,已记录的文件不变。
|
||||||
|
- 保持时间到点由控制器 `poll()` 自动结束,参考窗口为计划时长(`t_end - t_start = 保持秒数`),与点击或计时器迟到多久无关。
|
||||||
|
- 勾选“模拟”后不需要平台、总线、相机、标定包,也不需要先对准;没有标定包时,U、V 直接作为点的 X、Y,位姿标为 `simulated`。有平台时仍须先对准。
|
||||||
|
- 仅软件验证:控制器与 Campaign 的新增测试已运行;界面测试(含新增的离线模拟用例)在本机因缺 libEGL 未能运行,须在 Windows 上补跑。
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# 标定采集页重构:DeepSeek 代理执行清单
|
||||||
|
|
||||||
|
状态:2026-10-18 计划,W0–W4 已完成(P0–P6 全部实施)。**仅软件验证**:独立验收无阻断,2 项重要 + 1 项次要已修复并复核通过;V10 实机验收未执行。阶段定义、入口与出口条件、统一验证见[主计划](capture-workflow-plan.zh.md);本页只规定由谁执行、按什么顺序执行、交付什么。复选框记录实际完成情况。
|
||||||
|
|
||||||
|
## 主代理指令
|
||||||
|
|
||||||
|
开始前先读工作区 `AGENTS.md`、`docs/AUTHORING.md`、`docs/platform/engineering-workflow.md` 和主计划,再重新检查 app 与工作区根的分支、HEAD、index 和工作树。已有的 `motion/service.py`、`record_diagnostics.py` 改动以及根仓库 `scripts/finger_multical/` 的改动都不属于本任务,不得覆盖。
|
||||||
|
|
||||||
|
不得连接真实控制器、移动平台或加载;不得提交、推送、暂存或改写历史。D1、D2 未经用户确认时,停在相应阶段并报告。
|
||||||
|
|
||||||
|
scratch 统一放在 `.scratch/autocal-capture-workflow/`;运行期间保留 `.active` 标记,每次 pytest 用新的 `--basetemp` 并加 `-p no:cacheprovider`。
|
||||||
|
|
||||||
|
主代理负责以下文件的最终合并:
|
||||||
|
- 接口:`capture_controller.py` 的公共 API、`recording_options.py` 的结构;
|
||||||
|
- 共享文件:`ui/window.py`、`preferences.py`、`config/preferences.default.json`、两份 locale 文件、`docs/`。
|
||||||
|
|
||||||
|
子代理需要改这些文件时,先把改动交给主代理,由主代理串行合入。
|
||||||
|
|
||||||
|
## 角色
|
||||||
|
|
||||||
|
| 角色 | 负责阶段 | 可改文件 | 不可改 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 主代理 | P0、接口冻结、合并、P6 文档、交接 | 上面列出的共享文件 | — |
|
||||||
|
| A 数据与文件 | P1、P2 | `ui/reveal.py`、`summary_export.py`、`recording_options.py`、`capture_guard.py`、`summary.py`、`session_review.py`,`trial.py` 与 `multipoint.py` 中构造 `RecordConfig` 的两行,以及相应测试 | 状态机、界面布局、`shared/` |
|
||||||
|
| B 控制器 | P3 | `capture_controller.py`、`tests/test_capture_controller.py`;`multipoint.py` 只允许为控制器暴露只读属性 | 任何 `ui/` 文件 |
|
||||||
|
| C 界面 | P4,之后 P5 | `ui/calibration_panel.py`、`ui/multipoint_panel.py`、`ui/sensor_panel.py`、`ui/finger_source_panel.py`、新增的 `ui/*` 组件,以及相应 UI 测试 | 控制器逻辑、数据格式 |
|
||||||
|
| D 编码验证 | P2b | 只在 scratch 中写脚本和报告 | 产品代码 |
|
||||||
|
| V 验收 | 最终验收 | 只读;可在 scratch 中写报告 | 任何产品文件 |
|
||||||
|
|
||||||
|
同一时间最多 3 个实现子代理在工作。控制器 API 冻结前,C 只能对着主代理给出的桩接口开发。
|
||||||
|
|
||||||
|
## 执行顺序
|
||||||
|
|
||||||
|
```text
|
||||||
|
W0 主代理 P0 + 冻结 CaptureController / RecordingOptions 接口
|
||||||
|
W1 A: P1 → P2 B: P3 D: P2b (三者并行)
|
||||||
|
W2 C: P4(需要 P1、P3;P2 合入后补 Ready 行的录像标签)
|
||||||
|
W3 C: P5 主代理: P6 文档;可选的批次导出交给 A
|
||||||
|
W4 V: 独立验收 → 主代理修复 → V 复核修复项
|
||||||
|
```
|
||||||
|
|
||||||
|
检查点 1 在 W1 结束时:P1、P2、P3 的 V1–V3 全部通过,才允许开始 W2。检查点 2 在 W3 结束时:V1–V8 全部通过,才交给 V。
|
||||||
|
|
||||||
|
## 任务清单
|
||||||
|
|
||||||
|
### W0 主代理
|
||||||
|
|
||||||
|
- [x] P0 基线:记录 HEAD、工作树状态和基线测试结果到 `scratch/baseline.md`。
|
||||||
|
- [x] 冻结接口,写入 `scratch/interfaces.md`:`readiness()`、`primary_action()`、`perform()`、`secondary_actions()`、`poll()` 的签名、返回结构和动作键清单;`RecordingOptions` 的字段与 `session.json` 中 `recording` 块的结构。(实施中按缺口三次修订:1.4.1 对准阶梯、1.8/1.10 队列字段与编辑、1.11 补口。)
|
||||||
|
|
||||||
|
### A 数据与文件
|
||||||
|
|
||||||
|
交接提示:你负责让用户能找到数据、能控制视频体积。不得改变原始目录结构、不得删除或移动已有录像;summary 副本只是方便查看,指针仍是唯一权威。
|
||||||
|
|
||||||
|
- [x] P1:`reveal` 提取与三个按钮(按钮的布局由 C 在 P4 完成;A 在 P1 先接到现有会话行),`summary/` 平铺副本及失败处理。测试:`test_summary_export.py` 增加副本一致性、副本失败两项,新增 `test_reveal.py`。
|
||||||
|
- [x] P2:录像选项、工厂函数、`recording` 块、`video_expected` 判定、编码探测、体积估算。测试:`test_multipoint_recording.py` 增加关视频完成、编码不可用两项;`test_summary.py` 增加关视频行 eligible、旧会话不变两项;`test_preferences_isolation.py` 覆盖新的偏好键。(体积估算与探测界面由 C 在 P4 实现。)
|
||||||
|
|
||||||
|
### B 控制器
|
||||||
|
|
||||||
|
交接提示:你负责把流程判断从界面中移出。控制器不得发出运动命令、不得导入 Qt;对准只发出请求。每个禁用的动作必须带原因,原因文字通过 locale 键给出。
|
||||||
|
|
||||||
|
- [x] P3:实现控制器及表驱动测试;用同一组输入比较“直接驱动 `Campaign`”与“经由控制器”写出的文件结构。
|
||||||
|
|
||||||
|
### C 界面
|
||||||
|
|
||||||
|
交接提示:你负责重排界面,依据是设计稿与主计划 P4、P5。界面只渲染控制器的输出;不得在 `ui/` 中判断 `campaign.phase`。实时图与 UV 图必须能同时看到。
|
||||||
|
|
||||||
|
- [x] P4:新布局、更改输入对话框、队列表模型、当前点卡片、消息行;在模拟模式下用界面测试走完两点;截图存 scratch。
|
||||||
|
- [x] P5:Finger debug 改名并瘦身,测点表迁移,安装偏置变化后重新求解。
|
||||||
|
|
||||||
|
### D 编码验证
|
||||||
|
|
||||||
|
- [x] P2b:按主计划完成对比并写报告,内容包括输入文件、编码参数、逐帧差值统计和窗口均值差占 1 N 阶跃的比例。不给结论性推荐,由用户决定。
|
||||||
|
|
||||||
|
### V 验收
|
||||||
|
|
||||||
|
- [x] 用新进程跑完主计划 V1–V8;按严重度列出发现,每条附复现步骤;不修改代码。
|
||||||
|
|
||||||
|
## 子代理交付格式
|
||||||
|
|
||||||
|
每个任务结束时交回以下内容:
|
||||||
|
1. 改动文件清单;
|
||||||
|
2. 新增测试,以及它们在修复前失败的证据(可行时);
|
||||||
|
3. 实际执行的命令和结果(复制输出摘要,不改写);
|
||||||
|
4. 未验证项及原因;
|
||||||
|
5. 接口变化;没有就写“无”。
|
||||||
|
|
||||||
|
不得把跳过、未运行或环境缺失写成通过。
|
||||||
|
|
||||||
|
## 主代理最终交接
|
||||||
|
|
||||||
|
- [x] 所有改动保持未暂存;每个改动过的仓库各写 `.scratch/autocal-capture-workflow/commit-message.txt`,按 AGENTS 第 7 条给出单行的 `git add` 和 `git commit -F` 命令。(本任务只改 `orisys-app`。)
|
||||||
|
- [x] 列出需要排除的既有改动。
|
||||||
|
- [x] 状态写为“仅软件验证,待实机验收”;V10 实机验收需要用户指定设备、运动与载荷范围以及停机条件。
|
||||||
|
|
||||||
|
## 需要排除的既有改动(不属于本任务)
|
||||||
|
|
||||||
|
`orisys-app` 工作树里这些改动在任务开始前就存在,提交时必须排除:`apps/autocal/src/orisys_autocal/motion/service.py`、
|
||||||
|
`apps/autocal/src/orisys_autocal/record_diagnostics.py`;未跟踪的 `LZ.*.zip`、`LZ.LigMaster V1.7.0/`、
|
||||||
|
`LZ.Devpack_L826(六维力非标设备包)(1)/`、`docs/test-execution-plan.zh.md`。
|
||||||
|
|
||||||
|
任务运行期间还有并行会话改了工作区其他部分,同样不属于本任务:`apps/studio/**` 与
|
||||||
|
`shared/src/orisys_app_shared/theme/qss.py`(另一个 Studio 评审会话)、工作区根的 `docs/README.md` 与
|
||||||
|
`docs/inventory.json`、`orisys-sdk` 的 `HEAD`(`f7f4682` → `ff7614a`,只改 `calibration.py`)。因此全仓
|
||||||
|
`lint_gate` 的 `status: FINDINGS` 来自这些外部改动,本任务范围内的文件是 `status: clean`。
|
||||||
@@ -0,0 +1,611 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 2,
|
||||||
|
"diagram_type": "workflow",
|
||||||
|
"meta": {
|
||||||
|
"title": "Autocal 当前录像与复核流程",
|
||||||
|
"locale": "zh-CN",
|
||||||
|
"quality_profile": "showcase",
|
||||||
|
"output": "apps/autocal/docs/diagrams/recording-20261017-110144/recording-workflow.html",
|
||||||
|
"repository": {
|
||||||
|
"url": "ssh://git@892887cvmk08.aicp.io:24928/orisys-tech/orisys-app.git",
|
||||||
|
"revision": "19426c3c4a648ca7f4d3785e2e11b1bf124a8e31",
|
||||||
|
"link_mode": "local-only"
|
||||||
|
},
|
||||||
|
"legend": {
|
||||||
|
"entries": {
|
||||||
|
"frontend": {
|
||||||
|
"label": "操作界面"
|
||||||
|
},
|
||||||
|
"backend": {
|
||||||
|
"label": "软件处理"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"label": "数据与文件"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"label": "身份与完整性"
|
||||||
|
},
|
||||||
|
"messagebus": {
|
||||||
|
"label": "帧队列"
|
||||||
|
},
|
||||||
|
"external": {
|
||||||
|
"label": "图像来源"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lanes": [
|
||||||
|
{
|
||||||
|
"id": "operator",
|
||||||
|
"label": "操作与试验状态"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "record",
|
||||||
|
"label": "逐帧采集与后台写入"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "exceptions",
|
||||||
|
"label": "异常证据与完整性",
|
||||||
|
"variant": "exception"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "failed",
|
||||||
|
"label": "已关闭的失败记录",
|
||||||
|
"variant": "exception"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "review",
|
||||||
|
"label": "离线复核与操作员汇总"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"mainPath": [
|
||||||
|
"start",
|
||||||
|
"camera",
|
||||||
|
"guard",
|
||||||
|
"queue",
|
||||||
|
"writer",
|
||||||
|
"closing",
|
||||||
|
"verdict"
|
||||||
|
],
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "start",
|
||||||
|
"lane": "operator",
|
||||||
|
"col": 0,
|
||||||
|
"type": "frontend",
|
||||||
|
"label": "开始本次试验",
|
||||||
|
"sublabel": "固定测点与重复身份",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/multipoint.py",
|
||||||
|
"line": 1025,
|
||||||
|
"end_line": 1106,
|
||||||
|
"label": "HEAD:登记 repeat"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"width": 156
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "holds",
|
||||||
|
"lane": "operator",
|
||||||
|
"col": 4,
|
||||||
|
"type": "frontend",
|
||||||
|
"label": "人工确认载荷",
|
||||||
|
"sublabel": "保持窗口与事件时间",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/multipoint.py",
|
||||||
|
"line": 707,
|
||||||
|
"end_line": 734,
|
||||||
|
"label": "HEAD:保持事件"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"width": 156
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "closing",
|
||||||
|
"lane": "operator",
|
||||||
|
"col": 5,
|
||||||
|
"type": "backend",
|
||||||
|
"label": "异步停止与排空",
|
||||||
|
"sublabel": "移除 sink 后轮询收尾",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/multipoint.py",
|
||||||
|
"line": 1146,
|
||||||
|
"end_line": 1187,
|
||||||
|
"label": "HEAD:异步收尾"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"width": 156
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "verdict",
|
||||||
|
"lane": "exceptions",
|
||||||
|
"col": 5,
|
||||||
|
"type": "security",
|
||||||
|
"label": "判定证据完整性",
|
||||||
|
"sublabel": "report 与视频探测",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/capture_guard.py",
|
||||||
|
"line": 149,
|
||||||
|
"end_line": 225
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"width": 156
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "camera",
|
||||||
|
"lane": "record",
|
||||||
|
"col": 0,
|
||||||
|
"type": "external",
|
||||||
|
"label": "相机输入画面",
|
||||||
|
"sublabel": "帧号与主机采集时间",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"path": "shared/src/orisys_app_shared/acquisition/finger_worker.py",
|
||||||
|
"line": 945,
|
||||||
|
"end_line": 1010
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"width": 156
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "guard",
|
||||||
|
"lane": "record",
|
||||||
|
"col": 1,
|
||||||
|
"type": "security",
|
||||||
|
"label": "检查来源状态",
|
||||||
|
"sublabel": "检测断流与来源重启",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/capture_guard.py",
|
||||||
|
"line": 49,
|
||||||
|
"end_line": 78
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/multipoint.py",
|
||||||
|
"line": 1288,
|
||||||
|
"end_line": 1300,
|
||||||
|
"label": "HEAD:来源健康检查"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"width": 156
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "queue",
|
||||||
|
"lane": "record",
|
||||||
|
"col": 2,
|
||||||
|
"type": "messagebus",
|
||||||
|
"label": "有界录像队列",
|
||||||
|
"sublabel": "采集线程不等待编码",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"path": "shared/src/orisys_app_shared/record/experiment.py",
|
||||||
|
"line": 549,
|
||||||
|
"end_line": 641
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"width": 156
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "writer",
|
||||||
|
"lane": "record",
|
||||||
|
"col": 3,
|
||||||
|
"type": "backend",
|
||||||
|
"label": "后台写入视频",
|
||||||
|
"sublabel": "MP4 与逐帧 CSV",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"path": "shared/src/orisys_app_shared/record/experiment.py",
|
||||||
|
"line": 737,
|
||||||
|
"end_line": 828
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"width": 156
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "raw",
|
||||||
|
"lane": "record",
|
||||||
|
"col": 4,
|
||||||
|
"type": "database",
|
||||||
|
"label": "原始试验目录",
|
||||||
|
"sublabel": "视频、力、参考与事件",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"path": "shared/src/orisys_app_shared/record/experiment.py",
|
||||||
|
"line": 1,
|
||||||
|
"end_line": 24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/multipoint.py",
|
||||||
|
"line": 1089,
|
||||||
|
"end_line": 1106,
|
||||||
|
"label": "HEAD:reference/events"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"width": 156
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fault",
|
||||||
|
"lane": "exceptions",
|
||||||
|
"col": 3,
|
||||||
|
"type": "security",
|
||||||
|
"label": "中止当前试验",
|
||||||
|
"sublabel": "断流、重启或写入失败",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/multipoint.py",
|
||||||
|
"line": 806,
|
||||||
|
"end_line": 830,
|
||||||
|
"label": "HEAD:中止路径"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "shared/src/orisys_app_shared/record/experiment.py",
|
||||||
|
"line": 897,
|
||||||
|
"end_line": 921,
|
||||||
|
"label": "写线程失败状态"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"width": 156
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "incomplete",
|
||||||
|
"lane": "failed",
|
||||||
|
"col": 5,
|
||||||
|
"type": "database",
|
||||||
|
"label": "保留不完整证据",
|
||||||
|
"sublabel": "记录原因,收尾后可诊断",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/capture_guard.py",
|
||||||
|
"line": 194,
|
||||||
|
"end_line": 225
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/multipoint.py",
|
||||||
|
"line": 1231,
|
||||||
|
"end_line": 1262,
|
||||||
|
"label": "HEAD:保留失败记录"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"width": 156
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "reopen",
|
||||||
|
"lane": "review",
|
||||||
|
"col": 0,
|
||||||
|
"type": "frontend",
|
||||||
|
"label": "打开标定记录",
|
||||||
|
"sublabel": "成功与已关闭失败记录",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/session_review.py",
|
||||||
|
"line": 408,
|
||||||
|
"end_line": 435
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"width": 156
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "frames",
|
||||||
|
"lane": "review",
|
||||||
|
"col": 1,
|
||||||
|
"type": "backend",
|
||||||
|
"label": "按采集时间定位",
|
||||||
|
"sublabel": "帧身份与分段位置关联",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/video_review.py",
|
||||||
|
"line": 126,
|
||||||
|
"end_line": 187,
|
||||||
|
"label": "HEAD:索引定位"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"width": 156
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "selection",
|
||||||
|
"lane": "review",
|
||||||
|
"col": 2,
|
||||||
|
"type": "frontend",
|
||||||
|
"label": "检查与选择区间",
|
||||||
|
"sublabel": "对应画面与原始力曲线",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/session_review.py",
|
||||||
|
"line": 310,
|
||||||
|
"end_line": 366
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"width": 156
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "save",
|
||||||
|
"lane": "review",
|
||||||
|
"col": 3,
|
||||||
|
"type": "database",
|
||||||
|
"label": "保存复核快照",
|
||||||
|
"sublabel": "新版本,不覆盖采集文件",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/session_review.py",
|
||||||
|
"line": 598,
|
||||||
|
"end_line": 622
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"width": 156
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "summary",
|
||||||
|
"lane": "review",
|
||||||
|
"col": 4,
|
||||||
|
"type": "backend",
|
||||||
|
"label": "计算窗口与灵敏度",
|
||||||
|
"sublabel": "候选与确认分别统计",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/summary.py",
|
||||||
|
"line": 447,
|
||||||
|
"end_line": 500,
|
||||||
|
"label": "HEAD:分集合拟合"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"width": 156
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "publish",
|
||||||
|
"lane": "review",
|
||||||
|
"col": 5,
|
||||||
|
"type": "database",
|
||||||
|
"label": "发布操作员汇总",
|
||||||
|
"sublabel": "报告、两表与最新指针",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/summary_export.py",
|
||||||
|
"line": 308,
|
||||||
|
"end_line": 366
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"width": 156
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"id": "begin-capture",
|
||||||
|
"from": "start",
|
||||||
|
"to": "camera",
|
||||||
|
"variant": "emphasis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "begin-holds",
|
||||||
|
"from": "start",
|
||||||
|
"to": "holds",
|
||||||
|
"label": "逐档确认",
|
||||||
|
"role": "branch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "capture-guard",
|
||||||
|
"from": "camera",
|
||||||
|
"to": "guard",
|
||||||
|
"variant": "emphasis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "guard-queue",
|
||||||
|
"from": "guard",
|
||||||
|
"to": "queue",
|
||||||
|
"variant": "emphasis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "queue-write",
|
||||||
|
"from": "queue",
|
||||||
|
"to": "writer",
|
||||||
|
"variant": "emphasis",
|
||||||
|
"role": "async"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "write-files",
|
||||||
|
"from": "writer",
|
||||||
|
"to": "raw",
|
||||||
|
"label": "写盘"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "holds-files",
|
||||||
|
"from": "holds",
|
||||||
|
"to": "raw",
|
||||||
|
"label": "参考与事件",
|
||||||
|
"role": "branch",
|
||||||
|
"variant": "dashed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "holds-close",
|
||||||
|
"from": "holds",
|
||||||
|
"to": "closing",
|
||||||
|
"label": "完成或中止",
|
||||||
|
"fromSide": "top",
|
||||||
|
"toSide": "top",
|
||||||
|
"via": [
|
||||||
|
[
|
||||||
|
872,
|
||||||
|
65
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1056,
|
||||||
|
65
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "write-close",
|
||||||
|
"from": "writer",
|
||||||
|
"to": "closing",
|
||||||
|
"label": "结束后收尾",
|
||||||
|
"role": "main"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "close-verdict",
|
||||||
|
"from": "closing",
|
||||||
|
"to": "verdict",
|
||||||
|
"label": "writer 终态",
|
||||||
|
"variant": "emphasis",
|
||||||
|
"fromSide": "right",
|
||||||
|
"toSide": "top",
|
||||||
|
"via": [
|
||||||
|
[
|
||||||
|
1204,
|
||||||
|
119
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1204,
|
||||||
|
315
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1056,
|
||||||
|
315
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "write-fault",
|
||||||
|
"from": "writer",
|
||||||
|
"to": "fault",
|
||||||
|
"label": "异常",
|
||||||
|
"role": "error",
|
||||||
|
"variant": "security"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fault-close",
|
||||||
|
"from": "fault",
|
||||||
|
"to": "closing",
|
||||||
|
"label": "停止后排空",
|
||||||
|
"role": "error",
|
||||||
|
"variant": "security",
|
||||||
|
"fromSide": "right",
|
||||||
|
"toSide": "bottom",
|
||||||
|
"via": [
|
||||||
|
[
|
||||||
|
968,
|
||||||
|
367
|
||||||
|
],
|
||||||
|
[
|
||||||
|
968,
|
||||||
|
175
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1056,
|
||||||
|
175
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "verdict-failed",
|
||||||
|
"from": "verdict",
|
||||||
|
"to": "incomplete",
|
||||||
|
"label": "未通过",
|
||||||
|
"role": "error",
|
||||||
|
"variant": "security"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "verdict-review",
|
||||||
|
"from": "verdict",
|
||||||
|
"to": "reopen",
|
||||||
|
"label": "收尾后复核",
|
||||||
|
"role": "return"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "reopen-frames",
|
||||||
|
"from": "reopen",
|
||||||
|
"to": "frames",
|
||||||
|
"variant": "emphasis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "frames-select",
|
||||||
|
"from": "frames",
|
||||||
|
"to": "selection",
|
||||||
|
"variant": "emphasis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "select-save",
|
||||||
|
"from": "selection",
|
||||||
|
"to": "save",
|
||||||
|
"variant": "emphasis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "save-summary",
|
||||||
|
"from": "save",
|
||||||
|
"to": "summary",
|
||||||
|
"variant": "emphasis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "summary-publish",
|
||||||
|
"from": "summary",
|
||||||
|
"to": "publish",
|
||||||
|
"variant": "emphasis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "source-fault",
|
||||||
|
"from": "guard",
|
||||||
|
"to": "fault",
|
||||||
|
"label": "来源异常",
|
||||||
|
"role": "error",
|
||||||
|
"variant": "security",
|
||||||
|
"fromSide": "bottom",
|
||||||
|
"toSide": "left",
|
||||||
|
"via": [
|
||||||
|
[
|
||||||
|
310,
|
||||||
|
367
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "failed-review",
|
||||||
|
"from": "incomplete",
|
||||||
|
"to": "reopen",
|
||||||
|
"label": "诊断回放",
|
||||||
|
"role": "branch",
|
||||||
|
"variant": "dashed",
|
||||||
|
"fromSide": "left",
|
||||||
|
"toSide": "left",
|
||||||
|
"via": [
|
||||||
|
[
|
||||||
|
26,
|
||||||
|
491
|
||||||
|
],
|
||||||
|
[
|
||||||
|
26,
|
||||||
|
615
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"dot": "cyan",
|
||||||
|
"title": "每次试验的一条证据链",
|
||||||
|
"items": [
|
||||||
|
"连续保留零载、换档、加载与卸载;保持段不拆视频。",
|
||||||
|
"单次试验写 trials;多点试验写 sites/repeat/capture。"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dot": "emerald",
|
||||||
|
"title": "复核与汇总",
|
||||||
|
"items": [
|
||||||
|
"用主机采集时间关联窗口,用帧索引定位视频。",
|
||||||
|
"回放仅解码所选区间;不累积保存所有图像。",
|
||||||
|
"结束整场可先发布候选汇总,保存复核后发布新版本。"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dot": "amber",
|
||||||
|
"title": "版本与验证边界",
|
||||||
|
"items": [
|
||||||
|
"当前图包含五项未提交修复;源码链接对应 HEAD 基线的模块归属。",
|
||||||
|
"工作树补丁与文件哈希见同目录 snapshot.json。",
|
||||||
|
"录像支持视觉复核;不代表独立精度验证或 FT 物理同步。"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"receiptId": "5aac4630-c1c0-45fa-9b13-87c930df7706",
|
||||||
|
"status": "current",
|
||||||
|
"command": "deliver",
|
||||||
|
"type": "workflow",
|
||||||
|
"input": "D:\\orisys_ws\\orisys-app\\apps\\autocal\\docs\\diagrams\\recording-20261017-110144\\candidate.json",
|
||||||
|
"output": "D:\\orisys_ws\\orisys-app\\apps\\autocal\\docs\\diagrams\\recording-20261017-110144\\recording-workflow.html",
|
||||||
|
"specification": {
|
||||||
|
"sha256": "6778c4a565b36a780a741a791aa59580682eb6c3d61092b1da110319faff96c1",
|
||||||
|
"bytes": 14323
|
||||||
|
},
|
||||||
|
"artifact": {
|
||||||
|
"sha256": "33f8f732ff5f7540d55690a3a8f7a2115b247ade43f3918bdd0654bc87f90e91",
|
||||||
|
"bytes": 779934
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"format_version": "autocal-workflow-snapshot-1.0",
|
||||||
|
"baseline_revision": "19426c3c4a648ca7f4d3785e2e11b1bf124a8e31",
|
||||||
|
"source_links": "HEAD module evidence; current fixes are described by working-tree.patch and file hashes",
|
||||||
|
"patch": "working-tree.patch",
|
||||||
|
"patch_sha256": "dce358a6882f44e02069ebcae65707503018958d9884aab534c8702402bc36db",
|
||||||
|
"candidate_sha256": "6778c4a565b36a780a741a791aa59580682eb6c3d61092b1da110319faff96c1",
|
||||||
|
"html_sha256": "33f8f732ff5f7540d55690a3a8f7a2115b247ade43f3918bdd0654bc87f90e91",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/capture_guard.py",
|
||||||
|
"head_sha256": "3db256e207cff524074b80b1725fa9f3551b63ce56de4601d7a0a6a56b0668f1",
|
||||||
|
"worktree_sha256": "3db256e207cff524074b80b1725fa9f3551b63ce56de4601d7a0a6a56b0668f1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/multipoint.py",
|
||||||
|
"head_sha256": "345eeaa41d1e12b3fa237f8eea239d9955716b20b23f1ba2d5bbb4e49d39968b",
|
||||||
|
"worktree_sha256": "fad7e9bbdc41472806972be45eea346f7d14eb72fb2f2f7c74d7e07108586ebb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/session_review.py",
|
||||||
|
"head_sha256": "61d01756593629c7b6282dbaf3adbd043a94b4b7a30468c52e61e55cb778fc3e",
|
||||||
|
"worktree_sha256": "21619b4185abcb15cbb6e508acfcbcc63b618f590d4107cbbc687fc344096318"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/summary.py",
|
||||||
|
"head_sha256": "84fd0ff5f6c3465f0d4a3526178a37c32b64304e62cdd10cbc88f0c52e5202f2",
|
||||||
|
"worktree_sha256": "d15e7f00e944305f730075fd2e302cda4e44b0aed14bebc6b0d473c4c5ec0f2f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/summary_export.py",
|
||||||
|
"head_sha256": "a49ac5d0c55eed301d413af7b0b35d46fc4ba9bec9bf0a05fb421831b5102e9f",
|
||||||
|
"worktree_sha256": "a49ac5d0c55eed301d413af7b0b35d46fc4ba9bec9bf0a05fb421831b5102e9f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py",
|
||||||
|
"head_sha256": "5e4b204cc1304ac3d9235d2c4d0f287a45b86c3fc3c24dc6ad141a958a4c366a",
|
||||||
|
"worktree_sha256": "472dc129a68fad5ec40673185a9ce7549a7310b817b20a5636379ded4e8d57e6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/ui/session_review_dialog.py",
|
||||||
|
"head_sha256": "b9fe09f64abc5f508a2f47c877faee3e2ec7827dd1719dac77aeba685a12d408",
|
||||||
|
"worktree_sha256": "fe50e1b3b9fc1edb1b64c36fc44997416db818c10363341021598d46864f8640"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/ui/video_review_dialog.py",
|
||||||
|
"head_sha256": "96d363c5fcfee9fc11367273ddcf36f2730595d3c957e62d91fe30946ce94ce9",
|
||||||
|
"worktree_sha256": "f03648ac69197e9bb5f91a425e8d8b376f568396f3ce81f6d90007a6c092ace9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "apps/autocal/src/orisys_autocal/video_review.py",
|
||||||
|
"head_sha256": "44233205b41a075f8abd89f4a1f01c6079dd092d739d1842bc66cde19e2b65e1",
|
||||||
|
"worktree_sha256": "18992c25777afd8a241545693c63d91b321de7c3cb08690b606fcc54fb6d192a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "shared/src/orisys_app_shared/acquisition/finger_worker.py",
|
||||||
|
"head_sha256": "a9bff46aef6191a99efdc019aff4d4a4908c2a39e9bfcb0c4dc29ec6a6909a33",
|
||||||
|
"worktree_sha256": "db9e59c728fef241e5fdfde6cdf683c70c9785efea1dc2139c0080695ecd110b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "shared/src/orisys_app_shared/record/experiment.py",
|
||||||
|
"head_sha256": "3f0c6fba0cbfd711d4b9db8a62aa325887073ef08b29f7561d24de27e9a666a2",
|
||||||
|
"worktree_sha256": "405abe333319a8f0b9aeb6b60a15e2d7cedbfdcec9a84d82a3685c4ec23008ed"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"verification": {
|
||||||
|
"regression_before": "5 failed",
|
||||||
|
"focused_after": "45 passed",
|
||||||
|
"autocal_after": "572 passed; 0 skipped",
|
||||||
|
"lint": "pass",
|
||||||
|
"archify_gates": {
|
||||||
|
"validate": "pass",
|
||||||
|
"deliver": "pass",
|
||||||
|
"check": "pass",
|
||||||
|
"browser-check": "pass"
|
||||||
|
},
|
||||||
|
"visual_review": "light and dark screenshots inspected"
|
||||||
|
},
|
||||||
|
"scope": "software only; no physical camera, controller or load operation"
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
diff --git a/apps/autocal/src/orisys_autocal/multipoint.py b/apps/autocal/src/orisys_autocal/multipoint.py
|
||||||
|
index c0bfc92..e7461f4 100644
|
||||||
|
--- a/apps/autocal/src/orisys_autocal/multipoint.py
|
||||||
|
+++ b/apps/autocal/src/orisys_autocal/multipoint.py
|
||||||
|
@@ -1298,6 +1298,10 @@ class Campaign:
|
||||||
|
# called complete evidence.
|
||||||
|
self._source_stopped = True
|
||||||
|
self.stop("the image source stopped")
|
||||||
|
+ elif self._recorder is not None and (
|
||||||
|
+ not self._recorder.is_recording or self._recorder.stats().write_errors
|
||||||
|
+ ):
|
||||||
|
+ self.stop("the recording writer stopped or failed")
|
||||||
|
if self.capture_state == "closing":
|
||||||
|
self._finalise_close()
|
||||||
|
return {
|
||||||
|
@@ -1329,6 +1333,9 @@ class Campaign:
|
||||||
|
self.capture_state == "recording"
|
||||||
|
and self._guard is not None
|
||||||
|
and not self._guard.failed
|
||||||
|
+ and self._recorder is not None
|
||||||
|
+ and self._recorder.is_recording
|
||||||
|
+ and not self._recorder.stats().write_errors
|
||||||
|
)
|
||||||
|
|
||||||
|
def capture_summary(self) -> dict:
|
||||||
|
diff --git a/apps/autocal/src/orisys_autocal/summary.py b/apps/autocal/src/orisys_autocal/summary.py
|
||||||
|
index e282a78..d8c5105 100644
|
||||||
|
--- a/apps/autocal/src/orisys_autocal/summary.py
|
||||||
|
+++ b/apps/autocal/src/orisys_autocal/summary.py
|
||||||
|
@@ -502,18 +502,19 @@ def build_sensitivity(windows: list[dict]) -> list[dict]:
|
||||||
|
|
||||||
|
def aggregate_sensitivity(rows: list[dict]) -> list[dict]:
|
||||||
|
"""Mean and sample deviation of the per-trial slopes. Not a joint fit."""
|
||||||
|
- groups: dict[tuple[str, str], list[dict]] = {}
|
||||||
|
+ groups: dict[tuple[str, str, str], list[dict]] = {}
|
||||||
|
for row in rows:
|
||||||
|
if row["status"] != "available" or row["slope_raw_per_N"] is None:
|
||||||
|
continue
|
||||||
|
- groups.setdefault((row["site_id"], row["component"]), []).append(row)
|
||||||
|
+ groups.setdefault((row["site_id"], row["component"], row["review_status"]), []).append(row)
|
||||||
|
result = []
|
||||||
|
- for (site_id, component), group in sorted(groups.items()):
|
||||||
|
+ for (site_id, component, review_status), group in sorted(groups.items()):
|
||||||
|
slopes = [float(row["slope_raw_per_N"]) for row in group]
|
||||||
|
result.append({
|
||||||
|
"site_id": site_id,
|
||||||
|
"component": component,
|
||||||
|
- "trials": len(slopes),
|
||||||
|
+ "review_status": review_status,
|
||||||
|
+ "trials": len({row["trial_id"] for row in group}),
|
||||||
|
"slope_mean_raw_per_N": float(np.mean(slopes)),
|
||||||
|
"slope_std_raw_per_N": float(np.std(slopes, ddof=1)) if len(slopes) >= 2 else None,
|
||||||
|
"window_count": sum(int(row["window_count"] or 0) for row in group),
|
||||||
|
diff --git a/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py b/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py
|
||||||
|
index 23c9b74..e6f7c12 100644
|
||||||
|
--- a/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py
|
||||||
|
+++ b/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py
|
||||||
|
@@ -551,6 +551,7 @@ class RecordOverviewDialog(QDialog):
|
||||||
|
|
||||||
|
def _video(self):
|
||||||
|
"""Open the pictures of the selected point's current window."""
|
||||||
|
+ from ..video_review import trial_is_closed
|
||||||
|
from .video_review_dialog import VideoReviewDialog
|
||||||
|
|
||||||
|
index = self.table.currentRow()
|
||||||
|
@@ -571,8 +572,7 @@ class RecordOverviewDialog(QDialog):
|
||||||
|
(item for item in self.review.get("trials") or []
|
||||||
|
if item["trial_id"] == window["trial_id"]), None,
|
||||||
|
)
|
||||||
|
- if trial is None or trial.get("status") != "complete":
|
||||||
|
- # An unclosed trial still has the writer's file open.
|
||||||
|
+ if not trial_is_closed(self.directory, trial):
|
||||||
|
self.notice.setText(t("overview.guide_incomplete"))
|
||||||
|
return None
|
||||||
|
dialog = VideoReviewDialog(
|
||||||
|
diff --git a/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py b/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py
|
||||||
|
index 6992bac..5a25696 100644
|
||||||
|
--- a/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py
|
||||||
|
+++ b/apps/autocal/src/orisys_autocal/ui/session_review_dialog.py
|
||||||
|
@@ -14,6 +14,7 @@ from PySide6.QtWidgets import (
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..session_review import review_session, save_review, load_review, reselect_review
|
||||||
|
+from ..video_review import trial_is_closed
|
||||||
|
from .review_job import ReviewJob
|
||||||
|
from .chart_helpers import add_value_axes, clear_chart
|
||||||
|
|
||||||
|
@@ -231,8 +232,7 @@ class SessionReviewDialog(QDialog):
|
||||||
|
(item for item in self.review.get("trials") or []
|
||||||
|
if item["trial_id"] == window["trial_id"]), None,
|
||||||
|
)
|
||||||
|
- if trial is None or trial.get("status") != "complete":
|
||||||
|
- # Only a closed trial has a finished container to decode.
|
||||||
|
+ if not trial_is_closed(self.directory, trial):
|
||||||
|
self.message.setText(t("overview.guide_incomplete"))
|
||||||
|
return None
|
||||||
|
from .video_review_dialog import VideoReviewDialog
|
||||||
|
diff --git a/apps/autocal/src/orisys_autocal/ui/video_review_dialog.py b/apps/autocal/src/orisys_autocal/ui/video_review_dialog.py
|
||||||
|
index fbf2547..cfe2242 100644
|
||||||
|
--- a/apps/autocal/src/orisys_autocal/ui/video_review_dialog.py
|
||||||
|
+++ b/apps/autocal/src/orisys_autocal/ui/video_review_dialog.py
|
||||||
|
@@ -293,13 +293,18 @@ class VideoReviewDialog(QDialog):
|
||||||
|
window = self.window
|
||||||
|
if not window:
|
||||||
|
return
|
||||||
|
- origin = int(window["hold_start_host_mono_ns"])
|
||||||
|
+ origin = int(window["t_start_host_mono_ns"])
|
||||||
|
+ duration = max((int(window["t_end_host_mono_ns"]) - origin) / 1e9, 0.001)
|
||||||
|
+ selected_trace = [
|
||||||
|
+ (stamp, value) for stamp, _frame, value in window.get("trace") or []
|
||||||
|
+ if origin <= stamp < int(window["t_end_host_mono_ns"])
|
||||||
|
+ ]
|
||||||
|
trace = QScatterSeries()
|
||||||
|
trace.setMarkerSize(3)
|
||||||
|
trace.setName(t("review.valid_samples"))
|
||||||
|
trace.replace([
|
||||||
|
QPointF((stamp - origin) / 1e9, value)
|
||||||
|
- for stamp, _frame, value in window.get("trace") or []
|
||||||
|
+ for stamp, value in selected_trace
|
||||||
|
])
|
||||||
|
self.curve.addSeries(trace)
|
||||||
|
self.curve_line = QLineSeries()
|
||||||
|
@@ -308,15 +313,15 @@ class VideoReviewDialog(QDialog):
|
||||||
|
reference = QLineSeries()
|
||||||
|
reference.setName(t("video.reference"))
|
||||||
|
reference.append(0.0, float(window.get("reference_n") or 0.0))
|
||||||
|
- reference.append(max((window["hold_end_host_mono_ns"] - origin) / 1e9, 0.001),
|
||||||
|
+ reference.append(duration,
|
||||||
|
float(window.get("reference_n") or 0.0))
|
||||||
|
self.curve.addSeries(reference)
|
||||||
|
axis_x = QValueAxis()
|
||||||
|
axis_x.setTitleText(t("review.hold_time_axis"))
|
||||||
|
- axis_x.setRange(0, max((window["hold_end_host_mono_ns"] - origin) / 1e9, 0.001))
|
||||||
|
+ axis_x.setRange(0, duration)
|
||||||
|
axis_y = QValueAxis()
|
||||||
|
axis_y.setTitleText(t("review.raw_axis"))
|
||||||
|
- values = [value for _stamp, _frame, value in window.get("trace") or []]
|
||||||
|
+ values = [value for _stamp, value in selected_trace]
|
||||||
|
values.append(float(window.get("reference_n") or 0.0))
|
||||||
|
low, high = (min(values), max(values)) if values else (0.0, 1.0)
|
||||||
|
margin = max((high - low) * 0.1, 0.1)
|
||||||
|
@@ -335,7 +340,7 @@ class VideoReviewDialog(QDialog):
|
||||||
|
return
|
||||||
|
window = self.window
|
||||||
|
frame = self.video.frames[index]
|
||||||
|
- origin = int(window["hold_start_host_mono_ns"])
|
||||||
|
+ origin = int(window["t_start_host_mono_ns"])
|
||||||
|
x = (frame.t_host_mono_ns - origin) / 1e9
|
||||||
|
axis = self.curve.axes(Qt.Orientation.Vertical)
|
||||||
|
low, high = (axis[0].min(), axis[0].max()) if axis else (0.0, 1.0)
|
||||||
|
diff --git a/apps/autocal/src/orisys_autocal/video_review.py b/apps/autocal/src/orisys_autocal/video_review.py
|
||||||
|
index 4391a74..ad15f97 100644
|
||||||
|
--- a/apps/autocal/src/orisys_autocal/video_review.py
|
||||||
|
+++ b/apps/autocal/src/orisys_autocal/video_review.py
|
||||||
|
@@ -11,7 +11,8 @@ segment stays a reason. The decoder is released by :meth:`WindowVideo.close`.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
-from dataclasses import dataclass, field
|
||||||
|
+import json
|
||||||
|
+from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
@@ -79,6 +80,24 @@ def _trial(review: dict, trial_id: str) -> dict | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
+def trial_is_closed(root: Path, trial: dict | None) -> bool:
|
||||||
|
+ """Closed failed evidence is replayable; a live writer's container is not."""
|
||||||
|
+ if trial is None or trial.get("capture_status") in {"idle", "starting", "recording", "closing"}:
|
||||||
|
+ return False
|
||||||
|
+ if (trial.get("capture_report") or {}).get("completed") is True:
|
||||||
|
+ return True
|
||||||
|
+ if trial.get("status") == "complete":
|
||||||
|
+ return True
|
||||||
|
+ directory = resolve_capture_directory(root, trial_directory=str(trial.get("directory") or ""))
|
||||||
|
+ if directory is None:
|
||||||
|
+ return False
|
||||||
|
+ try:
|
||||||
|
+ metadata = json.loads((directory / "session.json").read_text(encoding="utf-8"))
|
||||||
|
+ except (OSError, ValueError):
|
||||||
|
+ return False
|
||||||
|
+ return bool(metadata.get("ended_at"))
|
||||||
|
+
|
||||||
|
+
|
||||||
|
def in_window_frames(
|
||||||
|
review: dict, *, trial_id: str, step_index: int
|
||||||
|
) -> list[FrameRow] | str:
|
||||||
|
@@ -97,8 +116,8 @@ def in_window_frames(
|
||||||
|
)
|
||||||
|
if directory is None:
|
||||||
|
return NO_FRAME_SOURCE
|
||||||
|
- start = int(window["hold_start_host_mono_ns"])
|
||||||
|
- end = int(window["hold_end_host_mono_ns"])
|
||||||
|
+ start = int(window.get("t_start_host_mono_ns", window["hold_start_host_mono_ns"]))
|
||||||
|
+ end = int(window.get("t_end_host_mono_ns", window["hold_end_host_mono_ns"]))
|
||||||
|
unique, _duplicated = unique_frames(read_frame_index(directory))
|
||||||
|
frames = [
|
||||||
|
frame for frame in unique.values()
|
||||||
|
@@ -205,7 +224,6 @@ class WindowVideo:
|
||||||
|
frames: list[FrameRow]
|
||||||
|
#: Index of the frame currently decoded, or -1 when the decoder was reset.
|
||||||
|
position: int = -1
|
||||||
|
- _images: dict[int, np.ndarray] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def frame_count(self) -> int:
|
||||||
|
@@ -214,8 +232,6 @@ class WindowVideo:
|
||||||
|
def frame(self, index: int) -> np.ndarray | None:
|
||||||
|
"""Image for ``frames[index]``, or ``None`` when that frame has no picture."""
|
||||||
|
index = max(0, min(int(index), self.frame_count - 1))
|
||||||
|
- if index in self._images:
|
||||||
|
- return self._images[index]
|
||||||
|
row = self.frames[index]
|
||||||
|
try:
|
||||||
|
image, served = self.reader.video_frame_at(row.t_rel_s)
|
||||||
|
@@ -231,12 +247,10 @@ class WindowVideo:
|
||||||
|
self.position = -1
|
||||||
|
return None
|
||||||
|
self.position = index
|
||||||
|
- self._images[index] = image
|
||||||
|
return image
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
self.reader.close()
|
||||||
|
- self._images.clear()
|
||||||
|
self.position = -1
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
@@ -53,7 +53,7 @@ With the corrected envelope, solved by closed form
|
|||||||
|
|
||||||
| Envelope | Worst residual | Within 0.5 deg | In range |
|
| Envelope | Worst residual | Within 0.5 deg | In range |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| pitch 360 deg, **yaw +-180 deg** (the machine) | **1.2e-06 deg** | 100% | **1032 / 1032** |
|
| pitch 90 deg, **yaw +-180 deg** (the machine) | **1.2e-06 deg** | 100% | **1032 / 1032** |
|
||||||
|
|
||||||
Every vertex is reachable, and the residual is at the floating-point floor rather
|
Every vertex is reachable, and the residual is at the floating-point floor rather
|
||||||
than at any clamp. The "0.21 deg worst, genuinely clamped vertex" claim above was
|
than at any clamp. The "0.21 deg worst, genuinely clamped vertex" claim above was
|
||||||
@@ -65,11 +65,17 @@ the reachability evidence. Whether they help *repeatability* — a shorter pitch
|
|||||||
move, a better-conditioned contact — is a separate bench question, not a
|
move, a better-conditioned contact — is a separate bench question, not a
|
||||||
kinematic one.
|
kinematic one.
|
||||||
|
|
||||||
One thing the corrected solve did surface: **51 of 1032 vertices end inverted**,
|
**Correction (motion-correction-plan.zh.md task 1).** This note used to claim that
|
||||||
whichever closed-form branch is chosen, because the tool direction is
|
**51 of 1032 vertices end inverted** whichever branch is chosen, because the tool
|
||||||
antiparallel to the sensor's own up axis. That is the tool-polarity question, it
|
direction was antiparallel to the sensor's own up axis. That count came from
|
||||||
needs the bench, and it is *not* visible in the residual. See
|
`uprightness` comparing a platform axis through `to_sensor(np.eye(3)[1])`, which is
|
||||||
`inverse-kinematics.md` → *Still open*.
|
the sensor's own **-Z** — a horizontal axis — so a body rolled about the vertical
|
||||||
|
was reported as turned over. Measured against the post-mount nominal up (the tool
|
||||||
|
axis) no exported vertex is inverted, and the corrected flag changes no vertex's
|
||||||
|
branch selection. What did invert poses was the `approach_sign` polarity bug
|
||||||
|
recorded above; the residual could not see it, and neither could that up test. See
|
||||||
|
`inverse-kinematics.md` → *the face is aimed at +Z* and *the two exact poses of one
|
||||||
|
normal*.
|
||||||
|
|
||||||
## What was wrong before, and why it is worth recording
|
## What was wrong before, and why it is worth recording
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ Three buttons, and where each one lives is deliberate:
|
|||||||
|
|
||||||
| Button | Where | What it does |
|
| Button | Where | What it does |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **执行动作** Execute | this screen, next to 播放动作 | Sends the selected point's solved pose to the platform: **yaw, then pitch, then table X/Y**. The press axis **z is not commanded** — pressing is P7, with contact detection and press depth, neither of which exists here, so a move to the contact height would be a press with none of its guards. |
|
| **执行动作** Execute | this screen, next to 播放动作 | Sends the selected point's solved pose to the platform: the stages together with the table X/Y, and the press axis **only when its zero is calibrated** (设备设置 → 压轴). The geometric contact height is not a controller coordinate, so an uncalibrated press axis is *held* and reported as held rather than commanded with a number the machine has not measured. |
|
||||||
| **停止** Stop | here *and* the strip's 全部停止 | The same command. Two access points, one implementation: during a move the operator's eyes are on this pane, and an emergency stop has to be where they are. |
|
| **停止** Stop | here *and* the strip's 全部停止 | The same command. Two access points, one implementation: during a move the operator's eyes are on this pane, and an emergency stop has to be where they are. |
|
||||||
| **回初始位** Return to zero | the axis rail, on every tab | Every drive to 0 — press axis first, then the stages, then the table. A machine-level action, wanted after work in any tab, so it is not repeated inside one. |
|
| **回初始位** Return to zero | the axis rail, on every tab | Every drive to 0 — press axis first, then the stages, then the table. A machine-level action, wanted after work in any tab, so it is not repeated inside one. |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 独立参考力与反馈骨架
|
||||||
|
|
||||||
|
用途:当前代码契约。状态:离线可测试;未接设备、运动循环、界面或生产记录。架构问题与实施顺序见[审查](../../../../docs/workflow/autocal-architecture-review.zh.md)。
|
||||||
|
|
||||||
|
## 边界与单位
|
||||||
|
|
||||||
|
[`reference_force.py`](../src/orisys_autocal/reference_force.py) 定义 `ReferenceForceSensor`:由适配器建立连接、去皮和校准,单个采集线程拥有非阻塞 `read()` 与 `close()`;无新数据返回 `None`。异常、断开和设备生命周期由未来适配器处理,不能转成零力。
|
||||||
|
|
||||||
|
单轴适配器输出不可变的 `NormalForceSample`,`force_n` 为 N,压缩为正。FT 适配器输出 `WrenchSample`,三个力分量为 N、可选三个力矩分量为 N m,明确 `frame`。两者携带 `source_id`、`calibration_id`、有效性和接收时的 `t_host_mono_ns`。设备原生时钟以后另存,不能直接冒充主机时钟。
|
||||||
|
|
||||||
|
`project_wrench()` 必须显式给出 transducer→tool 的旋转、预期输入坐标系和 `compression_sign`。它验证正交性、行列式 +1,并复用 `normal_force_n()`:`F_normal = compression_sign * (R @ F_sensor)[TOOL_AXIS]`。单轴传感器的安装方向由其适配器标定;不得输入触觉算法的 raw 值。FT 安装于旋转台时,R 必须随姿态更新;固定工具端才可采用已验证的固定 R。
|
||||||
|
|
||||||
|
力投影不需要平移;转换不同力矩原点时还需力臂,本次未实现力矩变换。保留原始 wrench,不能把只投影法向力称作完整 wrench 变换。原 `multipoint` 的 `parse_rotation/normal_force_n` 导入保持兼容,算法只有一个实现。
|
||||||
|
|
||||||
|
## 控制输入和输出
|
||||||
|
|
||||||
|
[`force_control.py`](../src/orisys_autocal/force_control.py) 的 `ForceController.update()` 消费已校准法向力、同步压入量和当前主机时间,返回 `ForceDecision`。`depth_mm` 是接触零点之后、向橡胶内部为正的压入量,范围为 `[0,max_depth_mm]`;它既不是几何高度,也不是 Z 轴绝对坐标。运动适配层必须用 `PressAxis` 的方向/零点及当次接触零点建立转换。
|
||||||
|
|
||||||
|
输出 `velocity_mm_s` 是压入速度建议,正值增加压入。样本、压入量、目标、误差、力变化率和故障原因均保留在结果中,可由未来记录器逐样本保存;当前不会自动写文件。配置中的增益量纲为:P `mm/(N s)`、I `mm/(N s²)`、D `mm/N`,全部显式输入,没有真实机器默认增益。
|
||||||
|
|
||||||
|
| 模式 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| `record_relaxation`(默认) | 输出速度为零,记录力衰减和压入量。实际位置保持由执行器完成。 |
|
||||||
|
| `compensate` | 误差驱动 PID 速度建议;橡胶松弛造成欠力时允许继续压入,始终受限。 |
|
||||||
|
|
||||||
|
导数作用于测量值,积分在输出受限且误差继续推动饱和时冻结。速度同时受最大速度及剩余压入行程/最大样本间隔约束。每次保持有最长时限,不无限追赶松弛。模型测试覆盖标准线性固体的松弛行为,只证明算法路径可用,不代表实际橡胶模型已识别。
|
||||||
|
|
||||||
|
## 故障与执行责任
|
||||||
|
|
||||||
|
无效/非有限数据、缺少身份、未来/过期时间戳、重复/倒序/过大采样间隔、传感器或校准身份改变、力幅值超限、压入越界、保持超时及数值溢出都会锁存零输出,需显式 `reset()` 开始新保持。校准时保留有符号力;超力检查使用绝对值,不取绝对值伪造正法向力。
|
||||||
|
|
||||||
|
调用者必须在每个控制周期推进检查。没有新样本时可用最后一帧调用以触发停止;该版本也将重复样本作为故障,因此控制更新频率应跟随参考采样,而独立看门狗负责无数据时停止。接触尚未建立时不能运行该保持策略。
|
||||||
|
|
||||||
|
零速度建议不等于停止已经发出的运动。未来执行器必须保证唯一运动所有权、控制器端看门狗、限速/加速度与行程保护、命令确认及实际回读;故障或取消时发停止并核实停止。不得将每个周期的建议堆进现有 GUI 命令队列,也不能声称 `MotionPoller` 的 100 ms 刷新提供确定实时周期。本次骨架没有调用 DLL 或绕过 `MotionService`。
|
||||||
|
|
||||||
|
## 后续接入验收
|
||||||
|
|
||||||
|
先做硬件禁用状态的“适配器→投影→策略→记录”链路:证明掉线、饱和、过期、时钟跳变与取消保留原因,实际执行反馈可追溯到建议;然后才接受授权范围内的台架测试。保持实验分别记录恒力跟踪误差、峰值力、额外压入量和固定位置衰减曲线。PID 参数、滤波延迟、采样率、去皮、重力补偿和机械方向均须按具体设备实测。
|
||||||
|
|
||||||
|
当前测试位于 [`test_force_control.py`](../tests/test_force_control.py),覆盖非平凡旋转/反力符号、无效性、两种松弛模式、采样时间、身份变化、故障锁存、输出限幅、抗积分饱和及松弛模型。未连接的适配器与真实执行器不属于已验证能力。
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Autocal finger 标定实施计划
|
||||||
|
|
||||||
|
状态:2026-10-11 修订。用途:方案。阶段一的交付为操作员手动控制完成整条标定流程;M1 已接通视频与相机编号输入,并把主机采集时间绑定到 finger 帧。跨点 session、保持倒计时和模拟页测点操作表已接通;已接通会话重开、保持窗口统计、逐点曲线和 UV 初步灵敏度;正式系数及相机实机尚未验收。操作与目标文件字段以[数据格式草稿](calibration-data-format.zh.md)为准。
|
||||||
|
|
||||||
|
## 阶段一目标
|
||||||
|
|
||||||
|
操作员选择 finger 测点,设定法向加载范围和档位,在已有平台手动控制界面逐步移动或人工加砝码,标记实际到档时间。系统从加载前到卸载恢复持续记录图像、参考载荷、平台反馈和事件;结束后按测点计算灵敏度,展示曲线与空间分布,并能重新打开会话复核。阶段一不自动遍历测点或按载荷闭环运动。“自动执行”只作为以后可替换的动作来源,不改变记录和分析链。
|
||||||
|
|
||||||
|
| 边界 | 阶段一选择 |
|
||||||
|
|---|---|
|
||||||
|
| 传感器与坐标 | 仅 finger;UV pixel 与 mesh mm 同存,保存几何版本和测点姿态 |
|
||||||
|
| 加载 | 目标范围暂定 0–30 N;操作员确认档位并手动控制,加载与卸载保持同一 trial;不把范围当设备动作许可 |
|
||||||
|
| 参考 | 先用人工砝码/人工确认的有效时段跑通;ECAT 或 FT 有可用接口时接入同一时间与参考数据契约 |
|
||||||
|
| 视频 | 先用已有视频调试 `FingerAcquisitionWorker`;正式采集由相机进入同一 `SdkPacket`、记录和分析路径;每 trial 保存独立视频 |
|
||||||
|
| 结果 | 每个测点的参考力—原始法向信号曲线、拟合系数、有效点数、误差和加载/卸载差异;在 UV 测点图上着色,可切换测点看曲线 |
|
||||||
|
|
||||||
|
## 时间与来源契约
|
||||||
|
|
||||||
|
实时相机在电脑收到新帧时记录高精度单调时钟,时间戳须与**该帧身份**一起传递;`frames.csv` 记录采集时间,不用处理完成时间或配置帧率推算时间。另存 UTC 墙钟供外部追溯,时长和同进程流对齐用单调时钟。ECAT/FT 采样在电脑收到对应样本时使用相同时间基准,记录来源、采样序号、有效性及可测得的通信延迟。低延迟是待验证条件,不能省略时间戳或默认各流无偏移。
|
||||||
|
|
||||||
|
`orisys-sdk` 0.5.4 的 `CaptureSource.read_raw_stamped`/`read_latest_stamped` 现将图像、源帧编号、主机单调与 UTC 时钟一并返回;shared finger worker 将这些字段写入 `SdkPacket`,并区分 `host_camera` 与 `host_replay`。自定义运行时若没有带时间的读取接口则仅标 `host_fallback`,不能把该时间当成已验证的采集时刻。文件视频的主机时间是**本次解码时间**;媒体时间另存,不能凭配置帧率伪造原实验时间,也不能与原实验的 FT/运动流做物理同步。相机输入只经过模拟验证,仍需实机检查驱动延迟、丢帧与时钟对应。
|
||||||
|
|
||||||
|
## 复用边界与实施顺序
|
||||||
|
|
||||||
|
| 包 | 交付与验收 | 主要所有者 | 人时 |
|
||||||
|
|---|---|---|---:|
|
||||||
|
| M1 采集与时钟 | 视频/相机同一 finger worker;帧身份与电脑采集时间配对,来源和掉帧可见;针对两种输入测试 | SDK capture、shared acquisition | 8–12 |
|
||||||
|
| M2 会话记录 | `session/site/trial/step`、基准、每 trial 视频、帧/参考/事件索引;失败保留 incomplete,可重新打开 | Autocal 控制;shared record 仅提取真正共用的写入能力 | 12–18 |
|
||||||
|
| M3 手动操作 | 选点、设范围/档位、预览、开始、到档确认、卸载、结束/中止;沿用已有手动运动入口 | Autocal UI 与 motion | 10–14 |
|
||||||
|
| M4 灵敏度与图表 | 从有效保持窗口取原始法向信号和参考力,逐测点拟合;展示曲线、空间图、误差和滞后 | Autocal analysis/UI;优先复用 shared 的标定准备及拟合逻辑 | 8–12 |
|
||||||
|
| M5 集成验收 | 合成会话、旧消费者回归、视频重放、真实设备手动试验及操作指南核对 | 主代理集成 | 6–10 |
|
||||||
|
| 合计 | 可手动完成一次多点标定并复核结果 | 见下文 | 44–66 |
|
||||||
|
|
||||||
|
M1 的视频与相机输入现共用带时间的数据包;M2 记录器与 M3 手动 UI 可在接口固定后并行。M4 可先用合成 session 开发,但阶段一只有在真实记录上重新打开并计算逐测点结果才算完成。Studio 的 `SessionController`、widget 和 `RecordingCoordinator` 保留在 Studio;Autocal 直接复用 shared `FingerAcquisitionWorker`、`SdkPacket` 和 sink,不跨应用导入私有模块。只有出现两端实际共用的逻辑时才移到 shared;SDK 不依赖 UI。
|
||||||
|
|
||||||
|
界面复核:原先测点、计划和输入分散在“手指调试”“测试序列”“传感器”页。现在“标定采集”页串起单次 trial;全局“运行”按钮尚未负责标定,页面内有独立的“开始本次试验”。
|
||||||
|
|
||||||
|
已实现的最小闭环在“标定采集”页:点击 UV 图粗选、以 U/V 整数像素精确修改、切换到实时二维 UV 图像。首个 trial 建立本场 session,同点重复和新测点试验归入其 trials 子目录;每档人工确认后倒计时,初始/最终零载也需保持。参考窗口在计时终点结束,换档只保留视频和信号,不标成恒定参考力;中止保留 incomplete。
|
||||||
|
|
||||||
|
模拟页可按选点顺序导出与标定页相同 U/V 的 CSV 操作表,导入仍后置。未实现 FT 同步、稳定性复核、图像基准快照、正式标定和实体相机验收。手动 Z 控制仍使用既有设备界面,专用弹窗需先确定操作边界。
|
||||||
|
|
||||||
|
目前 M4 已提供独立会话复核窗口:按帧身份和采集时间取完整保持窗口,排除无效帧和换档,显示加载/卸载散点、加载分支初步线性拟合及 UV 测点着色。结果可另存 derived;未复核稳定性的人工参考及视频调试不升级为正式系数。
|
||||||
|
|
||||||
|
主代理负责接口、M3 和集成;一名子代理负责 M1/M2,另一名负责 M4 与合成会话验证。建议总计主代理加两名子代理,约 4–6 个工程工作日并留设备联调时间;单人约 6–9 日。估算不包括设备、FT 协议或硬件等待。当前计划不启动子代理。
|
||||||
|
|
||||||
|
## 阶段一验收和后续
|
||||||
|
|
||||||
|
| 检查 | 通过条件 |
|
||||||
|
|---|---|
|
||||||
|
| 操作闭环 | 操作员对至少两个 finger 测点完成选择、设档、手动加载/卸载、记录、结束;每点至少两个 trial |
|
||||||
|
| 时间与数据 | 每帧、参考样本和事件可按电脑采集时间关联;原始输入与 trial 输出可区分;缺失或失同步显式标记 |
|
||||||
|
| 分析与呈现 | 每点可见参考力—原始信号曲线、灵敏度和有效性;加载/卸载分别展示,跨点差异在 UV 图可见 |
|
||||||
|
| 恢复与故障 | 全新进程重开会话可复核结果;中断、写盘失败及无效参考不显示成有效系数 |
|
||||||
|
| 硬件 | 实际运动只在确认设备、行程/载荷边界及停机条件后执行,软件离线通过不等于实机通过 |
|
||||||
|
|
||||||
|
阶段二再增加自动测点遍历或自动加载:它们复用阶段一的 trial、事件、采集和分析接口,只替换人工动作及到档确认来源。FT/ECAT 自动参考接入可按设备可用性单独排期;测量延迟与钟偏通过实测确认。剪切、复杂空间模型和实时部署不属于阶段一。
|
||||||
@@ -16,10 +16,10 @@ the contact point ends up, and Z is reported, never solved.
|
|||||||
| Item | Value | Where |
|
| Item | Value | Where |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Tool axis | `z` | `motion/frames.py` |
|
| Tool axis | `z` | `motion/frames.py` |
|
||||||
| Yaw axis | `z`, travel **±180°** | `YAW_AXIS`, `finger_yaw_limit_deg` |
|
| Yaw axis | `z`, travel **±180°**, positive is clockwise from above | `YAW_AXIS`, `YAW_COMMAND_SIGN` |
|
||||||
| Pitch axis | `y`, travel 360° | `PITCH_AXIS`, `finger_pitch_limit_deg` |
|
| Pitch axis | `x`, travel **90°**, positive nods the dome toward `−Y` | `PITCH_AXIS`, `PITCH_COMMAND_SIGN` |
|
||||||
| Stage order | `R_pitch @ R_yaw` | `stage_rotation` |
|
| Stage order | `R_pitch @ R_yaw` | `stage_rotation` |
|
||||||
| Mount map | sensor `x→x, y→z, z→-y` | `AXIS_MAP` |
|
| Mount map | sensor `x→-x, y→z, z→y` | `AXIS_MAP` |
|
||||||
| Aim direction | `(0, 0, +1)` in the platform frame | `approach_sign = +1` |
|
| Aim direction | `(0, 0, +1)` in the platform frame | `approach_sign = +1` |
|
||||||
|
|
||||||
### The face is aimed at +Z, and this was wrong
|
### The face is aimed at +Z, and this was wrong
|
||||||
@@ -29,9 +29,10 @@ tool feeds **down** onto it. So the sensing face — and the surface normal —
|
|||||||
along **`+Z`**, and the solver's aim is to bring the normal onto `+Z`.
|
along **`+Z`**, and the solver's aim is to bring the normal onto `+Z`.
|
||||||
|
|
||||||
`approach_sign` was **`-1`**, aiming every normal at `-Z` and asking the machine to
|
`approach_sign` was **`-1`**, aiming every normal at `-Z` and asking the machine to
|
||||||
turn the sensor over. It survived because the residual is sign-blind
|
turn the sensor over. Nothing caught it, because the residual is measured against
|
||||||
(`arccos` maps a face turned fully over to `0.0000°`, the same as perfectly
|
the aim vector itself: `acos(turned · tool_direction)` reports `0.0000°` for a face
|
||||||
aligned) and because every test asserted only that the residual was *small*. The
|
that landed *on the aim*, and the aim had been flipped — so a pose turned fully over
|
||||||
|
scored as perfect. Every test asserted only that the residual was *small*. The
|
||||||
failure was visible **only** as the model flipping upside down.
|
failure was visible **only** as the model flipping upside down.
|
||||||
|
|
||||||
Measured over the export, counting vertices whose face ends up pointing away from
|
Measured over the export, counting vertices whose face ends up pointing away from
|
||||||
@@ -40,12 +41,15 @@ the tool:
|
|||||||
| `approach_sign` | Face ends inverted | Nominal pose (`yaw = pitch = 0`) |
|
| `approach_sign` | Face ends inverted | Nominal pose (`yaw = pitch = 0`) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `-1` (was) | **981 / 1032** | pitch **180°** needed; lands on `(0, 0, -1)` |
|
| `-1` (was) | **981 / 1032** | pitch **180°** needed; lands on `(0, 0, -1)` |
|
||||||
| `+1` (now) | **22 / 1032** | **no rotation**; lands on `(0, 0, +1)` |
|
| `+1` (now) | **0 / 1032** | **no rotation**; lands on `(0, 0, +1)` |
|
||||||
|
|
||||||
The 22 are the rim of the dome: their normals sit 60–90° off the tool axis, so
|
An earlier revision of this document reported 22 vertices not `upright` under `+1`.
|
||||||
aligning them necessarily rolls the body about that normal and the sign is
|
That count came from `uprightness` comparing a **platform Y** direction — a
|
||||||
decided by rounding. They are edge-on, not turned over — the most negative
|
horizontal axis — so a pose that merely rolled the body about the vertical was
|
||||||
`uprightness` among them is a hair past zero, not `-1`.
|
reported as turned over. `motion-correction-plan.zh.md` task 1 replaced the
|
||||||
|
reference with the post-mount nominal up (the tool axis) in the same frame; with
|
||||||
|
that reference the exported model reports `upright` for **all 1032 vertices**, and
|
||||||
|
the corrected flag does not change which branch any of them selects.
|
||||||
|
|
||||||
The nominal pose needing **no rotation at all** is the tell: a machine that has to
|
The nominal pose needing **no rotation at all** is the tell: a machine that has to
|
||||||
make a half-turn to reach its own commissioned configuration has the polarity
|
make a half-turn to reach its own commissioned configuration has the polarity
|
||||||
@@ -127,24 +131,25 @@ The closed form is **~200,000× more accurate** and **~7× faster**. Verified at
|
|||||||
That residual is the floating-point floor: `acos` near 1 loses about `sqrt(eps)`
|
That residual is the floating-point floor: `acos` near 1 loses about `sqrt(eps)`
|
||||||
in radians, so "exact" cannot be reported as more precise than this.
|
in radians, so "exact" cannot be reported as more precise than this.
|
||||||
|
|
||||||
All 1032 vertices are **in range**, and 1010 report `upright`. The 22 that do not
|
All 1032 vertices are **in range**, and all 1032 report `upright` under the
|
||||||
are the rim of the dome, where the normal is 60–90° off the tool axis — see
|
tool-axis reference task 1 introduced. Reachability is the yaw limit, not the rim:
|
||||||
*the face is aimed at +Z* above and *still open* below.
|
see *the face is aimed at +Z* above and *the two exact poses of one normal* below.
|
||||||
|
|
||||||
### The two pitch bindings are one family
|
### The pitch axis is the left-right axis
|
||||||
|
|
||||||
`PITCH_AXIS` is `y`, while the trunnion drawing shows the pitch arc about `x`.
|
The right-hand identity
|
||||||
This turns out not to matter:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
R_y(p) · R_z(t) ≡ R_x(p) · R_z(t − 90°)
|
R_y(p) · R_z(t) ≡ R_x(p) · R_z(t − 90°)
|
||||||
```
|
```
|
||||||
|
|
||||||
agreeing to machine precision in alignment for every yaw and pitch tried. So the
|
still says the two bindings can reach the same alignments. It does not say the
|
||||||
axis binding is a **naming and zero-offset choice**: the two reach exactly the
|
drive can use either one. Measured from the commissioned zero on 2026-10-15:
|
||||||
same set of alignments, and the 90° difference is absorbed when the yaw stage is
|
pitch is about platform `x`. An executed pose matched the preview only after
|
||||||
zeroed at commissioning. `PITCH_AXIS` is documentation, not a correctness
|
the pitch sign was reversed, so positive pitch is the right-hand sense about
|
||||||
question — which is why the tests pin the identity rather than a value.
|
`+X` and nods the dome toward the operator. Axis 2 at `+10°` turned the tip clockwise from above, so positive yaw is
|
||||||
|
the opposite of the right-hand sense about `+Z`. The solver, the preview and
|
||||||
|
the number sent to the drive are that commanded angle.
|
||||||
|
|
||||||
## What the solver reports
|
## What the solver reports
|
||||||
|
|
||||||
@@ -154,43 +159,78 @@ everything the machine needs to know:
|
|||||||
| Field | Meaning |
|
| Field | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `residual_deg` | angle between the rotated normal and the tool direction |
|
| `residual_deg` | angle between the rotated normal and the tool direction |
|
||||||
| `upright` | whether the pose leaves the sensing face pointing along the tool |
|
| `upright` | whether the mounted body's up axis still points along the tool |
|
||||||
| `in_range` | whether the pose is inside the stage travel |
|
| `in_range` | whether the pose is inside the stage travel |
|
||||||
| `reason` | why not, when it is not |
|
| `reason` | why not, when it is not |
|
||||||
|
| `mounted_point_mm` | the picked point after mounting, before the stages turn |
|
||||||
|
| `contact_z_mm` | the contact height **after** the stages turn |
|
||||||
|
| `contact_point_mm` | where the move ends: `(tool_x, tool_y, contact_z_mm)` |
|
||||||
|
| `branch_reason` | which key chose this branch among the exact ones |
|
||||||
|
|
||||||
A pose that aligns perfectly and turns the sensor over is a different thing from
|
`upright` used to be measured against the platform's Y axis, which reports a body
|
||||||
one that aligns perfectly and does not, and the residual alone cannot tell them
|
rolled about the vertical as turned over; it now compares the tool axis with the
|
||||||
apart. Likewise a pose needing more travel than the stage has: `in_range` says so,
|
same axis after the stages turn. A pose that aligns perfectly and turns the sensor
|
||||||
and the solver reports the **best pose the limits do allow** with its real
|
over is a different thing from one that aligns perfectly and does not, and the
|
||||||
residual, rather than an angle the machine cannot reach or a residual it cannot
|
residual alone cannot tell them apart — but the up test can only do that if it uses
|
||||||
achieve.
|
the axis the machine actually calls up.
|
||||||
|
|
||||||
## History: the sign-blind residual
|
Three positions belong to one solve and they are not interchangeable: where the
|
||||||
|
point sits on the mounted sensor, where the stages leave it, and where the tool has
|
||||||
|
to be. `mounted_point_mm` answers the first, `contact_z_mm`/`contact_point_mm` the
|
||||||
|
second and third. The contact height is the **rotated** one: mounting a plate or
|
||||||
|
offsetting the mount shifts the presented point, so the height after the stages
|
||||||
|
turn is what the press axis has to reach.
|
||||||
|
|
||||||
The previous residual was `acos(turned · tool_direction)`, which is **sign-blind**.
|
### From contact height to a press axis command
|
||||||
A rotation putting the normal *on* the tool axis and one putting it *opposite*
|
|
||||||
both give `|cos| = 1`, so both scored `0.0000°`. That is how an upside-down pose
|
A geometry height is not a controller coordinate. `motion/pose_move.py::PressAxis`
|
||||||
read as perfect, and it is why this document previously distrusted the solver's
|
holds the measured relation (`zero_mm` is the reading where the tool meets the
|
||||||
"100% within 0.5°".
|
table's zero plane, `rising` the drive direction) and turns a contact height into
|
||||||
|
an axis target only when it is marked `calibrated`. An uncalibrated press axis is
|
||||||
|
**held** — `press_targets` removes it from the move and reports the role as held —
|
||||||
|
rather than being commanded with a geometric number. The other four axes move
|
||||||
|
either way.
|
||||||
|
|
||||||
|
## History: a residual that measured the wrong aim
|
||||||
|
|
||||||
|
The previous residual was `acos(turned · tool_direction)`, where `tool_direction`
|
||||||
|
carried `approach_sign`. With the sign wrong, the aim itself pointed at `-Z`, so a
|
||||||
|
rotation putting the normal *on* that aim and one putting it on `+Z` were not
|
||||||
|
distinguished: the first scored `0.0000°` because it landed exactly where the
|
||||||
|
flipped aim asked. It is not that `arccos` cannot tell a direction from its
|
||||||
|
opposite — `acos(-1)` is `180°` — it is that the reference it measured against had
|
||||||
|
been inverted. An upside-down pose read as perfect, and that is why this document
|
||||||
|
previously distrusted the solver's "100% within 0.5°".
|
||||||
|
|
||||||
The angle itself is correct and is still what `residual_deg` reports; what was
|
The angle itself is correct and is still what `residual_deg` reports; what was
|
||||||
missing was `upright`, which now travels with every pose so the distinction cannot
|
missing was an aim the machine could trust plus `upright`, which now travels with
|
||||||
be lost. The lesson is recorded rather than deleted: a scalar that cannot express
|
every pose so the distinction cannot be lost. The lesson is recorded rather than
|
||||||
the failure mode it is used to detect will not detect it.
|
deleted: a scalar that cannot express the failure mode it is used to detect will
|
||||||
|
not detect it.
|
||||||
|
|
||||||
## Still open
|
## The two exact poses of one normal
|
||||||
|
|
||||||
**22 of 1032 vertices report not-upright** (16 by more than the tie band). These
|
Every solvable normal has two exact poses, and they are a **180° roll about the
|
||||||
are the rim of the dome, where the surface normal is 60–90° from the tool axis.
|
vertical**, not an upright/inverted pair: `R_branch2 = R_z(180°) @ R_branch1`
|
||||||
Aligning an edge-on face necessarily rolls the body about that normal, and which
|
agrees with the measured branches to `2.2e-16`. Both leave the body up (the same
|
||||||
side of zero it lands on is settled by rounding — the most negative uprightness is
|
tool-axis component), both reach the same contact height, and both put the contact
|
||||||
`-0.997` for a vertex whose normal is `90.4°` from the tool axis, i.e. a face being
|
back under the tool — the table XY differs, because the roll moves the point
|
||||||
presented side-on rather than turned over.
|
sideways and the table compensates.
|
||||||
|
|
||||||
They are not a solver defect: the closed form is exact for all of them
|
So the choice is a machine convention, and the solver states it instead of hiding
|
||||||
(`residual < 1.3e-06°`). Whether they matter is a coverage question — a contact
|
it: among the branches with the best residual (within `1e-6°`), it prefers one that
|
||||||
made on the rim is the least well-conditioned one on the patch anyway — and it is
|
is in range, then upright, then the smaller total travel `|yaw| + |pitch|`, and
|
||||||
worth a look at the bench rather than a change in the maths.
|
`Pose.branch_reason` reports which of those keys decided (`only`, `alignment`,
|
||||||
|
`in_range`, `upright`, `smallest`). An equivalent solution never wins on a
|
||||||
|
floating-point residue.
|
||||||
|
|
||||||
|
On the two bench screenshots this picks `yaw +36.42° / pitch −47.69°` on the right
|
||||||
|
and `yaw −61.32° / pitch +45.64°` on the left; the other exact pair is
|
||||||
|
`−143.58° / +47.69°` and `+118.68° / −45.64°`. A fixed approach sign on the rim
|
||||||
|
(which of the two the operator wants there) still needs the platform lead's rule —
|
||||||
|
it is not something the solver may invent, and it is not derivable from the UV
|
||||||
|
position. A contact made on the rim is the least well-conditioned one on the patch
|
||||||
|
anyway, so it is worth a look at the bench rather than a change in the maths.
|
||||||
|
|
||||||
The polarity question that used to live here is **settled**: see *the face is aimed
|
The polarity question that used to live here is **settled**: see *the face is aimed
|
||||||
at +Z* above.
|
at +Z* above.
|
||||||
@@ -212,7 +252,7 @@ normals, points = bundle.vertex_normals(), bundle.vertices_mm
|
|||||||
analytical = [stack.solve(p, n, method="analytical") for p, n in zip(points, normals)]
|
analytical = [stack.solve(p, n, method="analytical") for p, n in zip(points, normals)]
|
||||||
residuals = np.array([pose.residual_deg for pose in analytical])
|
residuals = np.array([pose.residual_deg for pose in analytical])
|
||||||
print(residuals.max()) # ~1.2e-06
|
print(residuals.max()) # ~1.2e-06
|
||||||
print(sum(pose.upright for pose in analytical), "/", len(analytical)) # 1010 / 1032
|
print(sum(pose.upright for pose in analytical), "/", len(analytical)) # 1032 / 1032
|
||||||
print(all(pose.in_range for pose in analytical)) # True
|
print(all(pose.in_range for pose in analytical)) # True
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Autocal 运动与多点标定修正计划
|
||||||
|
|
||||||
|
状态:任务 1–4 的软件部分已实施并继续复核(2026-10-14),任务 0 与任务 5 的实机部分待平台负责人参加。工程师证据保留在 `orisys-app/.scratch/autocal-motion-fix/verification.md`,接手后的结果见 `orisys-app/.scratch/autocal-motion-takeover/verification.md`。审查基准:`dc5a6f8`;接手 HEAD:`5339624`。本计划不表示实机错向已修复,整体范围沿用[阶段一计划](implementation-plan.zh.md),字段以[标定数据格式](calibration-data-format.zh.md)为准。
|
||||||
|
|
||||||
|
## 已知事实与待确认项
|
||||||
|
|
||||||
|
用户确认 yaw 台承载传感器,左侧主要表现为 pitch 方向不符合预期。当前链 `R_pitch @ R_yaw` 与这一叠放关系相符,不能据此交换矩阵顺序。执行层没有按测点左右反转 pitch,反号来自求解器选取不同的逆解分支。
|
||||||
|
|
||||||
|
截图显示值经舍入;以下为按三位小数法向重新归一化后的离线结果,不是实机测量:
|
||||||
|
|
||||||
|
| 测点 | sensor XYZ(mm) | sensor 法向 | 当前选解 yaw/pitch | 另一等价解 yaw/pitch |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 右 | 4.872, 5.444, 16.176 | 0.595, 0.673, 0.439 | +36.42° / −47.69° | −143.58° / +47.69° |
|
||||||
|
| 左 | −2.698, 5.186, 18.228 | −0.343, 0.699, 0.627 | −61.32° / +45.64° | +118.68° / −45.64° |
|
||||||
|
|
||||||
|
两组分支都能在现有模型中对准法向。只反转左点 pitch 会留下约 91° 误差。修正朝上判断也不保证默认选解变成负 pitch:必须另行确认平台允许的姿态分支。
|
||||||
|
|
||||||
|
| 已确认缺陷 | 位置与后果 |
|
||||||
|
|---|---|
|
||||||
|
| 朝上参考错误 | `motion/kinematics.py::uprightness` 用平台 Y 判断朝上,错误标记候选姿态 |
|
||||||
|
| 接触高度错误 | `PlatformStack.solve` 计算了旋转后的 `landed`,却把旋转前 Z 传给执行入口 |
|
||||||
|
| 姿态缓存过期 | `ui/multipoint_panel.py::_align` 对已有可达姿态不重新求解,安装或工具位置变化后可能执行旧目标 |
|
||||||
|
| 多点分析断开 | `session_review.py` 拒绝 `autocal-multipoint-1.0`,新采集不能进入既有复核界面 |
|
||||||
|
|
||||||
|
截图右点的 Y 为 +16.176,而当前零安装角、零 Y 偏移下应为 −16.176。先核对截图对应版本、当时设置与表格是否更新,不从这一异常直接推出轴翻转。
|
||||||
|
|
||||||
|
## 任务顺序与分工
|
||||||
|
|
||||||
|
建议一名熟悉 Autocal 的工程师负责软件,一名平台负责人参加坐标确认和实机验收。任务 1→2→3 是运动修正链;任务 4 在原始数据含义确认后可独立开展。当前不启动代理,也不包含自动力闭环或硬件驱动改写。
|
||||||
|
|
||||||
|
| 完成 | 任务 | 依赖 | 估计人时 |
|
||||||
|
|---|---|---|---:|
|
||||||
|
| [ ] | 0:固定坐标和实机约定 | 无 | 2–3 |
|
||||||
|
| [x] | 1:修正姿态判断和选解 | 0 | 4–6 |
|
||||||
|
| [x] | 2:统一接触高度与执行目标 | 0、1 | 4–6 |
|
||||||
|
| [x] | 3:消除旧姿态执行 | 1、2 | 3–4 |
|
||||||
|
| [x] | 4:接通多点记录复核 | 多点字段确认 | 6–8 |
|
||||||
|
| [ ] | 5:端到端验收和交接 | 1–4 | 3–4 |
|
||||||
|
|
||||||
|
软件和离线验证预计 22–31 人时,约单人 3–4 个工作日;额外预留 2–4 小时设备联调,不含硬件故障等待。估算需根据任务 0 的测量结果调整。
|
||||||
|
|
||||||
|
## 0:先固定坐标约定
|
||||||
|
|
||||||
|
记录传感器、yaw、pitch、平台 XY 和压头 Z 的零位与正方向,实际旋转中心、安装偏移、工具位置和当前驱动器映射。区分台体旋转轴和传感器自身坐标;不要用“看起来向左”代替正方向定义。实测显示模型缺少旋转中心偏移时,再补对应几何量。
|
||||||
|
|
||||||
|
交付一张坐标图、参数快照和左右点记录。每点保存 UV、XYZ、法向、两个候选角、选解理由、最终五轴目标、控制器反馈与照片。核对右点 Y 异常。实机动作须由平台负责人先明确设备、无接触间隙、角度/行程边界和停机条件;本计划本身不授权动作。
|
||||||
|
|
||||||
|
## 1:修正朝上判断和选解
|
||||||
|
|
||||||
|
修改 `motion/kinematics.py`,补充 `test_kinematics.py` 与 `test_ik_analytical.py`。
|
||||||
|
|
||||||
|
- [x] 朝上参考来自真正的安装后名义朝上方向,在同一坐标系中比较;默认映射下为平台 +Z,而不是平台 +Y。
|
||||||
|
- [x] 候选先满足方向、残差容差、行程和实机约定,再决定分支。等价解不因浮点残差的微小差异改变选择。若两解均允许,采用稳定且有说明的选择规则;需要固定接近姿态时,由平台负责人确认规则,不按 UV 左右硬编码符号。
|
||||||
|
- [x] 测试名义法向、左右截图、中心/边缘和行程边界。用独立旋转矩阵检查 `R @ n` 指向 +Z,角误差不大于 `1e-5°`,并验证平台朝上向量。截图角度允许因舍入与界面值相差 0.1°。
|
||||||
|
|
||||||
|
实施:`uprightness` 改用安装后名义朝上(工具轴)在同一坐标系比较;`select_branch` 依次取
|
||||||
|
`in_range`→`upright`→最小 `|yaw|+|pitch|`,`Pose.branch_reason` 报出依据,等价解不因残差改选;
|
||||||
|
rim 是否需要固定接近姿态仍属任务 0。证据:`test_kinematics.py` 的独立旋转与选解小节、
|
||||||
|
`.scratch/autocal-motion-fix/probe_motion_chain.txt`。
|
||||||
|
|
||||||
|
在界面显示所选分支及理由,无法满足约定时拒绝执行。不要仅让原测试保持通过;新回归须在审查基准上失败。
|
||||||
|
|
||||||
|
## 2:统一接触高度与执行目标
|
||||||
|
|
||||||
|
修改 `PlatformStack.solve` 和两个 UI 执行入口,补充 `test_kinematics.py`、`test_pose_move.py`。
|
||||||
|
|
||||||
|
- [x] 分开表示安装后、旋转前的点与最终接触点。XY 平移后,最终点应落在工具 XY;接触高度取旋转后坐标。默认偏移 `(0,0,8)` 下,两截图的旋转后 Z 约为 19.051 和 21.578 mm,不是 13.444 和 13.186 mm。
|
||||||
|
- [x] 明确接触高度到压头轴绝对目标的关系,包括机械零位和工具高度。只有该关系确认后才生成运动目标;不要把几何 Z 直接视为已标定的控制器零位。保留接触高度、无接触预定位和加载深度的不同含义。
|
||||||
|
- [x] 预览、目标表、确认框和执行计划使用同一求解结果。以非零安装偏移、安装角和工具 XY 测试,独立计算坐标误差应小于 `1e-6 mm`;实机误差阈值另由设备负责人确定。
|
||||||
|
|
||||||
|
实施:`Pose` 增加 `mounted_point_mm`/`contact_z_mm`/`contact_point_mm`/`table_xy_mm`;
|
||||||
|
`motion/pose_move.py::PressAxis`(`zero_mm`、`rising`、`calibrated`)把几何高度换算为压轴目标,
|
||||||
|
未标定时 `press_targets` 把该轴从动作中移除并报为保持;设备设置新增压轴页。证据:
|
||||||
|
`test_pose_move.py`、`test_device_setup.py`、`probe_motion_chain.txt`(两截图点 19.0510/21.5781)。
|
||||||
|
配置中的校准标志必须为布尔 `true`,零位必须有限,方向必须为布尔值;无效配置保持 Z。
|
||||||
|
|
||||||
|
更新[逆解说明](inverse-kinematics.md)中的旋转、高度和行程描述;修正其中把 `arccos` 称为正负方向不可区分的说法。不要把单纯改变多轴下发顺序当作修正机械旋转链。
|
||||||
|
|
||||||
|
## 3:执行前重新求解测点
|
||||||
|
|
||||||
|
修改 `ui/multipoint_panel.py` 和必要的 `SiteSpec` 状态,新增对应 UI 回归测试。
|
||||||
|
|
||||||
|
- [x] 每次对准前按当前几何和安装设置重新求解,而不是只看缓存的 `in_range`。优先直接复用现有快速求解器,暂不建立第二套缓存失效机制。
|
||||||
|
- [x] 几何版本变化时不能拿旧 UV 映射直接执行;旧 XYZ、法向和当前映射不一致时要求重新确认测点。新的目标须重新经过现有 `MotionService` 行程与连接检查。
|
||||||
|
- [x] 修改安装 X/Y/Z、安装角和工具位置后,确认框及实际提交的目标都更新;无解、越界或非有限值时不发送命令。记录当前求解参数及目标,保持期间不能对准其他点。
|
||||||
|
|
||||||
|
实施:`ui/multipoint_panel.py::_align` 每次都重新求解并重新写入缓存,测点或法向偏移超过
|
||||||
|
`0.05 mm`/`1e-3` 时报 `multipoint.point_moved`/`normal_moved` 要求重取,无可用来源报
|
||||||
|
`point_unmapped`。加载或保持期间拒绝对准;repeat 关闭后的换点阶段只允许对准待采测点。
|
||||||
|
日志位置来自轮询缓存,不为日志在调用线程额外读取控制器。证据:`test_multipoint_align.py`、`test_poller.py`。
|
||||||
|
|
||||||
|
数据记录留在 Autocal,纯几何计算留在 kinematics,轴单位和驱动映射留在 motion service。UI 不自行复制矩阵、反转轴符号或导入 Studio 私有模块。
|
||||||
|
|
||||||
|
## 4:接通多点数据复核
|
||||||
|
|
||||||
|
修改 `session_review.py` 的读取入口及需要的复核 UI;复用既有保持窗口统计、诊断和拟合能力,不另建一套分析器。
|
||||||
|
|
||||||
|
- [x] 将 `autocal-multipoint-1.0` 的实际目录、trial、参考窗口和录像索引转换为既有分析输入。先确认时间基准、力单位、模拟标记及帧来源;缺失字段显式报告,不补成有效零值。
|
||||||
|
- [x] 全新进程打开两个测点、每点两个 trial 的会话,显示采集覆盖、有效窗口、加载/卸载曲线和初步法向灵敏度。保持原有 finger 会话读取行为。
|
||||||
|
- [x] 未完成 trial、模拟数据、缺失视频或无效参考不得产生正式标定结论。分析结果写入 derived,原始文件哈希不变。增加完整、缺失、中止及原格式的回归样本。
|
||||||
|
|
||||||
|
实施:`review_session()` 按 `format_version` 分流到 `review_multipoint()`,复用窗口统计、拟合与
|
||||||
|
留一 trial 验证;缺 `capture/`、时间基准非 `host_camera`、repeat 未完成、窗口非法对应
|
||||||
|
`no_frame_source`/`time_basis_not_comparable`/`incomplete_trial`/`invalid reference window`,
|
||||||
|
`formal_conclusion` 恒为 false,结果只写 `derived/review_<时间>_<标识>/review.json`。格式与复核
|
||||||
|
输入契约见[多点标定会话格式](multipoint-data-format.zh.md)。证据:`test_session_review_multipoint.py`、
|
||||||
|
`.scratch/autocal-motion-fix/multipoint-review.png`、`multipoint-review-response.png`、
|
||||||
|
`multipoint-overview.png`。
|
||||||
|
复核读取实际的 `{u,v}`,时间曲线从 `capture/` 取信号;缺少对应视频、模拟或未完成 repeat
|
||||||
|
不进入拟合。已保存诊断原始信号仍可查看,原始记录不修改。
|
||||||
|
|
||||||
|
当前拟合仍为诊断结果。本任务不自动写回 SDK 标定系数,也不引入新采集格式;确有字段歧义时先更新数据契约并说明旧记录的解释限制。
|
||||||
|
|
||||||
|
## 5:验收与后续复查
|
||||||
|
|
||||||
|
检查点 A:任务 1–3 完成,先审查差异、离线几何证明和 mock 下发记录,再进入设备联调。检查点 B:任务 4–5 完成,从采集目录重开到图表的完整链通过后交接。
|
||||||
|
|
||||||
|
工程师提交以下证据供后续复查:
|
||||||
|
|
||||||
|
| 证据 | 要求 |
|
||||||
|
|---|---|
|
||||||
|
| 代码差异 | 基准/完成提交、修改清单、坐标与分支决策理由;未涉及的历史缺陷单独说明 |
|
||||||
|
| 回归 | 新测试在旧代码上的失败结果、修后结果、实际收集和跳过数量 |
|
||||||
|
| 运动链 | 两个截图点与至少三个边界点:输入→候选→选解→几何接触点→五轴目标→单位换算→mock 下发;不得通过实机运行测试自动化 |
|
||||||
|
| 实机 | 有授权的低速无接触核对,比较正负 pitch、非零 yaw 和零位;按已确定阈值记录位置、姿态与反馈,不用离线残差替代测量 |
|
||||||
|
| 数据 | 脱敏多点会话、录像/索引、重开截图、有效性理由和拟合结果;原始数据哈希 |
|
||||||
|
|
||||||
|
复查将重点检查独立预期值、分支选择、Z 零位关系、旧姿态、失败时是否仍下发,以及新旧数据是否都可复核。未提供设备证据时只能验收软件,不能宣布实机错向已修复。完成后提供提交或差异和证据目录;复查需由用户再次发起,不安排自动监控。
|
||||||
|
|
||||||
|
从 app 根运行首轮相关测试;每次使用新的 `--basetemp` 目录:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\orisys_ws\orisys-app
|
||||||
|
& .venv-build/Scripts/python.exe -m pytest apps/autocal/tests/test_kinematics.py apps/autocal/tests/test_ik_analytical.py apps/autocal/tests/test_pose_move.py apps/autocal/tests/test_multipoint.py apps/autocal/tests/test_multipoint_align.py apps/autocal/tests/test_session_review.py apps/autocal/tests/test_session_review_multipoint.py apps/autocal/tests/test_record_overview.py apps/autocal/tests/test_device_setup.py apps/autocal/tests/test_finger_panel.py -p no:cacheprovider --basetemp=.scratch/autocal-motion-fix/pytest-01
|
||||||
|
& .venv-build/Scripts/python.exe tools/lint_gate.py
|
||||||
|
```
|
||||||
|
|
||||||
|
新增 UI 回归加入上述命令;若修改共用录制入口,追加对应 shared record 测试。文档门禁使用工作区真实解释器,按 AUTHORING 执行,不通过扩大 baseline 获得通过。
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 运动单位校验与现有输出保持
|
||||||
|
|
||||||
|
用途:当前实现契约。范围:架构审查两项 P1 的保守修复。旋转标定比例、X 经验校正、坐标轴方向、姿态算法、UV 映射和原有运动目标保持原值;三项 P2 按用户要求暂缓。
|
||||||
|
|
||||||
|
## 配置、读回与执行
|
||||||
|
|
||||||
|
[`units.py`](../src/orisys_autocal/motion/units.py) 拥有物理单位换算。线性换算使用已有 `PULSES_PER_MM` 与每轴预期 `UNITS`;旋转保持轴 2 的 12500、轴 3 的 29500 用户单位/90°,并显式绑定测量时 `ROTARY_CALIBRATION_UNITS=10`。改变旋转轴当量后没有新标定,则角度换算返回 NaN,姿态移动被拒绝,不按推算的新比例驱动机器。
|
||||||
|
|
||||||
|
[`MotionService`](../src/orisys_autocal/motion/service.py) 的 `_params` 表示操作者配置的预期参数,`_last_axes` 表示控制器最近成功轮询的实际状态。二者不是可以互相覆盖的默认值。物理单位移动要求预期与读回当量为有限正数且一致;相对容差为 `1e-7`。没有读回、读取失败或不一致时明确拒绝。`to_mm/to_deg` 在这些条件不满足时返回 NaN,避免用错误比例判定到位。
|
||||||
|
|
||||||
|
修改轴参数时立即使旧读回失效,即使参数写入只完成一部分后失败,也不能沿用旧状态。下一次成功轮询恢复观测;程序不自动把实际值当成新标定,也不自动改写操作者配置。
|
||||||
|
|
||||||
|
[`PoseMover`](../src/orisys_autocal/motion/pose_move.py) 启动前检查计划中的轴编号、物理目标和已换算的用户单位目标仍对应当前配置。工作线程执行前再次读各目标轴的 `UNITS`,全部校验完成后才按原顺序发送原绝对目标。配置或角色映射在排队期间改变时,旧计划被拒绝,要求重新规划;不会偷偷套用新设置。
|
||||||
|
|
||||||
|
移动器按命令 token 等待工作线程确认成功,然后才用随后收到的位置快照判断到位或推进下一轴;等待期间仍检查报警及读取失败。提前收到的位置快照不缓存重放。对应命令被拒绝或发生异常时立即标记失败,其他命令的结果不解除等待;界面继续使用已有的移动失败处理和停止路径。
|
||||||
|
|
||||||
|
执行前增加的是只读参数检查,可能增加通信延迟,不保证与旧版完全相同的发送时刻。控制器被其他软件在最终读取和运动指令之间改动的并发窗口,本次也不能消除;操作期间仍需保证设备配置的单一写入者。
|
||||||
|
|
||||||
|
## 输出保持边界
|
||||||
|
|
||||||
|
| 已确认配置下保持的行为 | 新增的拒绝条件 |
|
||||||
|
|---|---|
|
||||||
|
| 原来的驱动轴编号、绝对目标和发送顺序 | 实际当量与预期不一致或无法读取 |
|
||||||
|
| 原有速度、加速度、对位与归零目标 | 旋转当量偏离已有标定条件 |
|
||||||
|
| X 的 `-10/11` 校正与 yaw/pitch 符号 | 排队后的轴映射或数值换算与原计划不符 |
|
||||||
|
| 原始用户单位的手动调试、jog、stop 与 home 接口 | 未对这些原始单位接口扩展物理单位保护 |
|
||||||
|
|
||||||
|
常驻读数和轴调试页的旋转轴毫米显示属于 P2,本次不修改。姿态实际角度显示会显式携带读回当量,未校准时不显示成有效角度。连续参考力录制及接入恒力执行器也不属于本次修复。
|
||||||
|
|
||||||
|
## 验证与限制
|
||||||
|
|
||||||
|
回归测试见 [`test_motion_unit_guards.py`](../tests/test_motion_unit_guards.py)。它覆盖既有五轴命令的确定数值和顺序、配置一致的线性换算、异常读回、旋转标定条件、排队期间配置改变、读取失败时整组零发送,以及原始单位调试移动的兼容性。另覆盖分步/整组的快照先于命令结果、拒绝及异常、token 匹配、逐轴确认与等待期间故障。现有测试夹具补充真实流程所需的单位观测和成功回调,没有降低断言或放宽限位。
|
||||||
|
|
||||||
|
修改前从本地 design 导出保存 1032 顶点,使用默认安装与当前偏好配置,共 2064 组姿态和规划目标。修改后逐元素严格相等,最大差值为 0;使用修改前源码与当前源码在记录型 DemoTransport 上分别执行相同计划,产生的 8256 条绝对运动调用也完全一致。这验证软件输出保持,不构成实机轨迹或物理接触精度证明。
|
||||||
|
|
||||||
|
首次单位防护验证为 136 项通过,Autocal 全套为 720 项通过、3 项失败。后续审查发现位置快照可能先于命令结果,提前误报成功;2026-10-18 增加上述确认门槛后,相关检查 164 项通过,原实现被新增测试检出 7 项失败。全套结果及限制见[架构审查](../../../../docs/workflow/autocal-architecture-review.zh.md)。证据位于 app `.scratch/autocal-unit-guards/` 与 `.scratch/autocal-command-ack/`,不属于长期归档。
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Autocal 多点标定会话格式(`autocal-multipoint-1.0`)
|
||||||
|
|
||||||
|
状态:2026-10-17 记录格式、只读复核、录像完整性判定与操作员汇总已接通,仅软件验证,待实机验收。坐标与载荷约定、finger
|
||||||
|
会话格式见[标定操作与数据格式](calibration-data-format.zh.md);姿态与接触高度见
|
||||||
|
[逆运动学说明](inverse-kinematics.md);运动边界与停机见[操作流程](workflow.md)。本页不会自动移动平台。
|
||||||
|
|
||||||
|
## 目录布局
|
||||||
|
|
||||||
|
多点标定按测点组织,不按 trial 平铺:每个测点重复若干次,每次一个 repeat 目录。
|
||||||
|
|
||||||
|
```text
|
||||||
|
sites/<site_id>/repeat-00N/
|
||||||
|
session.json
|
||||||
|
reference.csv
|
||||||
|
events.csv
|
||||||
|
capture/ (接了相机时才有)
|
||||||
|
session.json
|
||||||
|
frames.csv
|
||||||
|
forces.csv
|
||||||
|
raw_0001.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 根 `session.json` | `format_version=autocal-multipoint-1.0`、session_id、型号/SN、`coordinate_frame`、`force_to_tool_rotation`、`normal_projection`、力范围、`sync`、sites、trials、`completion`、`status`/`reason`;`platform_motion` 与 `automatic_force_control` 均为 false |
|
||||||
|
| 根 `session.json` 的 `recording` 块 | `video`、`codec`、`lossless_frames`;建立会话时写入并锁定,后续 repeat 不改写;缺该块的旧会话按 `video=true` 读取 |
|
||||||
|
| site 记录 | `site_id`、`position`/`position_frame`、`normal_sensor`、`pose`、`uv_px`、`levels_n`、`hold_s`、`repeats`;`pose` 为空或 `in_range=false` 的点只保存、不下发 |
|
||||||
|
| repeat `session.json` | `trial_id`、site、`pose`、`uv_px`、`steps[{step_index, branch, target_load_n}]`、`sync`、`images`、`status`;录像收尾后另有 `capture_status`、`capture_reason`、`capture_report`(共享记录的统计与问题清单) |
|
||||||
|
| `reference.csv` | 每个实际开始的保持窗口一行:`step_index`、`branch`、`target_load_n`、`t_start_host_mono_ns`、`t_end_host_mono_ns`、`F_ref_normal_N`、site 坐标与坐标系、`repeat_index`/`repeat_count`、`hold_complete`、`reference_source`、`fx_N`/`fy_N`/`fz_N`、力到工具的旋转、`nx`/`ny`/`nz`、`pose_*`、`normal_aligned_to` |
|
||||||
|
| `events.csv` | 电脑单调时间、UTC、事件类型与明细 |
|
||||||
|
| `capture/` | 共享记录器的录像会话(`session.json`、`frames.csv`、`forces.csv`、`raw_*.mp4`);模拟运行或未接相机时不产生,原因记在该 repeat 的 `images.reason` |
|
||||||
|
|
||||||
|
## 取值约定
|
||||||
|
|
||||||
|
| 项目 | 约定 |
|
||||||
|
|---|---|
|
||||||
|
| branch | 零载窗口写 `baseline`,其余为 `load`/`unload`,位次由 `load_steps()` 决定;没有 `stability_verified` 列,该标志只由复核时选中的子区间产生 |
|
||||||
|
| 参考力 | 称重模式是操作员输入的法向力;六轴模式是 `F_ref_normal_N = (R @ F) · tool_axis`,`R` 为本次会话输入力到工具的旋转。两者都是人工声明的参考,不是独立测量,也不能替代 FT |
|
||||||
|
| 时间关联 | 帧属于某个保持窗口,当且仅当它的 `t_host_mono_ns` 落在 `[t_start_host_mono_ns, t_end_host_mono_ns)`;不按行号配对 |
|
||||||
|
| 帧—力关联 | `forces.csv` 没有采集时间,只有 `frame_id`;两文件按 `frame_id` 关联,重复的帧号没有唯一时间,其样本被排除并记 `duplicate_frame_identity`,不按行号回退 |
|
||||||
|
| 归属 | 当前 repeat 先写入根清单与自己的 `session.json`,再以显式 trial 身份启动录像;启动失败不得沿用上一 repeat 的目录,也不得用 `trials[-1]` 指代新 repeat |
|
||||||
|
| 来源会话 | 一个 repeat 只接受启动时那个采集会话的帧。相机在录制中重启会换会话并复用帧号,因此写入被拒绝、repeat 判为 `incomplete`(`source_restarted`),需要新的 trial |
|
||||||
|
| 姿态 | `pose` 是求解结果快照(含 `in_range`、`upright`、`residual_deg`、`branch_reason`);执行前按当前安装设置重新求解,记录值只作对照 |
|
||||||
|
| 目录名 | 会话目录带型号、SN、时间与唯一标识;`repeat-00N` 编号同点重复与中止重试 |
|
||||||
|
|
||||||
|
## 录像收尾与完整性
|
||||||
|
|
||||||
|
停止时先摘除 sink,再异步排空;Qt 定时器轮询 `Campaign.poll()`,收尾期间界面仍可响应。终态由
|
||||||
|
`capture_guard.capture_verdict()` 判定,只有同时满足写入帧数大于零、录像帧数大于零、单分段、无队列
|
||||||
|
丢帧、视频首帧可解码、未中止且来源未重启时才是 `complete`;否则写 `incomplete` 并保留原因码
|
||||||
|
(`zero_frames`、`write_error`、`video_missing`、`video_undecodable`、`segment_count`、
|
||||||
|
`dropped_data`、`dropped_video`、`source_restarted`、`closing_timeout`、`aborted`)。
|
||||||
|
收尾超时保持 `closing_timeout`,不复用仍在写入的目录;旧记录没有 `capture_status` 时读取端记为
|
||||||
|
unknown,不把 `images.captured=true` 当成录像已验证。
|
||||||
|
|
||||||
|
## 只读复核输入
|
||||||
|
|
||||||
|
`session_review.review_session()` 按根清单的 `format_version` 分流,多点会话走
|
||||||
|
`review_multipoint()`:读取每个 repeat 的声明窗口,并把 `capture/forces.csv` 的 `FN_raw` 按
|
||||||
|
`frame_id` 与 `t_host_mono_ns` 关联成实测样本后,复用与 finger 会话相同的窗口统计、线性拟合与
|
||||||
|
留一 trial 验证。
|
||||||
|
|
||||||
|
| 情况 | 复核行为 |
|
||||||
|
|---|---|
|
||||||
|
| 缺 `capture/`(模拟运行、未接相机) | 记 `no_frame_source`,窗口标同名状态,样本数为 0、均值为空,不生成拟合 |
|
||||||
|
| 对应视频分段缺失、为空,或没有保存到录像的帧 | 记 `no_frame_source` 并说明原因;不生成拟合 |
|
||||||
|
| 模拟会话即使存在帧文件 | 标 `simulation`,仅供诊断,不参与拟合 |
|
||||||
|
| 帧时间基准不是 `host_camera` | 记 `time_basis_not_comparable`,该 repeat 的窗口不参与计算 |
|
||||||
|
| repeat 未完成 | 记 `incomplete_trial`,只展示采集与缺失文件,不参与拟合 |
|
||||||
|
| 窗口起止非法(终点不晚于起点、非有限值、参考力超出会话声明的最大力) | 抛 `invalid reference window`,不静默跳过 |
|
||||||
|
| 人工选中的子区间 | 必须落在记录窗口内,`stability_verified` 由选中决定;窗口外选择被拒绝 |
|
||||||
|
|
||||||
|
多点复核的 `formal_conclusion` 恒为 `false`:参考力是人工声明,帧来源可能缺失,格式里也没有独立
|
||||||
|
验证。结果只写 `derived/review_<时间>_<标识>/review.json`,原始文件哈希不变;未接入 FT/ECAT
|
||||||
|
前不写回 SDK 系数,也不引入第二种采集格式。
|
||||||
|
|
||||||
|
## 操作员汇总
|
||||||
|
|
||||||
|
`summary_export.publish_summary()` 在 `derived/summary_<时间>_<标识>/` 写 `summary.html`、
|
||||||
|
`summary.json`、`baseline_load_data.csv`、`sensitivity_values.csv`,再原子替换根目录的
|
||||||
|
`summary-latest.json`(相对路径、`summary_id`、输入与文件哈希)。采集终态自动发布候选汇总;人工
|
||||||
|
复核保存后再发布已复核汇总;失败保留旧指针并可重试,采集结果不变。
|
||||||
|
|
||||||
|
指针替换成功后,同一份四件文件镜像到会话根 `summary/`:先写临时目录再替换目录;副本失败只在界面/日志报告,指针仍指向 `derived/` 中的已发布版本,采集结果不变。
|
||||||
|
|
||||||
|
两张表都带 `schema_version`、`summary_id` 与来源文件:
|
||||||
|
|
||||||
|
| 表 | 行单位 | 关键字段 |
|
||||||
|
|---|---|---|
|
||||||
|
| `baseline_load_data.csv` | 每个实际保持尝试(含被重测废弃的) | `session_id`、`trial_id`、`hold_id`(`reference.csv` 数据行序号)、`step_index`、`branch`、`reference_load_n`、`window_method`、样本计数、三轴 raw 均值与标准差、`trial_status`、`capture_status`、`review_status`、`eligible`、`excluded_reason` |
|
||||||
|
| `sensitivity_values.csv` | 每点、每 trial、每轴、每审核集合 | `method=raw_on_reference_ols_v1`,`slope_raw_per_N`、`intercept_raw`、`R2`、`rmse_raw`、`endpoint_raw_per_N`、`initial_step_raw`、参考力范围、`window_count`、`status` |
|
||||||
|
|
||||||
|
默认候选窗口取保持后半段(`hold_second_half_v1`),人工确认后取复核区间的精确起止
|
||||||
|
(`review_selection_v1`);从不自动标记稳定。`stable_means.csv` 保留原列与过滤方法,只把帧—力
|
||||||
|
关联改为按 `frame_id`;因此重复帧号的样本不再参与旧表均值,其余数值规则不变。
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# 录像和操作员汇总数据契约提案
|
||||||
|
|
||||||
|
状态:2026-10-17 已按本契约实现,仅软件验证。属于[实施计划](recording-summary-plan.zh.md);既有采集字段仍以[单次格式](calibration-data-format.zh.md)及[多点格式](multipoint-data-format.zh.md)为准。本页描述的是当前行为,`formal_conclusion` 仍恒为 false。
|
||||||
|
|
||||||
|
## 文件组织
|
||||||
|
|
||||||
|
```text
|
||||||
|
<model>_<SN>_<UTC>_<uuid>/
|
||||||
|
session.json
|
||||||
|
stable_means.csv # 多点既有兼容输出
|
||||||
|
trials/trial-001/ # 单次格式,保持不变
|
||||||
|
session.json
|
||||||
|
raw_0001.mp4
|
||||||
|
frames.csv
|
||||||
|
forces.csv
|
||||||
|
reference.csv
|
||||||
|
events.csv
|
||||||
|
sites/<site_id>/repeat-001/ # 多点格式,与 trials 二选一
|
||||||
|
session.json
|
||||||
|
reference.csv
|
||||||
|
events.csv
|
||||||
|
capture/
|
||||||
|
session.json
|
||||||
|
raw_0001.mp4
|
||||||
|
frames.csv
|
||||||
|
forces.csv
|
||||||
|
derived/
|
||||||
|
review_<UTC>_<uuid>/ # 既有 review 快照
|
||||||
|
review.json
|
||||||
|
overview.json # 现有流程有生成时保留
|
||||||
|
baseline_<trial>.png
|
||||||
|
summary_<UTC>_<uuid>/
|
||||||
|
summary.json
|
||||||
|
summary.html
|
||||||
|
baseline_load_data.csv
|
||||||
|
sensitivity_values.csv
|
||||||
|
summary/ # 最近一次发布的可读副本(四件文件),非权威
|
||||||
|
summary.json
|
||||||
|
summary.html
|
||||||
|
baseline_load_data.csv
|
||||||
|
sensitivity_values.csv
|
||||||
|
summary-latest.json # 最后成功发布的相对路径指针
|
||||||
|
```
|
||||||
|
|
||||||
|
不建立第二份录像,不改变两种格式的原始目录。新汇总沿用样例文件名,但明确版本化字段;旧 `stable_means.csv` 保留原接口,不将新表悄悄写成旧表。新读取器缺少指针时仍可打开旧会话,绝不通过修改旧 manifest 补造证据。
|
||||||
|
|
||||||
|
导出先在唯一临时目录写齐并验证,再发布同盘目录,最后原子替换指针。失败保留旧指针,报告导出失败,不回滚采集状态。指针使用相对路径、summary_id 和内容哈希;并发发布由会话级单一任务串行化,取消后的旧任务不能更新指针(取消由调用方传入,界面暂未提供取消按钮)。原始文件、历史 review 和已发布 summary 均不覆盖。
|
||||||
|
|
||||||
|
指针替换后镜像同一份四件文件到根 `summary/`(先写临时目录再替换);副本失败只报告,不回滚指针、不改采集结果。
|
||||||
|
|
||||||
|
## 录像身份、时间和生命周期
|
||||||
|
|
||||||
|
逻辑身份使用 `(calibration_session_id, trial_id, capture_session_id, frame_id)`;保持窗口再加 `step_index`。共享 recorder 的整数 session_id 不等于标定 session_id。同试验来源重启不得静默复用 frame_id;中止并新建 trial。重复身份、行数不符或缺关联必须报错/排除,不能退回按行号拼接。
|
||||||
|
|
||||||
|
帧和力按采集 session、frame_id 关联;帧提供 `t_host_mono_ns`、UTC、time_basis 及 `segment/segment_frame`。仅同一次实时采集的 `host_camera` 可与保持窗口 `[start,end)` 比较。UTC 只追溯;`host_replay` 不提供原实验同步,`host_fallback` 标未验证。相机内丢帧与写盘丢帧分别记录。
|
||||||
|
|
||||||
|
定位窗口时选择范围内首个已入视频的有效帧;无帧就显示缺失,不跳到窗口外假装有证据。播放与曲线按已保存索引关联,可见时间跳跃;媒体 FPS 只影响容器播放,不用于实验时间换算。关闭/切换 trial 释放 decoder;后台结果以请求代次过滤。
|
||||||
|
|
||||||
|
```text
|
||||||
|
idle → starting → recording → closing → complete
|
||||||
|
└失败──────┴失败/中止───────→ incomplete
|
||||||
|
```
|
||||||
|
|
||||||
|
先创建当前 trial 清单,再以显式 trial 引用启动 recorder;禁止用 `trials[-1]` 隐式归属。首个可写入帧及 writer 正常后才允许保持确认。启动失败卸载 sink 并结束 writer;每次 start/stop 清理本会话来源引用,simulation 不继承 live worker。
|
||||||
|
|
||||||
|
停止时先移除 sink,再异步排空;到终态后校验 report、帧索引和视频可读性,再更新 repeat 和根清单。步骤完成、录像完成分别保存。至少零帧、写入错误、录像/数据丢失、不可解码、来源停止均阻止“证据完整”;相机来源丢帧单列警告,是否影响实验可用性保留待定,不伪造精度阈值。超时保持 closing/incomplete,不复用仍在写入的目录。
|
||||||
|
|
||||||
|
建议为多点 manifest 增加 `capture_status`、`capture_reason`、`capture_report`;只增加可选字段,读取器缺省为 unknown。不要把旧的 `images.captured=true` 当成录像验证成功。版本升级仅在旧读取器实际不兼容时实施并加兼容测试。
|
||||||
|
|
||||||
|
## 窗口均值表
|
||||||
|
|
||||||
|
`baseline_load_data.csv` 每个实际保持尝试一行;重测不可覆盖前次,使用 trial 内 `hold_id` 区分相同 step。关联键为 session_id、trial_id、hold_id;point 标签只用于展示。映射哈希+UV 校验点身份,缺哈希时保留来源命名空间,不按 UV 猜测跨 session 同一点。
|
||||||
|
|
||||||
|
旧格式的 hold_id 由 reference 文件内数据行序号派生,并与该文件哈希共同固定身份;不回写采集文件。人工裁剪保留 hold_id,只改变选择区间。必须保留原始行号再过滤,不能过滤后重新编号。
|
||||||
|
|
||||||
|
| 字段组 | 字段与含义 |
|
||||||
|
|---|---|
|
||||||
|
| 来源 | `schema_version, summary_id, session_id, trial_id, hold_id, site_id, point, repeat_index, step_index, source_file`;路径相对会话根 |
|
||||||
|
| 位置 | `U,V,mapping_sha256`;缺位置为空,不补零 |
|
||||||
|
| 载荷 | `reference_source,branch,target_load_n,reference_load_n`;实际参考和目标分开,baseline 标准化为 zero |
|
||||||
|
| 时间 | `t_start_host_mono_ns,t_end_host_mono_ns,time_basis,window_method,stability_verified` |
|
||||||
|
| 样本 | `samples_total,samples_valid,samples_excluded` 及每轴实际参与统计数;单独统计无效、未关联及无视频帧 |
|
||||||
|
| 结果 | `FN_mean,FX_mean,FY_mean,FN_std,FX_std,FY_std,unit`;raw 与 N 不混合 |
|
||||||
|
| 状态 | `trial_status,capture_status,review_status,eligible,excluded_reason`;不完整也列出,防止静默遗漏 |
|
||||||
|
|
||||||
|
默认自动输出候选后半保持窗口,`window_method=hold_second_half_v1`,不自动认为稳定。人工确认后使用现有 selections 的精确区间,`window_method=review_selection_v1`。统计默认有限且有效的原始分量,样本标准差 ddof=1;不足两样本为空。新表不默默继承旧表按轴去异常值;若以后支持过滤,须另给版本、参数和每轴计数。
|
||||||
|
|
||||||
|
候选与已确认数据不混合拟合;同一 trial 的被重测废弃保持段列出原因且排除。正式统计只用完整 trial、可比较时间及明确参考的窗口,缺失数值写空单元格/JSON null。
|
||||||
|
|
||||||
|
## 灵敏度表与报告
|
||||||
|
|
||||||
|
`sensitivity_values.csv` 每点每 trial 每轴每种审核集合一行;纵向结构允许缺轴。含身份、`component,review_status,method,slope_raw_per_N,intercept_raw,R2,rmse_raw,endpoint_raw_per_N,initial_step_raw,reference_min_N,reference_max_N,window_count,status,reason`。导出保持双精度,不沿用旧表六位有效数字作为计算输入。
|
||||||
|
|
||||||
|
方法固定为 `raw_on_reference_ols_v1`:以实际参考 N 为 x,原始窗口均值为 y,在同一 trial 内用初始零载和加载窗口等权拟合 `y=a*x+b`。卸载单独展示,不进入该拟合。至少两个不同参考力,否则 unavailable;不跨 trial 混合不同零点。
|
||||||
|
|
||||||
|
`sensitivity_summary` 按测点、分量和 `review_status` 分组,分别汇总候选/已确认 trial 斜率的均值与样本标准差,`trials` 统计唯一 trial_id;不混合两种审核集合、不计算联合拟合或平均 R²。
|
||||||
|
|
||||||
|
R² 为 `1-SSE/SST`;常量信号 SST=0 时为空并标记 flat_signal,零斜率不反推增益。endpoint 每 trial 用最高加载与初始零载的差除以参考力差;initial_step 为最低正加载均值减初始零载。跨 trial 只汇总这些量的均值及数量,缺配对或分母为零为空;不跨 trial 拼端点。
|
||||||
|
|
||||||
|
现有 `_fit()` 是 `N=gain*raw+offset`,其 `1/gain` 与上述正向 OLS 不一般等价。旧审核拟合不更名、不覆盖;新表明确方法,FN/FX/FY 对法向输入的响应不称为三轴力标定。高 R² 不代表物理精度或独立验证。
|
||||||
|
|
||||||
|
`summary.json` 保存版本、源文件 SHA256、review 相对路径/selections 哈希、软件版本、生成 UTC、统计方法、单位、缺失原因和全部表格结果;`formal_conclusion=false`。已复核汇总引用既有 review,不复制一套审核状态;自动候选无需预先创建 review。保存时重验输入哈希,变化则拒绝发布。HTML 只呈现同一份对象,转义操作员输入和路径,无外部网络依赖。
|
||||||
|
|
||||||
|
报告首屏显示型号/SN、完成与缺失点数、录像完整性、待复核窗口和导出状态;随后是逐点响应、三分量灵敏度、既有重复性/滞后/留一 trial 验证和证据链接。没有独立验证时明确显示不可用。停止或中止也生成失败摘要;复核变化生成新版本,不改原始数据。
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Autocal 录像复核与汇总实施计划
|
||||||
|
|
||||||
|
状态:2026-10-17 已实施,仅软件验证,待实机验收。范围不变:补齐录像可靠性、按事件回放和操作员汇总;不改变运动控制,不部署标定系数。实现落在 `frames_index.py`、`capture_guard.py`、`summary.py`、`summary_export.py`、`video_review.py` 与相应界面,详细字段见[数据契约](recording-summary-contract.zh.md),执行与验证结果见[任务分工](recording-summary-tasks.zh.md)。本计划补充[原实施计划](implementation-plan.zh.md),离线测试全绿不代表硬件验收。
|
||||||
|
|
||||||
|
## 现状与问题
|
||||||
|
|
||||||
|
调查基线:app `main`、HEAD `2cec3d905b193670e3de5df5f2de6dd4b1e8e3d7`。结论来自源码和样例 CSV,未运行相机、平台或故障复现。工作树已有 `motion/service.py`、`record_diagnostics.py` 和测试计划改动,实施前须重新核对,不能覆盖。
|
||||||
|
|
||||||
|
| 证据位置(相对应用源码目录) | 已有能力及缺口 |
|
||||||
|
|---|---|
|
||||||
|
| `trial.py: ManualTrial.start/finish/poll` | 已接共享录像器,单 trial 连续录像;异步排空及完整性判断已有模式可复用 |
|
||||||
|
| `multipoint.py: _start_images/_release_images/_close_repeat` | 已录 `capture/raw_*.mp4`,但停止时未采用 recorder report 判断 repeat 完整性 |
|
||||||
|
| `multipoint.py: _open_repeat/_start_images` | 后续 repeat 在加入清单前启动录像,随后以 `trials[-1]` 写关联,可能更新前一 repeat |
|
||||||
|
| `multipoint.py: start` | 会话重新开始未清除待连接 worker,存在旧来源跨会话继承风险;需回归证明 |
|
||||||
|
| `ui/multipoint_panel.py: _poll` | 当前主要处理保持倒计时,缺少与单 trial 等价的来源/录像失败检查 |
|
||||||
|
| `multipoint.py: stable_component_rows` | 已有 `stable_means.csv`,采用保持后半段、分量去异常值;帧与力按过滤后的行序关联,须改成明确身份校验 |
|
||||||
|
| `session_review.py`、`record_overview.py` | 已有保持段复核、FN 拟合及质量统计,复用其审核状态;缺少直接的视频复核入口和统一三分量导出 |
|
||||||
|
|
||||||
|
因此首要任务是让现有录像可验证、可发现、可回放,而非再写一套录制器。用户现场的缺录像原因仍待核对实际输出和运行版本;上述静态缺口不能直接证明现场原因。
|
||||||
|
|
||||||
|
## 参考样例与设计选择
|
||||||
|
|
||||||
|
`D:/orisys_ws/data/dd02-gl` 包含一个 MP4 和两张 CSV。`baseline_load_data.csv` 有 60 行,15 个 UV 点各有 0/1/2/3 N 四档,均为 repeat 1;`sensitivity_values.csv` 有 15 行。只有 11 个不同 site_id,多个来源重复使用编号,不能按 site_id 单独合并。
|
||||||
|
|
||||||
|
样例表缺少原始窗口时间、帧索引、配置/映射身份和完整来源文件,不能反推出与该 MP4 的同步关系,也不能证明这些窗口已经人工复核。只借鉴“窗口均值表+逐点灵敏度表”的操作方式,不把历史样例自动升级为已验证标定。
|
||||||
|
|
||||||
|
方案默认录传感器原始输入画面,不录桌面或第二台外部相机;现有 MP4 有损编码不称为无损原始图像。每 trial/repeat 一条逻辑录像,含零载、换档、加载、卸载和恢复;保持段不另建视频。跨测点间的对准等待不承诺录像。
|
||||||
|
|
||||||
|
首期保留当前 24 小时分段设置及单 trial 完整性约束,读取端支持已有分段索引。若以后改成短分段,必须同时修改 `ManualTrial.poll()` 的单段限制并验证跨段行为,不能只改录制参数。
|
||||||
|
|
||||||
|
保留现有两种采集目录和共享格式,新增可重建的汇总目录;不要移动、删除或复制原始视频来追求目录整洁。旧记录按原样读取,缺视频显示“无录像证据”。
|
||||||
|
|
||||||
|
## 数据流和界面
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[相机与采集时间] --> B[FingerAcquisitionWorker / SdkPacket]
|
||||||
|
B --> C[有界队列 / ExperimentRecorder]
|
||||||
|
B --> D[实时预览]
|
||||||
|
C --> E[视频 / frames / forces]
|
||||||
|
F[人工参考与保持事件] --> G[reference / events / session]
|
||||||
|
E --> H[只读身份与完整性检查]
|
||||||
|
G --> H
|
||||||
|
H --> I[保持段选择与视频回放]
|
||||||
|
I --> J[窗口统计与灵敏度]
|
||||||
|
J --> K[summary.html / 两份 CSV / summary.json]
|
||||||
|
```
|
||||||
|
|
||||||
|
采集线程只提交数据,不编码、不生成报告。写盘线程负责录像;Qt 线程管理状态和呈现。回放解码、校验和导出在后台执行,通过现有 `ReviewJob` 或专属解码任务返回结果;旧任务结果不得覆盖用户新选的 trial。
|
||||||
|
|
||||||
|
采集页展示“未就绪/录制中/收尾中/完整/证据不完整”、实际写入帧数、丢帧、时长和保存位置。开始保持必须等录像就绪;完成按钮进入收尾,不立即报成功。故障通知操作员停止加载并保存未完成证据,不新增自动运动。
|
||||||
|
|
||||||
|
记录总览继续作为唯一入口。点选测点、repeat、保持段即可播放对应画面,支持播放/暂停、逐帧、定位起止点;同步显示原始力曲线、实际参考力和有效状态。按帧索引定位,不用播放器秒数推算实际采集时间。采集中可见录像状态,回放首期只开放已收尾的 trial,避免同时读取未封口容器。
|
||||||
|
|
||||||
|
结束整场后自动生成候选汇总;人工复核保存后生成新的已复核汇总。操作员默认只看一个报告及两张表,原始目录通过“查看原始证据”展开。报告区分采集完整性、稳定段确认和计算可用性,不输出未经定义的合格/不合格结论。
|
||||||
|
|
||||||
|
## 实施与交付顺序
|
||||||
|
|
||||||
|
1. 先用两个连续 repeat、连续两个 session、live 后 simulation 证明录像归属和生命周期缺口,并锁定新增契约。
|
||||||
|
2. 修正现有录像收尾、失败传播和 UI 状态,以合成相机完成两点两次采集闭环。
|
||||||
|
3. 并行开发只读回放和汇总计算,再串入同一记录总览;禁止跨应用导入 Studio 私有控制器。
|
||||||
|
4. 增加自动汇总、原子发布和旧记录兼容;最后执行软件回归及独立人工复核。
|
||||||
|
|
||||||
|
粗估为 10–15 个工程人日,最多三名实现子代理并行时约 5–8 个工作日;这是排期估计,不是 DeepSeek 运行时承诺,不含设备等待。主代理负责共享文件、接口冻结和集成。
|
||||||
|
|
||||||
|
## 验收与停止条件
|
||||||
|
|
||||||
|
| 场景 | 必须得到的结果 |
|
||||||
|
|---|---|
|
||||||
|
| 两点各两次,含中止重试 | 四次正常试验各指向自己的录像;中止另占身份,不覆盖文件 |
|
||||||
|
| 断流、写盘失败、编码失败、队列溢出 | 不再报采集完整,仍可打开残存数据;界面不因排空阻塞 |
|
||||||
|
| 点击一个已确认窗口 | 定位其首/末有效帧,曲线、参考和视频使用同一采集时间及 trial 身份 |
|
||||||
|
| 两种现有格式与历史缺视频记录 | 可读且保留缺失原因;不伪造帧或稳定性 |
|
||||||
|
| 汇总重算 | 数值可由输入及窗口选择复算,结果不修改原文件;无有效数据保持空值和原因 |
|
||||||
|
| 操作员交付 | 一个报告链接能找到全部测点、失败原因、两张 CSV 和原始证据 |
|
||||||
|
|
||||||
|
软件完成必须通过任务清单中的测试、文档门禁和无硬件操作演示;相机吞吐、磁盘容量、驱动延迟和视频可解码性另做实机测量。真正加载实验需明确设备、载荷/运动边界和停止条件后执行。
|
||||||
|
|
||||||
|
本期不做 FT 同步、自动加载、批量跨 session 合并、历史视频补时间、原始数据清理或算法状态精确重演。录像支持视觉复核,不能保证有状态算法从全新进程逐帧复现。
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# DeepSeek 子代理执行清单
|
||||||
|
|
||||||
|
状态:2026-10-17 已实施,仅软件验证,待实机验收。本清单与[主计划](recording-summary-plan.zh.md)、[数据契约](recording-summary-contract.zh.md)配套;下面的复选框记录实际完成情况,末尾是本次真实执行结果。
|
||||||
|
|
||||||
|
## 主代理指令
|
||||||
|
|
||||||
|
先读工作区及作用域 AGENTS、工程约束、上述两份计划。重新检查 app 分支、HEAD、index、工作树;保护已有 `motion/service.py`、`record_diagnostics.py`、`docs/test-execution-plan.zh.md` 及根文档改动。只实现录像证据与汇总,不运行真实控制器或加载,不提交或推送。缺少现场复现不能宣称已修复现场原因。
|
||||||
|
|
||||||
|
每项先读列出的代码、写能区分错误行为的测试,再做最小改动。任务结束交回:文件清单、验证命令和结果、未验证项、接口变化。新测试临时文件统一放 `.scratch/autocal-recording-summary/`;运行期间保留 `.active`,测试使用唯一 basetemp。不得复制依赖仓库代码或另建录像引擎。
|
||||||
|
|
||||||
|
主代理拥有接口签名、公共测试夹具、`multipoint.py`、`session_review.py` 和两份 locale 文件的最终合并权。并行任务对同一文件的修改必须串行交接,不能各自覆盖。建议最多三个实现子代理;先完成依赖再分派,不让代理空等或自行更改契约。
|
||||||
|
|
||||||
|
## 执行波次
|
||||||
|
|
||||||
|
```text
|
||||||
|
T0 契约与回归夹具
|
||||||
|
→ A1 录像归属 → A2 异步结束与故障 → A3 采集状态界面
|
||||||
|
→ B1 身份与窗口统计 → B2 灵敏度 → B3 汇总文件发布
|
||||||
|
→ C1 帧定位与回放 → C2 记录总览接入
|
||||||
|
A3 + B3 + C2 → D1 自动导出和兼容 → D2 独立验收
|
||||||
|
```
|
||||||
|
|
||||||
|
A、B、C 在 T0 后可并行,但 `multipoint.py` 的改动优先归 A;B 先实现独立纯函数,新摘要接入由 D1 串行完成。C1 只用只读夹具,不等待真实录制修复。每个任务建议控制在 2–5 个文件。
|
||||||
|
|
||||||
|
## T0:冻结可测试接口(主代理,0.5–1 日)
|
||||||
|
|
||||||
|
- [ ] 读 `trial.py`、`multipoint.py`、共享 `record/experiment.py`、`record/playback.py`,确认 report 和帧身份可用字段;冻结输入输出,避免接口臆造。
|
||||||
|
- [ ] 建 `tests/evidence_helpers.py`,含双 repeat、两种格式、缺视频、重复 frame_id、断流、重测及非线性样例。可解码视频使用真实小文件,不能仅写假 MP4 字节。
|
||||||
|
- [x] 将 proposed 接口落实为:窗口选择复用现有 review;summary 构建返回普通 dict;export 接收不可变结果;回放定位接收 trial/hold 身份,返回 segment 和帧号。现有公共 recorder 签名保持不变。
|
||||||
|
|
||||||
|
验证:夹具能同时驱动现有读取器和新增回归;用小型独立数值例子断言预期。缺共享 API 时仅在有两个实际调用者的情况下提取,不导入 Studio 私有代码。
|
||||||
|
|
||||||
|
## A:录像可靠性子代理(2–3 日)
|
||||||
|
|
||||||
|
交接提示:你负责录制生命周期。先重现后续 repeat 错归属、连续 session 来源残留和 writer 失败仍 complete;不得修改统计算法或 motion。
|
||||||
|
|
||||||
|
| 任务/文件 | 验收条件 | 验证 |
|
||||||
|
|---|---|---|
|
||||||
|
| A1:`multipoint.py`、`test_multipoint.py` | 当前 trial 先登记,显式归属;两次 repeat 路径独立;live 后 simulation 无 sink 或 writer 泄漏 | 两点两 repeat、重新 start、attach 失败、重复 stop 回归 |
|
||||||
|
| A2:同模块+`test_multipoint_recording.py`;必要时 `trial.py` | poll 驱动 closing,report 后决定完整性;断流、零帧、编码异常、溢出、收尾失败均保留原因,不阻塞 Qt | fake writer 故障注入+真实短视频关闭/重开;source 重启不混 frame 身份 |
|
||||||
|
| A3:`ui/multipoint_panel.py`、`ui/calibration_panel.py`、`test_calibration_feedback.py` | 待就绪不能确认保持;显示录制/收尾/失败和保存位置,完成后仍可预览;关闭窗口可收尾 | Qt 事件循环在排空期间可响应;重复点击不重复启动 |
|
||||||
|
|
||||||
|
必要文案由主代理统一更新 locale。状态测试必须检查磁盘结果和清单,不能只断言按钮文字。
|
||||||
|
|
||||||
|
## B:汇总计算子代理(2–3 日)
|
||||||
|
|
||||||
|
交接提示:你负责把已有证据变成两张可追溯表。样例仅作布局与数值核对,不是身份或同步真值。不得把候选窗口自动标为稳定。
|
||||||
|
|
||||||
|
| 任务/文件 | 验收条件 | 验证 |
|
||||||
|
|---|---|---|
|
||||||
|
| B1:新增 `summary.py`、`test_summary.py`;协调 `session_review.py` | 两种格式统一身份关联;保留候选/确认、重测及排除原因;不按行序拼接;raw/N 隔离 | 乱序、缺行、重复 ID、无效值、不同轴缺失、手选窗口、未完成 trial |
|
||||||
|
| B2:同模块+`test_summary_fit.py` | raw 对实际参考 OLS,三轴分别计算;R²、endpoint、initial_step 的空值规则明确 | 独立手算直线/非线性、常量、重复参考、缺 baseline;证明与逆向拟合取倒数不同 |
|
||||||
|
| B3:新增 `summary_export.py`、`test_summary_export.py` | 一次发布 HTML/JSON/两 CSV,内容同源;相对路径、输入哈希、历史版本和原子指针 | 写盘失败不污染旧结果;并发/取消不倒退指针;HTML 转义;CSV 精度及中文 |
|
||||||
|
|
||||||
|
dd02-gl 的 60/15 行与重复 site_id 必须记录为历史案例;测试用小型合成样例,不能依赖该机器上的私有数据路径。旧 `stable_means.csv` 兼容层由 D1 接入,不能暗改其过滤方法。
|
||||||
|
|
||||||
|
## C:视频复核子代理(2–3 日)
|
||||||
|
|
||||||
|
交接提示:你负责让操作员从保持段找到原始画面。复用 shared `RecordingReader`;读取视频不重新计算标定。长录像解码必须在后台,切换 trial 不回显旧任务。
|
||||||
|
|
||||||
|
| 任务/文件 | 验收条件 | 验证 |
|
||||||
|
|---|---|---|
|
||||||
|
| C1:新增 `video_review.py`、`test_video_review.py` | 通过采集时间→帧身份→分段帧定位;无视频/缺帧有原因; decoder 生命周期明确 | 可识别帧编号的小视频、跨段、时间缺口、非均匀采样、无视频窗口;逐帧校验结果 |
|
||||||
|
| C2:新增 `ui/video_review_dialog.py`,修改 `ui/record_overview_dialog.py`,新增 UI 测试 | 点窗口可播放、暂停、逐帧并显示参考与曲线;只开放已收尾 trial;不阻塞界面 | 旧格式/多点格式、快速切换、关闭时任务仍运行、异常恢复、无硬件人工点击检查 |
|
||||||
|
|
||||||
|
首期不新增 QtMultimedia/FFmpeg 安装依赖,先验证现有 decoder;如部署环境无法可靠定位,给出实测证据及最小替代方案再由主代理决定。
|
||||||
|
|
||||||
|
## D:集成与独立验收(2–3 日)
|
||||||
|
|
||||||
|
- [x] D1:主代理修改 `multipoint.py`、`trial.py`、`ui/record_overview_dialog.py` 与 locale,将采集终态触发候选导出、保存 review 触发新汇总串起来;导出失败可重试且不改采集结果。加入 `test_summary_lifecycle.py`。
|
||||||
|
- [x] D1 后同步三份既有格式/实施说明和新契约状态,注册文档;不把计划全文复制到多个位置。
|
||||||
|
- [x] D2:一名只读验收代理独立复核(562 项实跑 exit 0);提出 3 项 major、9 项 minor,主代理已修 3 项 major 与 6 项 minor,其余记为未接线或已接受。
|
||||||
|
|
||||||
|
首个检查点在 A2/B2/C1 后:身份、收尾、公式全部通过才接界面。最终检查点:新进程打开两种会话,选择已确认窗口播放,打开报告及两 CSV,中止/缺视频能解释原因。任何必需环境缺失或测试跳过都列为未验证。
|
||||||
|
|
||||||
|
## 验证命令与本次结果
|
||||||
|
|
||||||
|
以下命令已在 app 根执行;未运行真实设备。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$pythonExe = 'D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe'
|
||||||
|
& $pythonExe --version
|
||||||
|
& $pythonExe -m pytest apps/autocal/tests -p no:cacheprovider --basetemp=.scratch/autocal-recording-summary/pytest-full
|
||||||
|
& $pythonExe -m pytest shared/tests/test_experiment_recorder.py shared/tests/test_record_lifecycle.py shared/tests/test_record_semantics.py shared/tests/test_playback.py -p no:cacheprovider --basetemp=.scratch/autocal-recording-summary/pytest-shared
|
||||||
|
& $pythonExe tools/lint_gate.py
|
||||||
|
& $pythonExe apps/autocal/tools/panel_check.py
|
||||||
|
& $pythonExe D:\orisys_ws\scripts\doc_lint.py
|
||||||
|
```
|
||||||
|
|
||||||
|
| 项目 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| T0 | `frames_index.py`(帧/力身份关联、重复帧排除、采集目录解析)、`tests/evidence_helpers.py`(真实可解码 MP4、两种会话样例、可重启来源的假 worker)、`test_frames_index.py` |
|
||||||
|
| A | `capture_guard.py`(`CaptureGuard` 与 `capture_verdict`)、`multipoint.py` 显式 repeat 归属/收尾状态机/来源重启判定、`trial.py` 复用同一判定、界面录像状态与"未就绪不能确认"、两份 locale |
|
||||||
|
| B | `summary.py`(窗口表、逐轴前向 OLS、端点/初始阶跃、旧 `stable_means.csv` 兼容行)、`summary_export.py`(四件文件、原子指针、输入哈希复核、取消/倒退保护) |
|
||||||
|
| C | `video_review.py`(按身份定位与解码、分段切换、释放 decoder)、`ui/video_review_dialog.py`,记录总览与保持复核对话框的录像入口 |
|
||||||
|
| D | 采集终态与复核保存触发发布;失败可重试且不改采集结果;两份格式说明与三份计划状态已同步 |
|
||||||
|
| Autocal 全量 | 562 项,0 失败、0 跳过,62.0 s;junit 证据在 `.scratch/autocal-recording-summary/final-autocal.xml` |
|
||||||
|
| shared 回归 | 4 个文件全部通过(recorder、lifecycle、semantics、playback) |
|
||||||
|
| `tools/lint_gate.py` | `status: clean`,仅 1 条已解释的 BLE001 抑制提示 |
|
||||||
|
| `apps/autocal/tools/panel_check.py` | 260 项中 2 项失败:finger 页 `QDoubleSpinBox` 高度大于父容器。用 HEAD 的独立 worktree 复跑同样失败,属先前存在,不是本次改动引入 |
|
||||||
|
| `scripts/doc_lint.py` | 本次涉及文档均在 reference 上限 3000 内,门禁通过 |
|
||||||
|
|
||||||
|
| D2 独立验收 | 只读评审提出 3 项 major(来源停止仍判完整、模拟写成完整、收尾期可开新场)与 9 项 minor;3 项 major 与 6 项 minor 已修并加回归测试,2 项记为已接受(首帧解码探测在收尾轮询内、有界)与未接线(发布取消只有库级参数,无界面按钮) |
|
||||||
|
| 回归数量 | 新增 est_frames_index、 est_multipoint_recording、 est_summary、 est_summary_fit、 est_summary_export、 est_summary_lifecycle、 est_video_review、 est_video_review_dialog |
|
||||||
|
|
||||||
|
未验证:真实相机与控制器、24 小时分段的解码耗时、`D:\orisys_ws\data\dd02-gl` 的现场私有数据路径(只核对行数,不作为夹具)、D2 独立人工验收。旧独立 `stable_means.csv` 目前只有 `summary.legacy_window_rows()` 映射与测试,尚未接入界面导入;会话内旧记录可读,缺视频时按原因排除。若以后改 shared,另跑实际受影响的 Studio 读取/记录测试,不以 Autocal 测试替代消费者回归。
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Autocal 当前录像工作流
|
||||||
|
|
||||||
|
状态:2026-10-17,软件实现说明,待实机验收。打开[交互工作流图](diagrams/recording-20261017-110144/recording-workflow.html)查看采集、异常收尾、视频复核和汇总发布;支持浅色/深色、放大及图片导出。
|
||||||
|
|
||||||
|
图中原有模块归属的源码引用固定在 `19426c3c4a648ca7f4d3785e2e11b1bf124a8e31`。本次五项修复尚未提交:写线程失败时中止、候选与确认结果分别汇总、按所选区间回放、允许已关闭失败记录诊断,以及取消全部解码图像的累积缓存。当前实现以[工作树快照清单](diagrams/recording-20261017-110144/snapshot.json)及其同目录补丁为依据,不将工作树字节冒充 HEAD 源码。
|
||||||
|
|
||||||
|
每 trial 连续保存零载、换档、加载和卸载画面,MP4 为有损输入录像。保持窗口依靠主机采集时间与帧身份关联,不能用容器 FPS 推算实验同步。操作员手动确认参考载荷;该流程不自动加载,也不证明 FT 物理同步。界面上的实时曲线与实时 UV 图只显示已有数据,不改变录像与坐标。
|
||||||
|
|
||||||
|
原始文件继续使用[单次](calibration-data-format.zh.md)与[多点](multipoint-data-format.zh.md)目录。保持段不拆成新视频,复核与汇总另存版本;字段及计算方法见[汇总契约](recording-summary-contract.zh.md)。
|
||||||
|
|
||||||
|
## 录像选项与关闭视频
|
||||||
|
|
||||||
|
录像选项(`video`、`codec`、`lossless_frames`)在建立会话前选定,写入根 `session.json` 的 `recording` 块并锁定;会话建立后再改不生效。可用编码在启动时用 3 帧探测,写不出来的编码在界面上禁用并写明原因,绝不静默回退到别的编码。本机 `cv2` 目前只能写 `mp4v`,H.264 需要另定编码器来源(未验证,见 `.scratch/autocal-capture-workflow/d/p2b-codec-equivalence.zh.md`)。
|
||||||
|
|
||||||
|
关闭视频时 `capture_verdict` 跳过视频类判定,只保留数据类判定(帧与力的写入、来源会话未变等),因此关视频的 trial 仍可 complete、汇总行仍 eligible;复核界面按 `recording` 块显示「按设置未录视频」。缺该块的旧会话按 `video=true` 读取,结果与以前一致。体积按码率与计划时长估算,与磁盘剩余一并显示,不足两倍估算时警告。
|
||||||
|
|
||||||
|
验证:五个回归用例在修复前失败;修复后的相关 45 项测试和 Autocal 全量 572 项测试通过。Archify 的 showcase 校验、产物检查及真实浏览器检查通过,并检查了截图。未操作真实相机、平台或载荷。
|
||||||
Binary file not shown.
@@ -4,6 +4,11 @@ A calibration sequence is a small JSON document: the **profile** that generated
|
|||||||
the points, plus the **points** themselves. It is written by
|
the points, plus the **points** themselves. It is written by
|
||||||
`导出序列…` in the Sequence tab and read by `导入序列…`.
|
`导出序列…` in the Sequence tab and read by `导入序列…`.
|
||||||
|
|
||||||
|
The Finger simulation page separately exports an operator CSV list with centred
|
||||||
|
UV pixels, sensor/platform millimetres and mapping identity. It uses the
|
||||||
|
[manual calibration coordinate convention](calibration-data-format.zh.md);
|
||||||
|
that list is not an `.oryseq` and is not imported into the acquisition page.
|
||||||
|
|
||||||
Why JSON and not a binary container: a sequence is at most a few hundred points
|
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
|
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
|
bench. Binary would buy nothing and cost all three. (The *acquired data* of a run
|
||||||
@@ -96,6 +101,7 @@ to another bench's number.
|
|||||||
| `id` | 1-based, stable within the file; used for run progress and resume |
|
| `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` |
|
| `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 |
|
| `sensor_mm` | where that point lands on the sensor; optional but written |
|
||||||
|
| `uv_px` | `{u, v}`: the point's place on the flat map, in pixels from the map origin; optional (added without a version change, older files simply lack it). The capture page takes its points by U/V, so a point with `uv_px` can be sent there directly |
|
||||||
| `clearance_mm` | the standoff for this point |
|
| `clearance_mm` | the standoff for this point |
|
||||||
| `enabled` | false = designed but deliberately skipped |
|
| `enabled` | false = designed but deliberately skipped |
|
||||||
| `note` | free text |
|
| `note` | free text |
|
||||||
|
|||||||
@@ -236,16 +236,17 @@ measured time per point.
|
|||||||
**Gate:** the operator has seen the path and a dry run has completed without a
|
**Gate:** the operator has seen the path and a dry run has completed without a
|
||||||
guard violation.
|
guard violation.
|
||||||
|
|
||||||
**The single-point dry run is built**, in the finger debug screen: 执行动作 sends the
|
**The single-point dry run is built**, in the geometry & mounting screen (几何与安装,
|
||||||
selected point's *solved pose* to the platform — yaw, then pitch, then table X/Y — and
|
which no longer carries a point table): 执行动作 sends the
|
||||||
does **not** command the press axis, so it cannot reach the sensor. The order is the
|
selected point's *solved pose* to the platform — the stages together with the table
|
||||||
model's own (`R_pitch · R_yaw`), so every intermediate pose is one the operator
|
X/Y, and the press axis only once its zero is calibrated (设备设置 → 压轴); until then
|
||||||
watched in the preview. Before it moves it checks the mapping, the target against the
|
it is held, so the move cannot reach the sensor by accident. The order follows the
|
||||||
P0 limits (millimetres for x/y/z, degrees for yaw/pitch) and the rotary calibration —
|
model's own composition (`R_pitch · R_yaw`), so every intermediate pose is one the
|
||||||
all in `motion/service.py`, so a later caller cannot walk around them — and the drives
|
operator watched in the preview. Before it moves it checks the mapping, the target
|
||||||
are commanded one at a time, each waiting for the previous to arrive, with a stall or
|
against the P0 limits (millimetres for x/y/z, degrees for yaw/pitch) and the rotary
|
||||||
an alarm aborting the rest. 停止 next to it is the same command as the strip's
|
calibration — all in `motion/service.py`, so a later caller cannot walk around them.
|
||||||
全部停止; 回初始位 in the rail sends every axis to zero with the press axis first.
|
停止 next to it is the same command as the strip's 全部停止; 回初始位 in the rail sends
|
||||||
|
every axis to zero with the press axis first.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -282,6 +283,22 @@ data stays valid.
|
|||||||
**Existing code:** the motion layer has the guards and the poller; the step
|
**Existing code:** the motion layer has the guards and the poller; the step
|
||||||
machine, the contact detection and the acquisition binding are new.
|
machine, the contact detection and the acquisition binding are new.
|
||||||
|
|
||||||
|
**As built today** (manual, through the capture page): the operator keeps one
|
||||||
|
queue of points — a single point is a queue with one row. `Ready` must be all
|
||||||
|
green before the first point. The page shows one primary button whose label is
|
||||||
|
the next step (align → start → at the target → hold finished → confirm liftoff →
|
||||||
|
finish) and states the reason whenever it is disabled. Alignment only requests a
|
||||||
|
guarded pose move, and a capture may not start until that move has completed, so
|
||||||
|
a repeat can never be recorded on a pose nobody confirmed. Remeasure, skip and
|
||||||
|
stop are the secondary actions. What the button says is derived from the campaign
|
||||||
|
state by `capture_controller.py`, never by the widgets.
|
||||||
|
|
||||||
|
Changing the mounting offset, the tool XY or the travel limits re-solves every
|
||||||
|
queued pose: while the queue is still editable the new solution is stored, and a
|
||||||
|
point the stage can no longer reach is marked and refused for alignment. The
|
||||||
|
exported operation table comes from the same queue (queue → 导出测点操作表…), so
|
||||||
|
the table and the run cannot describe different points.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## P8 — Store raw
|
## P8 — Store raw
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""Candidate calibration points in U/V, and the check that each one is usable.
|
||||||
|
|
||||||
|
A sequence is designed on the flat map, so the natural coordinates are the map's
|
||||||
|
own: ``U`` and ``V`` in pixels from the map origin (the same numbers the capture
|
||||||
|
page takes and the point table shows). This module makes arrays of them and says,
|
||||||
|
for each, whether the surface is really there. It imports no Qt, so the
|
||||||
|
arithmetic is tested without a display.
|
||||||
|
|
||||||
|
Two sources of points:
|
||||||
|
|
||||||
|
* a **regular grid** around a centre, optionally walked in a serpentine so the
|
||||||
|
stage does not fly back across the patch after every row;
|
||||||
|
* **staged presets** — a deliberately small first set that proves the hardware, then
|
||||||
|
larger sets added only once the smaller one behaves. The U/V of the presets are
|
||||||
|
the suggested starting layout, read off the planning figure; the check below is
|
||||||
|
what decides whether each one is usable on the loaded surface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .sensor.uv_flat import uv_origin
|
||||||
|
|
||||||
|
#: Why a candidate is unusable. Empty for a usable one.
|
||||||
|
OFF_SURFACE = "off_surface"
|
||||||
|
NEAR_EDGE = "near_edge"
|
||||||
|
|
||||||
|
#: Largest array one request may build; a typo in a pitch must not freeze the UI.
|
||||||
|
MAX_POINTS = 400
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Candidate:
|
||||||
|
"""One proposed point and whether the surface supports it."""
|
||||||
|
|
||||||
|
u: int
|
||||||
|
v: int
|
||||||
|
valid: bool = True
|
||||||
|
reason: str = ""
|
||||||
|
label: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
#: Suggested layouts, ``name -> ((label, u, v), ...)``. Staged on purpose: later
|
||||||
|
#: sets are *added* to earlier ones. The one overlap is C13, the stage-0 point, which
|
||||||
|
#: is also the centre of the stage-1 grid; a point already in the sequence is not
|
||||||
|
#: added twice, so adding stage 1 after stage 0 simply skips it.
|
||||||
|
PRESETS: dict[str, tuple[tuple[str, int, int], ...]] = {
|
||||||
|
# Hardware check: one interior point, away from every edge.
|
||||||
|
"stage0": (("C13", 0, 470),),
|
||||||
|
# First model: a centred 3 x 3, all well inside the mapped area.
|
||||||
|
"stage1": (
|
||||||
|
("C06", -165, 320), ("C07", 0, 320), ("C08", 165, 320),
|
||||||
|
("C12", -130, 470), ("C13", 0, 470), ("C14", 130, 470),
|
||||||
|
("C18", -195, 620), ("C19", 0, 620), ("C20", 195, 620),
|
||||||
|
),
|
||||||
|
# Independent checks for stage 1: between the model's points, never on them.
|
||||||
|
"stage1_check": (
|
||||||
|
("V06", -240, 395), ("V07", 0, 395), ("V08", 240, 395), ("V10", 0, 545),
|
||||||
|
),
|
||||||
|
# Stage 2 additions: towards the edges and the top and bottom.
|
||||||
|
"stage2": (
|
||||||
|
("C02", -260, 180), ("C03", 0, 180), ("C04", 260, 180),
|
||||||
|
("C11", -260, 470), ("C15", 260, 470),
|
||||||
|
("C22", -250, 750), ("C23", 0, 750), ("C24", 250, 750),
|
||||||
|
),
|
||||||
|
"stage2_check": (
|
||||||
|
("V03", -260, 250), ("V04", 0, 250), ("V05", 260, 250), ("V13", 0, 685),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
PRESET_KEYS: tuple[str, ...] = tuple(PRESETS)
|
||||||
|
|
||||||
|
|
||||||
|
def grid_uv(
|
||||||
|
rows: int,
|
||||||
|
cols: int,
|
||||||
|
u_pitch: float,
|
||||||
|
v_pitch: float,
|
||||||
|
centre: tuple[float, float] = (0.0, 0.0),
|
||||||
|
*,
|
||||||
|
serpentine: bool = True,
|
||||||
|
) -> list[tuple[int, int]]:
|
||||||
|
"""A ``rows x cols`` grid centred on ``centre``, bottom row first.
|
||||||
|
|
||||||
|
Rows run along U. With ``serpentine`` every second row is walked backwards, so
|
||||||
|
the visiting order is a path with no long return strokes. An oversized request
|
||||||
|
raises instead of building it: the preview redraws on every keystroke.
|
||||||
|
"""
|
||||||
|
rows, cols = int(rows), int(cols)
|
||||||
|
if rows < 1 or cols < 1:
|
||||||
|
return []
|
||||||
|
if rows * cols > MAX_POINTS:
|
||||||
|
raise ValueError(f"{rows} x {cols} points is more than the {MAX_POINTS} allowed")
|
||||||
|
points: list[tuple[int, int]] = []
|
||||||
|
for row in range(rows):
|
||||||
|
v = centre[1] + (row - (rows - 1) / 2.0) * float(v_pitch)
|
||||||
|
columns = [
|
||||||
|
centre[0] + (col - (cols - 1) / 2.0) * float(u_pitch) for col in range(cols)
|
||||||
|
]
|
||||||
|
if serpentine and row % 2 == 1:
|
||||||
|
columns.reverse()
|
||||||
|
points.extend((int(round(u)), int(round(v))) for u in columns)
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def preset_uv(key: str) -> list[tuple[str, int, int]]:
|
||||||
|
"""The labelled points of one preset; an unknown name is an error."""
|
||||||
|
try:
|
||||||
|
return list(PRESETS[key])
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"no such preset: {key!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def check_candidates(
|
||||||
|
flat,
|
||||||
|
points: Sequence[tuple[int, int] | tuple[str, int, int]],
|
||||||
|
margin_px: int = 0,
|
||||||
|
*,
|
||||||
|
origin: tuple[int, int] | None = None,
|
||||||
|
) -> list[Candidate]:
|
||||||
|
"""Mark each ``(u, v)`` (or ``(label, u, v)``) usable or not on this surface.
|
||||||
|
|
||||||
|
Unusable means the pixel is not on the mapped surface, or — when a margin is
|
||||||
|
asked for — that the mapped area does not extend ``margin_px`` pixels around it
|
||||||
|
in every direction. A press near the edge lands partly off the sensor, which is
|
||||||
|
the thing the margin exists to avoid. With no surface loaded nothing can be
|
||||||
|
vouched for, so every point is reported off-surface.
|
||||||
|
"""
|
||||||
|
if flat is None:
|
||||||
|
return [_candidate(point, False, OFF_SURFACE) for point in points]
|
||||||
|
covered = np.asarray(flat.face_index) >= 0
|
||||||
|
height, width = covered.shape
|
||||||
|
origin_x, origin_y = origin if origin is not None else uv_origin(flat)
|
||||||
|
margin = max(0, int(margin_px))
|
||||||
|
found: list[Candidate] = []
|
||||||
|
for point in points:
|
||||||
|
u, v = _uv(point)
|
||||||
|
x, y = origin_x + u, origin_y - v
|
||||||
|
if not (0 <= x < width and 0 <= y < height) or not covered[y, x]:
|
||||||
|
found.append(_candidate(point, False, OFF_SURFACE))
|
||||||
|
continue
|
||||||
|
low_x, high_x = x - margin, x + margin + 1
|
||||||
|
low_y, high_y = y - margin, y + margin + 1
|
||||||
|
inside = low_x >= 0 and low_y >= 0 and high_x <= width and high_y <= height
|
||||||
|
if margin and (not inside or not covered[low_y:high_y, low_x:high_x].all()):
|
||||||
|
found.append(_candidate(point, False, NEAR_EDGE))
|
||||||
|
continue
|
||||||
|
found.append(_candidate(point, True, ""))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _uv(point) -> tuple[int, int]:
|
||||||
|
if len(point) == 3:
|
||||||
|
return int(point[1]), int(point[2])
|
||||||
|
return int(point[0]), int(point[1])
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate(point, valid: bool, reason: str) -> Candidate:
|
||||||
|
label = str(point[0]) if len(point) == 3 else ""
|
||||||
|
u, v = _uv(point)
|
||||||
|
return Candidate(u=u, v=v, valid=valid, reason=reason, label=label)
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Autocal's multi-site manifest; the shared recorder owns each trial's files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from .recording_options import RecordingOptions
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(path: Path, payload: dict) -> None:
|
||||||
|
"""Replace a manifest atomically, keeping the previous version on failure."""
|
||||||
|
temporary = path.with_suffix(".json.tmp")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
temporary.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationSession:
|
||||||
|
"""One sensor/configuration/mapping identity, with independent trial attempts."""
|
||||||
|
|
||||||
|
def __init__(self, root: Path, metadata: dict) -> None:
|
||||||
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
identity = []
|
||||||
|
for key in ("sensor_model", "sensor_sn"):
|
||||||
|
value = re.sub(r"[^\w-]+", "-", metadata[key]).strip("-_")[:48]
|
||||||
|
if not value:
|
||||||
|
raise ValueError("sensor model and SN need letters or numbers")
|
||||||
|
identity.append(value)
|
||||||
|
self.directory = root / f"{'_'.join(identity)}_{stamp}_{uuid4().hex[:12]}"
|
||||||
|
self.identity = self.identity_of(metadata)
|
||||||
|
#: The video choice belongs to the session, not to each trial: it is
|
||||||
|
#: written once here and never rewritten by a later ``add_trial``.
|
||||||
|
self.recording = RecordingOptions.from_block(metadata.get("recording"))
|
||||||
|
self.document = {
|
||||||
|
"format_version": "autocal-session-1.0",
|
||||||
|
"session_id": self.directory.name,
|
||||||
|
**self.identity,
|
||||||
|
"config_snapshot": metadata["config_snapshot"],
|
||||||
|
"recording": self.recording.as_block(),
|
||||||
|
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"status": "in_progress",
|
||||||
|
"sites": [],
|
||||||
|
"trials": [],
|
||||||
|
}
|
||||||
|
self.directory.mkdir(parents=True, exist_ok=False)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def identity_of(metadata: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"sensor_model": metadata["sensor_model"],
|
||||||
|
"sensor_sn": metadata["sensor_sn"],
|
||||||
|
"config_sha256": metadata["config_sha256"],
|
||||||
|
"uv_definition": metadata["uv_definition"],
|
||||||
|
"uv_outline": metadata.get("uv_outline"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def add_trial(self, metadata: dict) -> dict:
|
||||||
|
if self.document["status"] != "in_progress":
|
||||||
|
raise RuntimeError("calibration session is closed")
|
||||||
|
if self.identity_of(metadata) != self.identity:
|
||||||
|
raise ValueError("finish this session before changing sensor, config or UV mapping")
|
||||||
|
site = metadata["site"]
|
||||||
|
sites = self.document["sites"]
|
||||||
|
stored = next((item for item in sites if item["uv_xy_px"] == site["uv_xy_px"]), None)
|
||||||
|
if stored is None:
|
||||||
|
stored = {"site_id": f"site-{len(sites) + 1:03d}", **site}
|
||||||
|
sites.append(stored)
|
||||||
|
trials = self.document["trials"]
|
||||||
|
trial_id = f"trial-{len(trials) + 1:03d}"
|
||||||
|
entry = {
|
||||||
|
"trial_id": trial_id,
|
||||||
|
"site_id": stored["site_id"],
|
||||||
|
"repeat_index": 1 + sum(item["site_id"] == stored["site_id"] for item in trials),
|
||||||
|
"directory": f"trials/{trial_id}",
|
||||||
|
"status": "incomplete",
|
||||||
|
"reason": "trial has not finished",
|
||||||
|
"plan": metadata["plan"],
|
||||||
|
}
|
||||||
|
trials.append(entry)
|
||||||
|
self.save()
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
write_json(self.directory / "session.json", self.document)
|
||||||
|
|
||||||
|
def close(self) -> str:
|
||||||
|
trials = self.document["trials"]
|
||||||
|
self.document["status"] = (
|
||||||
|
"complete" if trials and all(t["status"] == "complete" for t in trials)
|
||||||
|
else "incomplete"
|
||||||
|
)
|
||||||
|
self.document["ended_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
self.save()
|
||||||
|
return self.document["status"]
|
||||||
@@ -0,0 +1,773 @@
|
|||||||
|
"""The capture page's workflow controller.
|
||||||
|
|
||||||
|
The controller owns the operator's next step. It wraps one
|
||||||
|
:class:`~orisys_autocal.multipoint.Campaign` (the point queue) and turns the
|
||||||
|
campaign's state into the single primary button the page shows, plus the three
|
||||||
|
secondary actions. It is deliberately headless — no Qt and no motion service — so
|
||||||
|
the whole decision can be exercised without a window, a camera or a controller.
|
||||||
|
|
||||||
|
Aligning a point is only ever *requested*: the request is published on
|
||||||
|
:attr:`CaptureController.pending_alignment` and the window plans the move. The
|
||||||
|
controller cannot command motion even by accident.
|
||||||
|
|
||||||
|
The queue is edited through the controller too: adding, updating, removing and
|
||||||
|
reordering a point, and storing the reference rotation, all go through its
|
||||||
|
methods, and :meth:`model` returns every field the page renders. That keeps the
|
||||||
|
page from reading ``campaign.sites`` or writing a ``SiteSpec``.
|
||||||
|
|
||||||
|
The public contract is frozen in
|
||||||
|
``.scratch/autocal-capture-workflow/interfaces.md`` section 1. Two facts the
|
||||||
|
controller does not invent for itself:
|
||||||
|
|
||||||
|
* the arguments :meth:`Campaign.start` needs (output root, sensor model and SN,
|
||||||
|
coordinate frame, simulate flag) are supplied by :attr:`start_arguments`,
|
||||||
|
because they are the window's current state, not the campaign's;
|
||||||
|
* whether a point must be aligned first is answered by ``alignment_required``. The
|
||||||
|
default is yes; the page says no only for a simulated session, which commands no
|
||||||
|
axis and so needs no platform, bus or surface solver;
|
||||||
|
* the maximum force is the controller's own ``max_force_n`` and is the only
|
||||||
|
source of the sixth readiness check. Before a session exists the campaign has
|
||||||
|
no maximum force, and after it starts the value has already been validated.
|
||||||
|
|
||||||
|
The "this point is aligned" flag is what separates :meth:`align_point` from the
|
||||||
|
other primary actions. Only the window may set it, through
|
||||||
|
:meth:`mark_aligned`, after the requested move has actually finished: a request
|
||||||
|
can be refused, cancelled or fail, and starting a session on the old pose would
|
||||||
|
record the wrong geometry. The flag is cleared when a *different* point comes
|
||||||
|
into focus (the start of a session and each point change), never after
|
||||||
|
``campaign.reacquire()``: the platform is already on that point and recording it
|
||||||
|
again does not need another move.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING, NamedTuple
|
||||||
|
|
||||||
|
from .multipoint import Campaign
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover - typing only, keeps the motion package out
|
||||||
|
from .motion.kinematics import PlatformStack
|
||||||
|
|
||||||
|
#: Readiness rows in the frozen order. The label keys are the page's, not this
|
||||||
|
#: module's: a locale translation of a reason is never stored in the controller.
|
||||||
|
READINESS_ITEMS: tuple[tuple[str, str], ...] = (
|
||||||
|
("calibration_bundle", "cap.check.bundle"),
|
||||||
|
("input_frames", "cap.check.frames"),
|
||||||
|
("bus_ready", "cap.check.bus"),
|
||||||
|
("output_writable", "cap.check.output"),
|
||||||
|
("sensor_sn", "cap.check.sn"),
|
||||||
|
("max_force", "cap.check.max_force"),
|
||||||
|
)
|
||||||
|
|
||||||
|
READINESS_KEYS: tuple[str, ...] = tuple(key for key, _label in READINESS_ITEMS)
|
||||||
|
|
||||||
|
_ACTION_LABELS = {
|
||||||
|
"align_point": "cap.action.align",
|
||||||
|
"start_session": "cap.action.start",
|
||||||
|
"confirm_target": "cap.action.confirm_target",
|
||||||
|
"confirm_hold": "cap.action.confirm_hold",
|
||||||
|
"confirm_liftoff": "cap.action.confirm_liftoff",
|
||||||
|
"finish_session": "cap.action.finish",
|
||||||
|
"closing": "cap.block.closing",
|
||||||
|
"remeasure": "cap.action.remeasure",
|
||||||
|
"skip": "cap.action.skip",
|
||||||
|
"stop": "cap.action.stop",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ActionBlocked(RuntimeError):
|
||||||
|
"""An action exists but cannot run yet.
|
||||||
|
|
||||||
|
``reason`` is a locale key (``cap.block.*``), never translated text; the page
|
||||||
|
owns the translation. ``label_key`` names the action that was refused.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, reason: str, label_key: str = "") -> None:
|
||||||
|
super().__init__(reason)
|
||||||
|
self.reason = reason
|
||||||
|
self.label_key = label_key
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReadinessCheck:
|
||||||
|
"""One external fact the capture page needs before it may start."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
ok: bool
|
||||||
|
label_key: str
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class PrimaryAction(NamedTuple):
|
||||||
|
key: str
|
||||||
|
label_key: str
|
||||||
|
enabled: bool
|
||||||
|
blocked_reason: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AlignmentRequest:
|
||||||
|
"""The five-axis targets for one point, for the window's move planner."""
|
||||||
|
|
||||||
|
site_id: str
|
||||||
|
targets: dict[str, float]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SiteRow:
|
||||||
|
"""One row of the point queue, as the page renders it.
|
||||||
|
|
||||||
|
Everything the queue table and the point editor may show is copied into the
|
||||||
|
row, so the page never reads ``campaign.sites`` or a ``SiteSpec`` field.
|
||||||
|
``pose`` is a snapshot: a display or comparison value, not a handle the page
|
||||||
|
may write through.
|
||||||
|
"""
|
||||||
|
|
||||||
|
site_id: str
|
||||||
|
position: dict[str, float]
|
||||||
|
repeats_planned: int
|
||||||
|
repeats_complete: int
|
||||||
|
complete: bool
|
||||||
|
reachable: bool
|
||||||
|
levels: tuple[float, ...]
|
||||||
|
hold_s: float
|
||||||
|
uv_px: tuple[float, float] | None
|
||||||
|
normal: tuple[float, float, float] | None
|
||||||
|
pose: dict | None
|
||||||
|
position_frame: str
|
||||||
|
skipped: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CaptureModel:
|
||||||
|
"""Everything the page may read about the campaign, in one snapshot."""
|
||||||
|
|
||||||
|
sites: list[SiteRow]
|
||||||
|
current_index: int
|
||||||
|
progress: dict
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureController:
|
||||||
|
"""Walk one campaign through the operator's steps without touching Qt or motion."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
campaign: Campaign,
|
||||||
|
*,
|
||||||
|
solver: PlatformStack | None = None,
|
||||||
|
readiness: Callable[[], list[ReadinessCheck]] | None = None,
|
||||||
|
max_force_n: float | None = None,
|
||||||
|
start_arguments: Callable[[], dict] | None = None,
|
||||||
|
alignment_required: Callable[[], bool] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.campaign = campaign
|
||||||
|
self.solver = solver
|
||||||
|
self._readiness = readiness
|
||||||
|
self._max_force_n = None if max_force_n is None else float(max_force_n)
|
||||||
|
self._start_arguments = start_arguments
|
||||||
|
self._alignment_probe = alignment_required
|
||||||
|
self._pending_alignment: AlignmentRequest | None = None
|
||||||
|
self._aligned: set[str] = set()
|
||||||
|
self._skipped: set[str] = set()
|
||||||
|
self._focus_site = ""
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ readiness
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
return all(check.ok for check in self.readiness())
|
||||||
|
|
||||||
|
def readiness(self) -> list[ReadinessCheck]:
|
||||||
|
"""All six checks, in the frozen order, including the ones that failed."""
|
||||||
|
probe = self._probe_checks()
|
||||||
|
result: list[ReadinessCheck] = []
|
||||||
|
for key, label in READINESS_ITEMS:
|
||||||
|
if key == "max_force":
|
||||||
|
result.append(self._max_force_check(key, label))
|
||||||
|
else:
|
||||||
|
result.append(probe[key])
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _max_force_check(self, key: str, label: str) -> ReadinessCheck:
|
||||||
|
value = self._max_force_n
|
||||||
|
ok = value is not None and math.isfinite(value) and value > 0
|
||||||
|
return ReadinessCheck(key, ok, label, "" if ok else "max_force_n is not set")
|
||||||
|
|
||||||
|
def _probe_checks(self) -> dict[str, ReadinessCheck]:
|
||||||
|
"""Map the injected probe's answer onto the six frozen keys.
|
||||||
|
|
||||||
|
A probe that raises or omits a key must not take the page down: the
|
||||||
|
missing rows are reported as *not* ready with their cause, because an
|
||||||
|
unreported fact is not a satisfied one.
|
||||||
|
"""
|
||||||
|
if self._readiness is None:
|
||||||
|
return {
|
||||||
|
key: ReadinessCheck(key, True, label) for key, label in READINESS_ITEMS
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
supplied = list(self._readiness())
|
||||||
|
except Exception as exc: # noqa: BLE001 - a probe is external and must not crash the page
|
||||||
|
detail = str(exc) or type(exc).__name__
|
||||||
|
return {
|
||||||
|
key: ReadinessCheck(key, False, label, detail)
|
||||||
|
for key, label in READINESS_ITEMS
|
||||||
|
}
|
||||||
|
reported = {
|
||||||
|
str(getattr(check, "key", "")): check
|
||||||
|
for check in supplied
|
||||||
|
if getattr(check, "key", "")
|
||||||
|
}
|
||||||
|
rows: dict[str, ReadinessCheck] = {}
|
||||||
|
for key, label in READINESS_ITEMS:
|
||||||
|
check = reported.get(key)
|
||||||
|
if check is None:
|
||||||
|
rows[key] = ReadinessCheck(
|
||||||
|
key, False, label, "the readiness probe did not report this check"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows[key] = ReadinessCheck(
|
||||||
|
key, bool(check.ok), label, str(getattr(check, "detail", "") or "")
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ primary action
|
||||||
|
|
||||||
|
def primary_action(self) -> PrimaryAction:
|
||||||
|
"""The one button the page shows now, with a reason whenever it is disabled."""
|
||||||
|
self._sync_alignment_focus()
|
||||||
|
campaign = self.campaign
|
||||||
|
phase = campaign.phase
|
||||||
|
if self._closing():
|
||||||
|
return PrimaryAction(
|
||||||
|
"closing", "cap.block.closing", False, "cap.block.closing"
|
||||||
|
)
|
||||||
|
if phase == "idle":
|
||||||
|
if not campaign.sites:
|
||||||
|
return self._start_action()
|
||||||
|
# A session that moves nothing has no pose to reach first.
|
||||||
|
if not self._alignment_required() or self._aligned_in_focus():
|
||||||
|
return self._start_action()
|
||||||
|
return self._align_action()
|
||||||
|
if phase == "liftoff":
|
||||||
|
pending = campaign.pending_alignment_site
|
||||||
|
if (
|
||||||
|
pending
|
||||||
|
and self._alignment_required()
|
||||||
|
and not self._is_aligned(pending)
|
||||||
|
):
|
||||||
|
action = self._align_action()
|
||||||
|
if action.enabled:
|
||||||
|
return action
|
||||||
|
return PrimaryAction(
|
||||||
|
"confirm_liftoff", "cap.action.confirm_liftoff", True, ""
|
||||||
|
)
|
||||||
|
if phase == "load":
|
||||||
|
return PrimaryAction(
|
||||||
|
"confirm_target", "cap.action.confirm_target", True, ""
|
||||||
|
)
|
||||||
|
if phase == "hold":
|
||||||
|
if campaign.remaining_s() > 0:
|
||||||
|
return PrimaryAction(
|
||||||
|
"confirm_hold",
|
||||||
|
"cap.action.confirm_hold",
|
||||||
|
False,
|
||||||
|
"cap.block.hold_running",
|
||||||
|
)
|
||||||
|
return PrimaryAction("confirm_hold", "cap.action.confirm_hold", True, "")
|
||||||
|
if phase in {"done", "aborted"}:
|
||||||
|
return PrimaryAction("finish_session", "cap.action.finish", True, "")
|
||||||
|
# An unknown phase is never actionable; say the repeat is being written
|
||||||
|
# rather than inventing a button that would do nothing.
|
||||||
|
return PrimaryAction("closing", "cap.block.closing", False, "cap.block.busy")
|
||||||
|
|
||||||
|
def _alignment_required(self) -> bool:
|
||||||
|
"""Whether a point must be aligned before it is recorded.
|
||||||
|
|
||||||
|
A simulated session commands no axis, so the page says alignment is not
|
||||||
|
needed and the platform, the bus and the surface solver can all be absent.
|
||||||
|
Without that answer (no probe, or one that fails) alignment is required: the
|
||||||
|
stricter reading is the safe one.
|
||||||
|
"""
|
||||||
|
if self._alignment_probe is None:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
return bool(self._alignment_probe())
|
||||||
|
except Exception: # noqa: BLE001 - the page's own state must not break the decision
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _start_action(self) -> PrimaryAction:
|
||||||
|
campaign = self.campaign
|
||||||
|
if not campaign.sites:
|
||||||
|
return self._disabled("start_session", "cap.block.no_sites")
|
||||||
|
if self._start_arguments is None:
|
||||||
|
return self._disabled("start_session", "cap.block.no_session")
|
||||||
|
if not self.ready:
|
||||||
|
return self._disabled("start_session", "cap.block.not_ready")
|
||||||
|
return PrimaryAction("start_session", "cap.action.start", True, "")
|
||||||
|
|
||||||
|
def _align_action(self) -> PrimaryAction:
|
||||||
|
if self._recording():
|
||||||
|
return self._disabled("align_point", "cap.block.align_while_recording")
|
||||||
|
if self.solver is None:
|
||||||
|
return self._disabled("align_point", "cap.block.no_solver")
|
||||||
|
site_id = self._focus_site
|
||||||
|
if not site_id or not self._pose_usable(site_id):
|
||||||
|
return self._disabled("align_point", "cap.block.no_pose")
|
||||||
|
return PrimaryAction("align_point", "cap.action.align", True, "")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _disabled(key: str, reason: str) -> PrimaryAction:
|
||||||
|
return PrimaryAction(key, _ACTION_LABELS[key], False, reason)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ secondary actions
|
||||||
|
|
||||||
|
def secondary_actions(self) -> list[PrimaryAction]:
|
||||||
|
"""Remeasure, skip and stop, in that frozen order."""
|
||||||
|
self._sync_alignment_focus()
|
||||||
|
return [self._remeasure_action(), self._skip_action(), self._stop_action()]
|
||||||
|
|
||||||
|
def _remeasure_action(self) -> PrimaryAction:
|
||||||
|
enabled = self.campaign.can_remeasure_step()
|
||||||
|
return PrimaryAction(
|
||||||
|
"remeasure",
|
||||||
|
"cap.action.remeasure",
|
||||||
|
enabled,
|
||||||
|
"" if enabled else "cap.block.remeasure_unavailable",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _skip_action(self) -> PrimaryAction:
|
||||||
|
if not hasattr(self.campaign, "skip_current_point"):
|
||||||
|
return self._disabled("skip", "cap.block.skip_unavailable")
|
||||||
|
if self._recording():
|
||||||
|
return self._disabled("skip", "cap.block.skip_live")
|
||||||
|
if self.campaign.phase not in {"load", "hold"}:
|
||||||
|
return self._disabled("skip", "cap.block.busy")
|
||||||
|
return PrimaryAction("skip", "cap.action.skip", True, "")
|
||||||
|
|
||||||
|
def _stop_action(self) -> PrimaryAction:
|
||||||
|
running = self.campaign.is_active or self.campaign.busy
|
||||||
|
return PrimaryAction(
|
||||||
|
"stop",
|
||||||
|
"cap.action.stop",
|
||||||
|
running,
|
||||||
|
"" if running else "cap.block.stop_unavailable",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ perform
|
||||||
|
|
||||||
|
def perform(self, key: str, **inputs) -> None:
|
||||||
|
"""Run one offered action, or explain in a locale key why it cannot run."""
|
||||||
|
# A recording repeat must never be interrupted by a move, and a caller can
|
||||||
|
# ask for it directly instead of through the button. This guard outranks
|
||||||
|
# the "not the current action" check, so the refusal names the real cause.
|
||||||
|
if key == "align_point" and self._recording():
|
||||||
|
raise ActionBlocked("cap.block.align_while_recording", "cap.action.align")
|
||||||
|
self._sync_alignment_focus()
|
||||||
|
primary = self.primary_action()
|
||||||
|
secondary = {action.key: action for action in self.secondary_actions()}
|
||||||
|
if key == primary.key:
|
||||||
|
action = primary
|
||||||
|
elif key in secondary:
|
||||||
|
action = secondary[key]
|
||||||
|
else:
|
||||||
|
raise ValueError(f"{key!r} is not an offered capture action")
|
||||||
|
if not action.enabled:
|
||||||
|
raise ActionBlocked(action.blocked_reason, action.label_key)
|
||||||
|
if inputs and key not in {"confirm_target", "stop"}:
|
||||||
|
raise ValueError(f"{key} takes no inputs")
|
||||||
|
if key == "start_session":
|
||||||
|
self._perform_start_session()
|
||||||
|
elif key == "align_point":
|
||||||
|
self._perform_align_point()
|
||||||
|
elif key == "confirm_target":
|
||||||
|
self._perform_confirm_target(inputs)
|
||||||
|
elif key == "confirm_hold":
|
||||||
|
self.campaign.complete_hold()
|
||||||
|
elif key == "confirm_liftoff":
|
||||||
|
self.campaign.confirm_liftoff()
|
||||||
|
elif key == "finish_session":
|
||||||
|
self._perform_finish_session()
|
||||||
|
elif key == "remeasure":
|
||||||
|
self.campaign.remeasure_step()
|
||||||
|
elif key == "skip":
|
||||||
|
self._perform_skip()
|
||||||
|
elif key == "stop":
|
||||||
|
self._perform_stop(inputs)
|
||||||
|
else: # pragma: no cover - "closing" and unknown keys never reach here
|
||||||
|
raise ValueError(f"{key!r} is not an executable capture action")
|
||||||
|
|
||||||
|
def _perform_finish_session(self) -> None:
|
||||||
|
"""Close the finished session out so the queue is editable again.
|
||||||
|
|
||||||
|
``flush_close`` is not what ends a session: it only waits for a writer that
|
||||||
|
is still closing, and ``primary_action`` never offers this key while one is.
|
||||||
|
What the operator needs is the way back to the idle phase, with the next
|
||||||
|
session possible and the finished one's files left exactly as they are.
|
||||||
|
"""
|
||||||
|
self.campaign.end_session()
|
||||||
|
# The platform stayed where the last point left it, and the queue may be
|
||||||
|
# edited next: nothing is aligned until a move says so.
|
||||||
|
self._aligned.clear()
|
||||||
|
self._pending_alignment = None
|
||||||
|
|
||||||
|
def _perform_start_session(self) -> None:
|
||||||
|
arguments = self._start_arguments
|
||||||
|
if arguments is None: # pragma: no cover - the action is disabled without it
|
||||||
|
raise ActionBlocked("cap.block.no_session", "cap.action.start")
|
||||||
|
self._pending_alignment = None
|
||||||
|
self.campaign.start(**arguments())
|
||||||
|
# A new session measures every point again: skip marks belong to the
|
||||||
|
# session that recorded them.
|
||||||
|
self._skipped.clear()
|
||||||
|
|
||||||
|
def _perform_align_point(self) -> None:
|
||||||
|
"""Publish the move request. It does not mean the platform has moved."""
|
||||||
|
site_id = self._focus_site
|
||||||
|
try:
|
||||||
|
targets = dict(self.campaign.command_targets(site_id))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ActionBlocked("cap.block.no_pose", "cap.action.align") from exc
|
||||||
|
self.pending_alignment = AlignmentRequest(site_id=site_id, targets=targets)
|
||||||
|
|
||||||
|
def _perform_confirm_target(self, inputs: dict) -> None:
|
||||||
|
force_n = inputs.pop("force_n", None)
|
||||||
|
wrench = inputs.pop("wrench", None)
|
||||||
|
rotation = inputs.pop("rotation", None)
|
||||||
|
if inputs:
|
||||||
|
raise ValueError("confirm_target accepts force_n or wrench, nothing else")
|
||||||
|
if (force_n is None) == (wrench is None):
|
||||||
|
raise ValueError("confirm_target needs exactly one of force_n or wrench")
|
||||||
|
if force_n is not None:
|
||||||
|
if rotation is not None:
|
||||||
|
raise ValueError("rotation only applies to a six-axis reference")
|
||||||
|
self.campaign.confirm_reference(float(force_n))
|
||||||
|
return
|
||||||
|
values = tuple(float(value) for value in wrench)
|
||||||
|
if len(values) != 3:
|
||||||
|
raise ValueError("wrench needs three components")
|
||||||
|
if rotation is not None:
|
||||||
|
# ``Campaign.start`` cleared the rotation; the only moment the operator
|
||||||
|
# enters it is this confirmation, so set it right before the reading.
|
||||||
|
self.campaign.set_force_rotation([float(value) for value in rotation])
|
||||||
|
self.campaign.confirm_wrench(values[0], values[1], values[2])
|
||||||
|
|
||||||
|
def _perform_skip(self) -> None:
|
||||||
|
method = getattr(self.campaign, "skip_current_point", None)
|
||||||
|
if method is None: # pragma: no cover - the action is disabled without it
|
||||||
|
raise ActionBlocked("cap.block.skip_unavailable", "cap.action.skip")
|
||||||
|
site_id = self._focus_site
|
||||||
|
method()
|
||||||
|
if site_id:
|
||||||
|
# The queue still lists the point; the row says it was skipped so the
|
||||||
|
# operator can see it was not recorded rather than guess from a count.
|
||||||
|
self._skipped.add(site_id)
|
||||||
|
|
||||||
|
def _perform_stop(self, inputs: dict) -> None:
|
||||||
|
"""Stop the session, with an optional free-text reason for the record.
|
||||||
|
|
||||||
|
``session.json``'s ``reason`` is free text, not a locale key: the stop
|
||||||
|
button keeps the default phrase, while the window's shutdown path passes
|
||||||
|
its own so an application close stays distinguishable from an operator
|
||||||
|
stop in the data.
|
||||||
|
"""
|
||||||
|
reason = inputs.pop("reason", None)
|
||||||
|
if inputs:
|
||||||
|
raise ValueError("stop accepts a reason only")
|
||||||
|
if reason is None:
|
||||||
|
text = "operator stopped"
|
||||||
|
else:
|
||||||
|
text = str(reason).strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("a stop reason must not be blank")
|
||||||
|
self.campaign.stop(text)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ alignment result
|
||||||
|
|
||||||
|
def mark_aligned(self, site_id: str | None = None) -> None:
|
||||||
|
"""The window confirmed the requested move finished.
|
||||||
|
|
||||||
|
Only this call sets the "this point is aligned" flag. A request on its own
|
||||||
|
must not, because the move can be refused, cancelled, or fail half way —
|
||||||
|
and starting a session there would record the old pose. ``site_id=None``
|
||||||
|
names the point currently in focus.
|
||||||
|
"""
|
||||||
|
target = self._focus_site_id() if site_id is None else str(site_id)
|
||||||
|
if not target:
|
||||||
|
raise ValueError("there is no point in focus to mark as aligned")
|
||||||
|
self._aligned.add(target)
|
||||||
|
|
||||||
|
def invalidate_alignment(self) -> None:
|
||||||
|
"""Forget every "this point is aligned" flag.
|
||||||
|
|
||||||
|
The flag records that the platform stood on a point's pose at some moment.
|
||||||
|
Anything that moves it elsewhere (a jog, a home, a move from another page),
|
||||||
|
or changes what a pose means (a new surface, a changed mounting), makes
|
||||||
|
that statement false, and starting on it would record the wrong geometry.
|
||||||
|
Never raises: it runs on the window's motion paths.
|
||||||
|
"""
|
||||||
|
self._aligned.clear()
|
||||||
|
|
||||||
|
def mark_alignment_failed(self, site_id: str | None = None) -> None:
|
||||||
|
"""A move was refused, cancelled or failed, so the point stays unaligned.
|
||||||
|
|
||||||
|
The page still shows ``align_point`` for a retry. This never raises: it
|
||||||
|
runs on an error path where the page has its own message to show.
|
||||||
|
"""
|
||||||
|
target = self._focus_site_id() if site_id is None else str(site_id)
|
||||||
|
if target:
|
||||||
|
self._aligned.discard(target)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ queue editing
|
||||||
|
|
||||||
|
def add_point(
|
||||||
|
self,
|
||||||
|
site_id: str,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
z: float,
|
||||||
|
normal: tuple[float, float, float],
|
||||||
|
pose: dict,
|
||||||
|
levels: list[float],
|
||||||
|
hold_s: float,
|
||||||
|
repeats: int,
|
||||||
|
*,
|
||||||
|
uv_px: tuple[float, float] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Add a solved point to the queue. Idle only; never moves the platform."""
|
||||||
|
self._require_queue_editable()
|
||||||
|
self.campaign.add_solved_site(
|
||||||
|
site_id,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
z,
|
||||||
|
normal,
|
||||||
|
pose,
|
||||||
|
levels,
|
||||||
|
hold_s,
|
||||||
|
repeats,
|
||||||
|
max_force_n=self._require_max_force(),
|
||||||
|
uv_px=uv_px,
|
||||||
|
)
|
||||||
|
self._skipped.discard(site_id)
|
||||||
|
self._aligned.discard(site_id)
|
||||||
|
|
||||||
|
def update_point(
|
||||||
|
self,
|
||||||
|
site_id: str,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
z: float,
|
||||||
|
levels: list[float],
|
||||||
|
hold_s: float,
|
||||||
|
repeats: int,
|
||||||
|
*,
|
||||||
|
new_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Replace a point's geometry or plan. Idle only.
|
||||||
|
|
||||||
|
The stored pose and normal are kept by ``Campaign.update_site`` unless the
|
||||||
|
page re-solves and stores them; a changed point therefore loses its "this
|
||||||
|
point is aligned" flag, because that move was aimed at the old geometry.
|
||||||
|
"""
|
||||||
|
self._require_queue_editable()
|
||||||
|
self.campaign.update_site(
|
||||||
|
site_id,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
z,
|
||||||
|
levels,
|
||||||
|
hold_s,
|
||||||
|
repeats,
|
||||||
|
max_force_n=self._require_max_force(),
|
||||||
|
new_id=new_id,
|
||||||
|
)
|
||||||
|
for target in {site_id, new_id or site_id}:
|
||||||
|
self._skipped.discard(target)
|
||||||
|
self._aligned.discard(target)
|
||||||
|
|
||||||
|
def remove_point(self, site_id: str) -> None:
|
||||||
|
"""Remove one point from the queue. Idle only."""
|
||||||
|
self._require_queue_editable()
|
||||||
|
self.campaign.remove_site(site_id)
|
||||||
|
self._skipped.discard(site_id)
|
||||||
|
self._aligned.discard(site_id)
|
||||||
|
|
||||||
|
def move_point(self, site_id: str, delta: int) -> None:
|
||||||
|
"""Move one point in the queue order. Idle only; positions are unchanged."""
|
||||||
|
self._require_queue_editable()
|
||||||
|
self.campaign.move_site(site_id, delta)
|
||||||
|
|
||||||
|
def set_force_rotation(self, values: Sequence[float]) -> None:
|
||||||
|
"""Store the force-sensor-to-tool rotation used by six-axis references."""
|
||||||
|
self._require_queue_editable()
|
||||||
|
self.campaign.set_force_rotation(list(values))
|
||||||
|
|
||||||
|
def store_solved_pose(self, site_id: str, pose: dict) -> None:
|
||||||
|
"""Store a freshly solved pose for a point. Idle only.
|
||||||
|
|
||||||
|
Re-solving is queue data, so it belongs behind the controller like the
|
||||||
|
other edits. The point loses its "this point is aligned" flag: a move made
|
||||||
|
for the old pose is not a move to this one.
|
||||||
|
"""
|
||||||
|
self._require_queue_editable()
|
||||||
|
self.campaign.store_solved_pose(site_id, pose)
|
||||||
|
self._aligned.discard(site_id)
|
||||||
|
|
||||||
|
def set_max_force(self, value: float | None) -> None:
|
||||||
|
"""Update the maximum force the readiness check and queue edits use."""
|
||||||
|
self._max_force_n = None if value is None else float(value)
|
||||||
|
|
||||||
|
def _require_queue_editable(self) -> None:
|
||||||
|
if self.campaign.phase != "idle":
|
||||||
|
raise ActionBlocked("cap.block.queue_busy")
|
||||||
|
|
||||||
|
def _require_max_force(self) -> float:
|
||||||
|
if self._max_force_n is None:
|
||||||
|
raise ActionBlocked("cap.block.no_max_force")
|
||||||
|
return self._max_force_n
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ poll and views
|
||||||
|
|
||||||
|
def poll(self) -> dict:
|
||||||
|
"""Advance closing evidence and report what the page should draw.
|
||||||
|
|
||||||
|
``campaign.poll()`` is what finalises a repeat's recording; omitting it
|
||||||
|
would leave the page waiting on a close that never finishes.
|
||||||
|
"""
|
||||||
|
self.campaign.poll()
|
||||||
|
# The planned hold time is the operator's plan, not a button to wait for: the
|
||||||
|
# window ends when the time does. A failure here (a writer that died during
|
||||||
|
# the hold, say) reaches the page like any other poll error.
|
||||||
|
self.campaign.auto_complete_hold()
|
||||||
|
self._sync_alignment_focus()
|
||||||
|
return {
|
||||||
|
"phase": self.campaign.phase,
|
||||||
|
"capture_state": self.campaign.capture_state,
|
||||||
|
"capture_reason": self.campaign.capture_reason,
|
||||||
|
"remaining_s": float(self.campaign.remaining_s()),
|
||||||
|
"pending_alignment_site": self.campaign.pending_alignment_site,
|
||||||
|
}
|
||||||
|
|
||||||
|
def model(self) -> CaptureModel:
|
||||||
|
"""The queue and the campaign's own progress, read-only.
|
||||||
|
|
||||||
|
One row carries every field the queue table and the point editor show, so
|
||||||
|
the page never touches ``campaign.sites``. ``pose`` and ``position`` are
|
||||||
|
copies: the page may compare them, not write through them.
|
||||||
|
"""
|
||||||
|
self._sync_alignment_focus()
|
||||||
|
completion = {row["site_id"]: row for row in self.campaign.completion()}
|
||||||
|
rows = []
|
||||||
|
for site in self.campaign.sites:
|
||||||
|
counts = completion.get(site.site_id) or {}
|
||||||
|
rows.append(
|
||||||
|
SiteRow(
|
||||||
|
site_id=site.site_id,
|
||||||
|
position=site.position(),
|
||||||
|
repeats_planned=int(site.repeats),
|
||||||
|
repeats_complete=int(counts.get("repeats_complete", 0)),
|
||||||
|
complete=bool(counts.get("complete", False)),
|
||||||
|
reachable=self._pose_usable(site.site_id),
|
||||||
|
levels=tuple(float(value) for value in site.levels_n),
|
||||||
|
hold_s=float(site.hold_s),
|
||||||
|
uv_px=(
|
||||||
|
None
|
||||||
|
if site.uv_px is None
|
||||||
|
else (float(site.uv_px[0]), float(site.uv_px[1]))
|
||||||
|
),
|
||||||
|
normal=(
|
||||||
|
None
|
||||||
|
if site.normal is None
|
||||||
|
else tuple(float(value) for value in site.normal)
|
||||||
|
),
|
||||||
|
pose=None if site.pose is None else dict(site.pose),
|
||||||
|
position_frame=str(site.position_frame or ""),
|
||||||
|
skipped=site.site_id in self._skipped,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return CaptureModel(
|
||||||
|
sites=rows,
|
||||||
|
current_index=self._current_index(),
|
||||||
|
progress=self.campaign.progress(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pending_alignment(self) -> AlignmentRequest | None:
|
||||||
|
"""The move the page still has to plan. The page clears it after taking it."""
|
||||||
|
return self._pending_alignment
|
||||||
|
|
||||||
|
@pending_alignment.setter
|
||||||
|
def pending_alignment(self, value: AlignmentRequest | None) -> None:
|
||||||
|
self._pending_alignment = value
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ state helpers
|
||||||
|
|
||||||
|
def _closing(self) -> bool:
|
||||||
|
return (
|
||||||
|
self.campaign.phase == "closing" or self.campaign.capture_state == "closing"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _recording(self) -> bool:
|
||||||
|
return self.campaign.capture_state == "recording" and self.campaign.phase in {
|
||||||
|
"load",
|
||||||
|
"hold",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _is_aligned(self, site_id: str) -> bool:
|
||||||
|
return bool(site_id) and site_id in self._aligned
|
||||||
|
|
||||||
|
def _aligned_in_focus(self) -> bool:
|
||||||
|
return self._is_aligned(self._focus_site)
|
||||||
|
|
||||||
|
def _pose_usable(self, site_id: str) -> bool:
|
||||||
|
"""Whether the stored pose may be commanded. Read-only; never solves."""
|
||||||
|
try:
|
||||||
|
self.campaign.command_targets(site_id)
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _sync_alignment_focus(self) -> str:
|
||||||
|
"""Track the point in focus; a *different* point is not aligned yet.
|
||||||
|
|
||||||
|
An empty focus (no session, or a finished one) never clears a flag, so the
|
||||||
|
point a ``campaign.reacquire()`` comes back to keeps its alignment.
|
||||||
|
"""
|
||||||
|
focus = self._focus_site_id()
|
||||||
|
if focus and self._focus_site and focus != self._focus_site:
|
||||||
|
self._aligned.discard(focus)
|
||||||
|
self._focus_site = focus
|
||||||
|
return focus
|
||||||
|
|
||||||
|
def _focus_site_id(self) -> str:
|
||||||
|
campaign = self.campaign
|
||||||
|
if not campaign.sites:
|
||||||
|
return ""
|
||||||
|
phase = campaign.phase
|
||||||
|
if phase == "idle":
|
||||||
|
return campaign.sites[0].site_id
|
||||||
|
if phase == "liftoff":
|
||||||
|
return campaign.pending_alignment_site
|
||||||
|
if phase in {"done", "aborted"}:
|
||||||
|
return ""
|
||||||
|
return str(campaign.progress().get("site_id") or "")
|
||||||
|
|
||||||
|
def _current_index(self) -> int:
|
||||||
|
"""Zero-based row the page should highlight; ``-1`` when there is none.
|
||||||
|
|
||||||
|
During ``liftoff`` the campaign has already advanced, so this is the point
|
||||||
|
the operator is about to align.
|
||||||
|
"""
|
||||||
|
sites = self.campaign.sites
|
||||||
|
if not sites:
|
||||||
|
return -1
|
||||||
|
phase = self.campaign.phase
|
||||||
|
if phase == "idle":
|
||||||
|
return 0
|
||||||
|
if phase in {"done", "aborted"}:
|
||||||
|
return -1
|
||||||
|
index = int(self.campaign.progress().get("site_index") or 0) - 1
|
||||||
|
return index if 0 <= index < len(sites) else -1
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""Capture honesty: one source session per recording, and one closing verdict.
|
||||||
|
|
||||||
|
Two defects motivated this module.
|
||||||
|
|
||||||
|
*The sink outlives the session.* A worker's record sinks are registered on the
|
||||||
|
worker object and survive ``start()``. When a camera is restarted while a
|
||||||
|
recording runs, the recorder keeps accepting packets whose ``session_id`` is the
|
||||||
|
new one and whose ``frame_id`` begins again, so two acquisitions are written into
|
||||||
|
one directory under one identity. The recorder cannot see this on its own: it
|
||||||
|
writes its own session id into ``forces.csv``. A :class:`CaptureGuard` sits
|
||||||
|
between the worker and the recorder, refuses the packet that would mix identities
|
||||||
|
and remembers why.
|
||||||
|
|
||||||
|
*"Stopped" is not "complete".* The multi-point campaign used to mark a repeat
|
||||||
|
complete from the operator's confirmation alone, without asking the writer what
|
||||||
|
happened. :func:`capture_verdict` is the single answer both recording paths use:
|
||||||
|
zero frames, a write error, a missing or undecodable video, more than one segment,
|
||||||
|
dropped data, a restarted source or an aborted operator all keep the evidence
|
||||||
|
incomplete, and each one names its reason.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
#: Reason codes. Tokens, not sentences: the UI localises them.
|
||||||
|
SOURCE_RESTARTED = "source_restarted"
|
||||||
|
SOURCE_STOPPED = "source_stopped"
|
||||||
|
NOT_RECORDED = "not_recorded"
|
||||||
|
WRITE_ERROR = "write_error"
|
||||||
|
ZERO_FRAMES = "zero_frames"
|
||||||
|
VIDEO_MISSING = "video_missing"
|
||||||
|
VIDEO_UNDECODABLE = "video_undecodable"
|
||||||
|
SEGMENT_COUNT = "segment_count"
|
||||||
|
DROPPED_DATA = "dropped_data"
|
||||||
|
DROPPED_VIDEO = "dropped_video"
|
||||||
|
CLOSING_TIMEOUT = "closing_timeout"
|
||||||
|
ABORTED = "aborted"
|
||||||
|
PENDING = "pending"
|
||||||
|
|
||||||
|
COMPLETE = "complete"
|
||||||
|
INCOMPLETE = "incomplete"
|
||||||
|
|
||||||
|
#: How long a closing writer may take before it is reported as incomplete.
|
||||||
|
CLOSING_TIMEOUT_S = 20.0
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureGuard:
|
||||||
|
"""Sink wrapper: hands packets to the recorder only for the expected session."""
|
||||||
|
|
||||||
|
def __init__(self, *, expected_session_id: int, recorder) -> None:
|
||||||
|
self.expected_session_id = int(expected_session_id)
|
||||||
|
self.recorder = recorder
|
||||||
|
self.packets = 0
|
||||||
|
self.refused = 0
|
||||||
|
self.failed = False
|
||||||
|
self.reason = ""
|
||||||
|
|
||||||
|
def __call__(self, packet) -> None:
|
||||||
|
self.packets += 1
|
||||||
|
session = getattr(packet, "session_id", None)
|
||||||
|
if session is None or int(session) != self.expected_session_id:
|
||||||
|
# Do not forward: a row from another acquisition session has no
|
||||||
|
# identity inside this recording, and labelling it with the old
|
||||||
|
# session id would make the files lie.
|
||||||
|
self.refused += 1
|
||||||
|
self.failed = True
|
||||||
|
self.reason = SOURCE_RESTARTED
|
||||||
|
return
|
||||||
|
self.recorder.on_packet(packet)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"expected_session_id": self.expected_session_id,
|
||||||
|
"packets": self.packets,
|
||||||
|
"refused": self.refused,
|
||||||
|
"failed": self.failed,
|
||||||
|
"reason": self.reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CaptureVerdict:
|
||||||
|
"""The one answer about whether a recording is usable evidence."""
|
||||||
|
|
||||||
|
status: str
|
||||||
|
reason: str = ""
|
||||||
|
report: dict | None = None
|
||||||
|
frames: int = 0
|
||||||
|
video_frames: int = 0
|
||||||
|
segments: int = 0
|
||||||
|
dropped_data: int = 0
|
||||||
|
dropped_video: int = 0
|
||||||
|
duration_s: float = 0.0
|
||||||
|
source_restarted: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def complete(self) -> bool:
|
||||||
|
return self.status == COMPLETE
|
||||||
|
|
||||||
|
|
||||||
|
def probe_segments(directory: Path | None) -> tuple[bool, str]:
|
||||||
|
"""Decode the first frame of every segment. A non-empty file is not a video."""
|
||||||
|
if directory is None:
|
||||||
|
return False, "no directory"
|
||||||
|
segments = sorted(Path(directory).glob("raw_*.mp4"))
|
||||||
|
if not segments:
|
||||||
|
return False, "no segment file"
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
except ImportError: # pragma: no cover - cv2 is a recording dependency
|
||||||
|
# The recorder cannot have written an MP4 without cv2 either, so this is
|
||||||
|
# a broken environment rather than an undeodable recording; it is still
|
||||||
|
# not evidence and must not pass silently.
|
||||||
|
return False, "cv2 unavailable"
|
||||||
|
for path in segments:
|
||||||
|
capture = cv2.VideoCapture(str(path))
|
||||||
|
try:
|
||||||
|
if not capture.isOpened():
|
||||||
|
return False, path.name
|
||||||
|
ok, _image = capture.read()
|
||||||
|
if not ok:
|
||||||
|
return False, path.name
|
||||||
|
finally:
|
||||||
|
capture.release()
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def capture_report_dict(recorder, guard: CaptureGuard | None = None) -> dict | None:
|
||||||
|
if recorder is None:
|
||||||
|
return None
|
||||||
|
report = recorder.report
|
||||||
|
directory = recorder.directory
|
||||||
|
payload: dict = {
|
||||||
|
"directory": str(directory) if directory is not None else "",
|
||||||
|
"state": recorder.state,
|
||||||
|
"guard": guard.as_dict() if guard is not None else None,
|
||||||
|
}
|
||||||
|
if report is not None:
|
||||||
|
payload.update({
|
||||||
|
"completed": bool(report.completed),
|
||||||
|
"stats": report.stats.as_dict(),
|
||||||
|
"problems": list(report.problems),
|
||||||
|
})
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def capture_verdict(
|
||||||
|
recorder,
|
||||||
|
*,
|
||||||
|
complete: bool,
|
||||||
|
reason: str = "",
|
||||||
|
guard: CaptureGuard | None = None,
|
||||||
|
elapsed_s: float | None = None,
|
||||||
|
timeout_s: float = CLOSING_TIMEOUT_S,
|
||||||
|
probe_video: bool = True,
|
||||||
|
source_stopped: bool = False,
|
||||||
|
video_expected: bool = True,
|
||||||
|
) -> CaptureVerdict:
|
||||||
|
"""Decide whether the closed recording is evidence, and say why not.
|
||||||
|
|
||||||
|
``pending`` is returned while the writer is still draining, so a caller can
|
||||||
|
keep polling instead of blocking its event loop. Both recording paths pass
|
||||||
|
their own abort reason: the operator's reason is more useful than a generic
|
||||||
|
token and is what the existing single-trial contract already records.
|
||||||
|
|
||||||
|
``video_expected=False`` is the operator's recorded choice not to keep a
|
||||||
|
video. The video-specific judgements (a missing frame, an unexpected segment
|
||||||
|
count, dropped video frames, an undecodable file) are then skipped, because
|
||||||
|
there is nothing to find; the data rows must still have been written and
|
||||||
|
dropped data still keeps the evidence incomplete. The default keeps every
|
||||||
|
existing caller's meaning: a video was expected.
|
||||||
|
"""
|
||||||
|
if recorder is None:
|
||||||
|
# Nothing was recorded, so nothing is evidence. A simulation and a
|
||||||
|
# missing camera both land here; the caller decides whether a capture was
|
||||||
|
# expected at all, and the reason names the case.
|
||||||
|
return CaptureVerdict(INCOMPLETE, reason or NOT_RECORDED)
|
||||||
|
|
||||||
|
report = recorder.report
|
||||||
|
if report is None:
|
||||||
|
if elapsed_s is not None and elapsed_s > float(timeout_s):
|
||||||
|
return CaptureVerdict(
|
||||||
|
INCOMPLETE, CLOSING_TIMEOUT,
|
||||||
|
report=capture_report_dict(recorder, guard),
|
||||||
|
)
|
||||||
|
return CaptureVerdict(PENDING, report=capture_report_dict(recorder, guard))
|
||||||
|
|
||||||
|
stats = report.stats
|
||||||
|
base = dict(
|
||||||
|
report=capture_report_dict(recorder, guard),
|
||||||
|
frames=stats.frames,
|
||||||
|
video_frames=stats.video_frames,
|
||||||
|
segments=stats.segments,
|
||||||
|
dropped_data=stats.dropped_data,
|
||||||
|
dropped_video=stats.dropped_video,
|
||||||
|
duration_s=stats.duration_s,
|
||||||
|
source_restarted=bool(guard is not None and guard.failed),
|
||||||
|
)
|
||||||
|
|
||||||
|
def verdict(status: str, detail: str = "", **extra) -> CaptureVerdict:
|
||||||
|
return CaptureVerdict(status, detail, **{**base, **extra})
|
||||||
|
|
||||||
|
if guard is not None and guard.failed:
|
||||||
|
return verdict(INCOMPLETE, SOURCE_RESTARTED)
|
||||||
|
if source_stopped:
|
||||||
|
# The source stopped while holds were still being recorded: the frames
|
||||||
|
# that exist stop describing the end of the repeat.
|
||||||
|
return verdict(INCOMPLETE, SOURCE_STOPPED)
|
||||||
|
if not report.completed:
|
||||||
|
return verdict(INCOMPLETE, CLOSING_TIMEOUT)
|
||||||
|
if stats.failed or stats.write_errors:
|
||||||
|
return verdict(INCOMPLETE, WRITE_ERROR)
|
||||||
|
if not complete:
|
||||||
|
return verdict(INCOMPLETE, reason or ABORTED)
|
||||||
|
if stats.frames <= 0:
|
||||||
|
return verdict(INCOMPLETE, ZERO_FRAMES)
|
||||||
|
if video_expected:
|
||||||
|
if stats.video_frames <= 0:
|
||||||
|
return verdict(INCOMPLETE, VIDEO_MISSING)
|
||||||
|
if stats.segments != 1:
|
||||||
|
return verdict(INCOMPLETE, SEGMENT_COUNT)
|
||||||
|
if stats.dropped_data:
|
||||||
|
return verdict(INCOMPLETE, DROPPED_DATA)
|
||||||
|
if video_expected and stats.dropped_video:
|
||||||
|
return verdict(INCOMPLETE, DROPPED_VIDEO)
|
||||||
|
if video_expected and probe_video:
|
||||||
|
ok, detail = probe_segments(report.directory)
|
||||||
|
if not ok:
|
||||||
|
payload = dict(base["report"] or {})
|
||||||
|
payload["probe_detail"] = detail
|
||||||
|
return verdict(INCOMPLETE, VIDEO_UNDECODABLE, report=payload)
|
||||||
|
return verdict(COMPLETE)
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Offline force-hold policy. Outputs are advice, never motor commands.
|
||||||
|
|
||||||
|
Depth is measured indentation from an established contact zero, positive into
|
||||||
|
the rubber, in mm. It is not a geometric contact height or a motor coordinate.
|
||||||
|
The caller owns serialized updates, synchronized depth readback and stop on
|
||||||
|
fault. A future executor must revalidate motion limits and require a watchdog;
|
||||||
|
zero advice does not cancel a previously issued hardware velocity command.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, fields
|
||||||
|
from typing import Literal
|
||||||
|
import math
|
||||||
|
|
||||||
|
from .reference_force import NormalForceSample
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ForceControlConfig:
|
||||||
|
target_n: float
|
||||||
|
kp_mm_per_n_s: float
|
||||||
|
ki_mm_per_n_s2: float
|
||||||
|
kd_mm_per_n: float
|
||||||
|
max_force_n: float
|
||||||
|
max_speed_mm_s: float
|
||||||
|
max_depth_mm: float
|
||||||
|
max_sample_age_s: float
|
||||||
|
max_duration_s: float
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for item in fields(self):
|
||||||
|
value = getattr(self, item.name)
|
||||||
|
if not math.isfinite(value) or value < 0:
|
||||||
|
raise ValueError(f"{item.name} must be finite and nonnegative")
|
||||||
|
if item.name.startswith("max_") and value == 0:
|
||||||
|
raise ValueError(f"{item.name} must be positive")
|
||||||
|
if self.target_n > self.max_force_n:
|
||||||
|
raise ValueError("target_n exceeds max_force_n")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ForceDecision:
|
||||||
|
"""One recordable observation; positive velocity increases indentation."""
|
||||||
|
|
||||||
|
sample: NormalForceSample
|
||||||
|
depth_mm: float
|
||||||
|
target_n: float
|
||||||
|
velocity_mm_s: float = 0.0
|
||||||
|
error_n: float | None = None
|
||||||
|
force_rate_n_s: float | None = None
|
||||||
|
fault: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ForceController:
|
||||||
|
"""PID hold or fixed-position relaxation recording after contact acquisition.
|
||||||
|
|
||||||
|
Gains have explicit dimensions and no equipment defaults. Derivative acts
|
||||||
|
on measured force; conditional integration prevents speed-limit windup.
|
||||||
|
This is not a contact search, an unload sequence or a real-time scheduler.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: ForceControlConfig,
|
||||||
|
*,
|
||||||
|
mode: Literal["compensate", "record_relaxation"] = "record_relaxation",
|
||||||
|
) -> None:
|
||||||
|
if mode not in ("compensate", "record_relaxation"):
|
||||||
|
raise ValueError("unknown relaxation mode")
|
||||||
|
self.config = config
|
||||||
|
self.mode = mode
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Begin a new bounded hold; the caller must separately stop hardware."""
|
||||||
|
self._previous: NormalForceSample | None = None
|
||||||
|
self._start_ns: int | None = None
|
||||||
|
self._integral = 0.0
|
||||||
|
self._fault = ""
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self, sample: NormalForceSample, *, depth_mm: float, now_ns: int
|
||||||
|
) -> ForceDecision:
|
||||||
|
"""Consume a fresh calibrated sample in the host monotonic time domain.
|
||||||
|
|
||||||
|
Caller must invoke on missing-data ticks too (with its last sample), so
|
||||||
|
age detection still runs. Faults latch until explicit reset.
|
||||||
|
"""
|
||||||
|
cfg = self.config
|
||||||
|
fault = self._fault
|
||||||
|
previous = self._previous
|
||||||
|
age_s = (now_ns - sample.t_host_mono_ns) / 1e9
|
||||||
|
dt = (
|
||||||
|
0.0
|
||||||
|
if previous is None
|
||||||
|
else (sample.t_host_mono_ns - previous.t_host_mono_ns) / 1e9
|
||||||
|
)
|
||||||
|
if not fault:
|
||||||
|
if (
|
||||||
|
not sample.valid
|
||||||
|
or not sample.source_id
|
||||||
|
or not sample.calibration_id
|
||||||
|
or not math.isfinite(sample.force_n)
|
||||||
|
or sample.t_host_mono_ns < 0
|
||||||
|
):
|
||||||
|
fault = "invalid_reference"
|
||||||
|
elif not 0 <= age_s <= cfg.max_sample_age_s:
|
||||||
|
fault = "stale_or_future_reference"
|
||||||
|
elif previous is not None and not 0 < dt <= cfg.max_sample_age_s:
|
||||||
|
fault = "reference_time_discontinuity"
|
||||||
|
elif previous is not None and (sample.source_id, sample.calibration_id) != (
|
||||||
|
previous.source_id,
|
||||||
|
previous.calibration_id,
|
||||||
|
):
|
||||||
|
fault = "reference_identity_changed"
|
||||||
|
elif abs(sample.force_n) > cfg.max_force_n:
|
||||||
|
fault = "force_limit"
|
||||||
|
elif not math.isfinite(depth_mm) or not 0 <= depth_mm <= cfg.max_depth_mm:
|
||||||
|
fault = "depth_limit"
|
||||||
|
elif (
|
||||||
|
self._start_ns is not None
|
||||||
|
and (now_ns - self._start_ns) / 1e9 > cfg.max_duration_s
|
||||||
|
):
|
||||||
|
fault = "hold_timeout"
|
||||||
|
if fault:
|
||||||
|
self._fault = fault
|
||||||
|
return ForceDecision(sample, depth_mm, cfg.target_n, fault=fault)
|
||||||
|
|
||||||
|
if self._start_ns is None:
|
||||||
|
self._start_ns = now_ns
|
||||||
|
error = cfg.target_n - sample.force_n
|
||||||
|
rate = None if previous is None else (sample.force_n - previous.force_n) / dt
|
||||||
|
self._previous = sample
|
||||||
|
velocity = 0.0
|
||||||
|
if self.mode == "compensate":
|
||||||
|
candidate = self._integral + error * dt
|
||||||
|
proportional_derivative = cfg.kp_mm_per_n_s * error - cfg.kd_mm_per_n * (
|
||||||
|
rate or 0.0
|
||||||
|
)
|
||||||
|
requested = proportional_derivative + cfg.ki_mm_per_n_s2 * candidate
|
||||||
|
if not all(math.isfinite(value) for value in (candidate, requested)):
|
||||||
|
self._fault = "numerical_failure"
|
||||||
|
return ForceDecision(sample, depth_mm, cfg.target_n, fault=self._fault)
|
||||||
|
# Bound the next sample-age horizon, using measured depth, not integrated advice.
|
||||||
|
lower = max(-cfg.max_speed_mm_s, -depth_mm / cfg.max_sample_age_s)
|
||||||
|
upper = min(
|
||||||
|
cfg.max_speed_mm_s, (cfg.max_depth_mm - depth_mm) / cfg.max_sample_age_s
|
||||||
|
)
|
||||||
|
if not (
|
||||||
|
(requested > upper and error > 0) or (requested < lower and error < 0)
|
||||||
|
):
|
||||||
|
self._integral = candidate
|
||||||
|
velocity = min(upper, max(lower, requested))
|
||||||
|
if (requested > 0 and depth_mm >= cfg.max_depth_mm) or (
|
||||||
|
requested < 0 and depth_mm <= 0
|
||||||
|
):
|
||||||
|
self._fault = "depth_limit"
|
||||||
|
velocity = 0.0
|
||||||
|
return ForceDecision(
|
||||||
|
sample, depth_mm, cfg.target_n, velocity, error, rate, self._fault
|
||||||
|
)
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
"""Frame and force identity inside one shared-recorder directory.
|
||||||
|
|
||||||
|
A recording directory holds ``frames.csv`` (the frame index: host acquisition
|
||||||
|
time, video position, drop state) and ``forces.csv`` (the force rows). The two
|
||||||
|
are joined by ``frame_id``. A row's position in either file is not an identity:
|
||||||
|
the acquisition queue can lose a row, and a camera that restarts begins its
|
||||||
|
frame numbers again, so pairing by line number silently moves a sample to the
|
||||||
|
wrong window.
|
||||||
|
|
||||||
|
The policy for a repeated ``frame_id`` inside one directory is to **exclude**
|
||||||
|
the samples it would pair and record the reason. Refusing the whole recording
|
||||||
|
would leave an operator unable to open the evidence that shows the problem;
|
||||||
|
inventing an order instead is what this module exists to stop.
|
||||||
|
|
||||||
|
Read-only: nothing here writes, and no Qt is imported.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
#: The force components the review and the summary tables report separately.
|
||||||
|
AXES: tuple[str, ...] = ("FN", "FX", "FY")
|
||||||
|
#: Raw column per component. Newton columns are not the same measurement.
|
||||||
|
VALUE_COLUMNS: dict[str, str] = {
|
||||||
|
"FN": "FN_raw",
|
||||||
|
"FX": "FX_raw",
|
||||||
|
"FY": "FY_raw",
|
||||||
|
}
|
||||||
|
|
||||||
|
DROPPED_REASON = "dropped_row"
|
||||||
|
DUPLICATE_REASON = "duplicate_frame_identity"
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(path: Path) -> list[dict]:
|
||||||
|
with path.open(encoding="utf-8-sig", newline="") as handle:
|
||||||
|
return list(csv.DictReader(handle))
|
||||||
|
|
||||||
|
|
||||||
|
def _text(row: dict, key: str) -> str:
|
||||||
|
return str(row.get(key) or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _int(row: dict, key: str) -> int | None:
|
||||||
|
"""An integer cell, parsed exactly.
|
||||||
|
|
||||||
|
A host monotonic stamp is around 1e18 ns, which a float64 cannot hold to the
|
||||||
|
nanosecond: parsing through ``float`` would map neighbouring frames onto one
|
||||||
|
timestamp and make the identity join look like it worked. Integers are read
|
||||||
|
as integers; a decimal spelling is accepted only for its integer part.
|
||||||
|
"""
|
||||||
|
text = _text(row, key)
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(text)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return int(float(text))
|
||||||
|
except (ValueError, OverflowError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _flag(row: dict, key: str) -> bool:
|
||||||
|
"""A 0/1 column. A missing column is *unknown*, which callers decide about."""
|
||||||
|
return _text(row, key) == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def _number(row: dict, key: str) -> float | None:
|
||||||
|
text = _text(row, key)
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = float(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return value if math.isfinite(value) else None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FrameRow:
|
||||||
|
"""One line of ``frames.csv``, before any identity decision."""
|
||||||
|
|
||||||
|
frame_id: int
|
||||||
|
t_host_mono_ns: int | None
|
||||||
|
t_rel_s: float | None
|
||||||
|
time_basis: str
|
||||||
|
segment: int
|
||||||
|
segment_frame: int
|
||||||
|
#: ``None`` when the recording predates the ``in_video`` column.
|
||||||
|
in_video: bool | None
|
||||||
|
dropped: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Sample:
|
||||||
|
"""One force row paired with the acquisition time of its frame.
|
||||||
|
|
||||||
|
``values`` is aligned to :data:`AXES`. ``None`` means the cell was empty:
|
||||||
|
an axis without a raw channel is not zero.
|
||||||
|
"""
|
||||||
|
|
||||||
|
frame_id: int
|
||||||
|
t_host_mono_ns: int
|
||||||
|
row_index: int
|
||||||
|
valid: bool
|
||||||
|
values: tuple[float | None, float | None, float | None]
|
||||||
|
|
||||||
|
def value(self, axis: str) -> float | None:
|
||||||
|
return self.values[AXES.index(axis)]
|
||||||
|
|
||||||
|
def usable(self) -> bool:
|
||||||
|
"""A valid row with at least one finite raw component."""
|
||||||
|
return self.valid and any(value is not None for value in self.values)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class JoinReport:
|
||||||
|
"""What the join had to give up, so a summary can show it rather than hide it."""
|
||||||
|
|
||||||
|
frames_total: int = 0
|
||||||
|
frames_dropped: int = 0
|
||||||
|
frames_without_time: int = 0
|
||||||
|
frames_not_in_video: int = 0
|
||||||
|
duplicate_frame_ids: int = 0
|
||||||
|
force_rows: int = 0
|
||||||
|
unjoined_force_rows: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reasons(self) -> tuple[str, ...]:
|
||||||
|
found: list[str] = []
|
||||||
|
if self.duplicate_frame_ids:
|
||||||
|
found.append(DUPLICATE_REASON)
|
||||||
|
if self.frames_dropped:
|
||||||
|
found.append(DROPPED_REASON)
|
||||||
|
return tuple(found)
|
||||||
|
|
||||||
|
|
||||||
|
def read_frame_index(directory: Path) -> list[FrameRow]:
|
||||||
|
"""Every parseable ``frames.csv`` row, in file order.
|
||||||
|
|
||||||
|
Unparseable rows are skipped rather than sorted out: a hand-edited or
|
||||||
|
truncated index must not crash a review, and the skipped count is visible in
|
||||||
|
the index length.
|
||||||
|
"""
|
||||||
|
path = Path(directory) / "frames.csv"
|
||||||
|
if not path.is_file():
|
||||||
|
return []
|
||||||
|
has_video_column = False
|
||||||
|
frames: list[FrameRow] = []
|
||||||
|
with path.open(encoding="utf-8-sig", newline="") as handle:
|
||||||
|
reader = csv.DictReader(handle)
|
||||||
|
has_video_column = reader.fieldnames is not None and "in_video" in reader.fieldnames
|
||||||
|
for row in reader:
|
||||||
|
frame_id = _int(row, "frame_id")
|
||||||
|
if frame_id is None:
|
||||||
|
continue
|
||||||
|
frames.append(
|
||||||
|
FrameRow(
|
||||||
|
frame_id=frame_id,
|
||||||
|
t_host_mono_ns=_int(row, "t_host_mono_ns"),
|
||||||
|
t_rel_s=_number(row, "t_rel_s"),
|
||||||
|
time_basis=_text(row, "time_basis"),
|
||||||
|
segment=_int(row, "segment") or 0,
|
||||||
|
segment_frame=_int(row, "segment_frame") or 0,
|
||||||
|
in_video=_flag(row, "in_video") if has_video_column else None,
|
||||||
|
dropped=_flag(row, "dropped"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return frames
|
||||||
|
|
||||||
|
|
||||||
|
def read_force_rows(directory: Path) -> list[dict]:
|
||||||
|
path = Path(directory) / "forces.csv"
|
||||||
|
return _rows(path) if path.is_file() else []
|
||||||
|
|
||||||
|
|
||||||
|
def unique_frames(frames: list[FrameRow]) -> tuple[dict[int, FrameRow], set[int]]:
|
||||||
|
"""``(frame_id -> row, duplicated ids)``. A repeated id has no single meaning."""
|
||||||
|
index: dict[int, FrameRow] = {}
|
||||||
|
duplicated: set[int] = set()
|
||||||
|
for frame in frames:
|
||||||
|
if frame.frame_id in index:
|
||||||
|
duplicated.add(frame.frame_id)
|
||||||
|
else:
|
||||||
|
index[frame.frame_id] = frame
|
||||||
|
for frame_id in duplicated:
|
||||||
|
index.pop(frame_id, None)
|
||||||
|
return index, duplicated
|
||||||
|
|
||||||
|
|
||||||
|
def joined_samples(
|
||||||
|
directory: Path,
|
||||||
|
*,
|
||||||
|
require_video: bool = False,
|
||||||
|
) -> tuple[list[Sample], JoinReport]:
|
||||||
|
"""Pair force rows to frame host times by ``frame_id``.
|
||||||
|
|
||||||
|
``require_video=True`` keeps only frames the recorder wrote into the video
|
||||||
|
container. ``False`` keeps every non-dropped frame, which is the legacy
|
||||||
|
``stable_means`` rule: that table never filtered on video membership and its
|
||||||
|
numbers must not change for that reason alone.
|
||||||
|
"""
|
||||||
|
frames = read_frame_index(directory)
|
||||||
|
index, duplicated = unique_frames(frames)
|
||||||
|
report = JoinReport(
|
||||||
|
frames_total=len(frames),
|
||||||
|
frames_dropped=sum(1 for frame in frames if frame.dropped),
|
||||||
|
frames_without_time=sum(
|
||||||
|
1 for frame in frames if not frame.dropped and frame.t_host_mono_ns is None
|
||||||
|
),
|
||||||
|
frames_not_in_video=sum(1 for frame in frames if frame.in_video is False),
|
||||||
|
duplicate_frame_ids=len(duplicated),
|
||||||
|
)
|
||||||
|
usable: dict[int, int] = {}
|
||||||
|
for frame in index.values():
|
||||||
|
if frame.dropped or frame.t_host_mono_ns is None:
|
||||||
|
continue
|
||||||
|
if require_video and frame.in_video is False:
|
||||||
|
continue
|
||||||
|
usable[frame.frame_id] = frame.t_host_mono_ns
|
||||||
|
|
||||||
|
samples: list[Sample] = []
|
||||||
|
rows = read_force_rows(directory)
|
||||||
|
unjoined = 0
|
||||||
|
for position, row in enumerate(rows, start=1):
|
||||||
|
frame_id = _int(row, "frame_id")
|
||||||
|
stamp = None if frame_id is None else usable.get(frame_id)
|
||||||
|
if stamp is None:
|
||||||
|
unjoined += 1
|
||||||
|
continue
|
||||||
|
samples.append(
|
||||||
|
Sample(
|
||||||
|
frame_id=frame_id,
|
||||||
|
t_host_mono_ns=stamp,
|
||||||
|
row_index=position,
|
||||||
|
valid=_text(row, "valid") in ("", "1"),
|
||||||
|
values=tuple(_number(row, VALUE_COLUMNS[axis]) for axis in AXES),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
samples.sort(key=lambda sample: (sample.t_host_mono_ns, sample.row_index))
|
||||||
|
return samples, JoinReport(
|
||||||
|
frames_total=report.frames_total,
|
||||||
|
frames_dropped=report.frames_dropped,
|
||||||
|
frames_without_time=report.frames_without_time,
|
||||||
|
frames_not_in_video=report.frames_not_in_video,
|
||||||
|
duplicate_frame_ids=report.duplicate_frame_ids,
|
||||||
|
force_rows=len(rows),
|
||||||
|
unjoined_force_rows=unjoined,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def window_samples(
|
||||||
|
samples: list[Sample], start_ns: int, end_ns: int
|
||||||
|
) -> list[Sample]:
|
||||||
|
"""Samples whose acquisition time is in ``[start_ns, end_ns)``.
|
||||||
|
|
||||||
|
Sorting happens in :func:`joined_samples`, so a caller may bisect; a scan is
|
||||||
|
used here because a window is a small slice of the recording.
|
||||||
|
"""
|
||||||
|
return [sample for sample in samples if start_ns <= sample.t_host_mono_ns < end_ns]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_capture_directory(
|
||||||
|
root: Path, *, trial_directory: str, frame_source: str = ""
|
||||||
|
) -> Path | None:
|
||||||
|
"""Where a trial's frame files are: the trial directory or its ``capture/``.
|
||||||
|
|
||||||
|
The single-trial format keeps them beside ``reference.csv``; the multi-point
|
||||||
|
format nests them. The review already records the answer in
|
||||||
|
``frame_source``, and this function is the one place that falls back.
|
||||||
|
"""
|
||||||
|
root = Path(root)
|
||||||
|
source = str(frame_source or trial_directory or "")
|
||||||
|
if not source:
|
||||||
|
return None
|
||||||
|
candidate = (root / source).resolve()
|
||||||
|
if not candidate.is_relative_to(root.resolve()):
|
||||||
|
return None
|
||||||
|
if candidate.is_dir() and (candidate / "frames.csv").is_file():
|
||||||
|
return candidate
|
||||||
|
nested = candidate / "capture"
|
||||||
|
if (nested / "frames.csv").is_file():
|
||||||
|
return nested
|
||||||
|
return None
|
||||||
|
|
||||||
|
def reference_holds(directory: Path) -> list[dict]:
|
||||||
|
"""Every declared hold attempt in `reference.csv`, with its row ordinal.
|
||||||
|
|
||||||
|
`hold_id` is the data row's position, not a hash of the file: the remeasure
|
||||||
|
path rewrites the file in place (one flag changes), so a hash would change the
|
||||||
|
identity of a hold whose window did not move. A row whose times are unusable
|
||||||
|
is not a window and is skipped.
|
||||||
|
"""
|
||||||
|
import csv as _csv
|
||||||
|
|
||||||
|
path = Path(directory) / "reference.csv"
|
||||||
|
if not path.is_file():
|
||||||
|
return []
|
||||||
|
holds: list[dict] = []
|
||||||
|
with path.open(encoding="utf-8-sig", newline="") as handle:
|
||||||
|
for ordinal, row in enumerate(_csv.DictReader(handle), start=1):
|
||||||
|
start = _number(row, "t_start_host_mono_ns")
|
||||||
|
end = _number(row, "t_end_host_mono_ns")
|
||||||
|
if start is None or end is None or end <= start:
|
||||||
|
continue
|
||||||
|
step = row.get("step_index")
|
||||||
|
try:
|
||||||
|
step_index = int(float(step))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
step_index = -1
|
||||||
|
holds.append({
|
||||||
|
"hold_id": ordinal,
|
||||||
|
"step_index": step_index,
|
||||||
|
"branch": str(row.get("branch") or ""),
|
||||||
|
"target_load_n": _number(row, "target_load_n"),
|
||||||
|
"reference_load_n": _number(row, "F_ref_normal_N"),
|
||||||
|
"reference_source": str(row.get("reference_source") or ""),
|
||||||
|
"t_start_host_mono_ns": int(start),
|
||||||
|
"t_end_host_mono_ns": int(end),
|
||||||
|
"complete": str(row.get("hold_complete") or "0").strip() == "1",
|
||||||
|
})
|
||||||
|
return holds
|
||||||
@@ -20,8 +20,10 @@ from .pose_move import (
|
|||||||
MovePlan,
|
MovePlan,
|
||||||
MoveStep,
|
MoveStep,
|
||||||
PoseMover,
|
PoseMover,
|
||||||
|
PressAxis,
|
||||||
plan_pose_move,
|
plan_pose_move,
|
||||||
plan_return_to_initial,
|
plan_return_to_initial,
|
||||||
|
press_targets,
|
||||||
)
|
)
|
||||||
from .service import (
|
from .service import (
|
||||||
AXIS_ROLES,
|
AXIS_ROLES,
|
||||||
@@ -76,6 +78,7 @@ __all__ = [
|
|||||||
"MoveStep",
|
"MoveStep",
|
||||||
"NativeTransport",
|
"NativeTransport",
|
||||||
"PoseMover",
|
"PoseMover",
|
||||||
|
"PressAxis",
|
||||||
"Snapshot",
|
"Snapshot",
|
||||||
"ZmcTransport",
|
"ZmcTransport",
|
||||||
"clamp_bas_task",
|
"clamp_bas_task",
|
||||||
@@ -84,6 +87,7 @@ __all__ = [
|
|||||||
"normalize_axis_roles",
|
"normalize_axis_roles",
|
||||||
"plan_pose_move",
|
"plan_pose_move",
|
||||||
"plan_return_to_initial",
|
"plan_return_to_initial",
|
||||||
|
"press_targets",
|
||||||
"safe_float",
|
"safe_float",
|
||||||
"units_per_degree",
|
"units_per_degree",
|
||||||
"user_units_to_deg",
|
"user_units_to_deg",
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""Edges worth remembering from the controller's poll stream (Qt-free).
|
||||||
|
|
||||||
|
The axis table shows *now*. An alarm that came and went between two glances, a
|
||||||
|
drive that stopped answering for a second, or a homing run that ended without
|
||||||
|
completing leaves nothing on screen, and the operator is left with a machine that
|
||||||
|
"did something". The tracker turns the poll stream into a short list of dated
|
||||||
|
events: only changes, never one line per poll.
|
||||||
|
|
||||||
|
Nothing here talks to the controller; it reads :class:`~.models.Snapshot` values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from .models import AxisState, Snapshot
|
||||||
|
|
||||||
|
#: Event kinds. The page maps each one to a locale string.
|
||||||
|
CONNECTED = "connected"
|
||||||
|
DISCONNECTED = "disconnected"
|
||||||
|
ALARM_ON = "alarm_on"
|
||||||
|
ALARM_OFF = "alarm_off"
|
||||||
|
ENABLED = "enabled"
|
||||||
|
DISABLED = "disabled"
|
||||||
|
READ_FAILED = "read_failed"
|
||||||
|
READ_OK = "read_ok"
|
||||||
|
HOME_STARTED = "home_started"
|
||||||
|
HOME_DONE = "home_done"
|
||||||
|
HOME_INCOMPLETE = "home_incomplete"
|
||||||
|
UNITS_MISMATCH = "units_mismatch"
|
||||||
|
UNITS_OK = "units_ok"
|
||||||
|
|
||||||
|
#: How serious each kind is, for the colour the page gives the line.
|
||||||
|
SEVERITY = {
|
||||||
|
CONNECTED: "info",
|
||||||
|
DISCONNECTED: "warning",
|
||||||
|
ALARM_ON: "error",
|
||||||
|
ALARM_OFF: "info",
|
||||||
|
ENABLED: "info",
|
||||||
|
DISABLED: "info",
|
||||||
|
READ_FAILED: "warning",
|
||||||
|
READ_OK: "info",
|
||||||
|
HOME_STARTED: "info",
|
||||||
|
HOME_DONE: "info",
|
||||||
|
HOME_INCOMPLETE: "warning",
|
||||||
|
UNITS_MISMATCH: "error",
|
||||||
|
UNITS_OK: "info",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Platform-wide events carry this instead of an axis number.
|
||||||
|
NO_AXIS = -1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AxisEvent:
|
||||||
|
"""One dated change. ``detail`` is raw data (a reason, a number), not prose."""
|
||||||
|
|
||||||
|
time: float
|
||||||
|
axis: int
|
||||||
|
kind: str
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def severity(self) -> str:
|
||||||
|
return SEVERITY.get(self.kind, "info")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _AxisMemory:
|
||||||
|
alarm: bool
|
||||||
|
enabled: bool
|
||||||
|
read_ok: bool
|
||||||
|
homing: bool
|
||||||
|
units_ok: bool | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AxisEventTracker:
|
||||||
|
"""Remember the last state of each axis and report what changed."""
|
||||||
|
|
||||||
|
limit: int = 200
|
||||||
|
clock: Callable[[], float] = time.time
|
||||||
|
_events: list[AxisEvent] = field(default_factory=list)
|
||||||
|
_connected: bool | None = None
|
||||||
|
_axes: dict[int, _AxisMemory] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def events(self) -> list[AxisEvent]:
|
||||||
|
"""Oldest first."""
|
||||||
|
return list(self._events)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._events.clear()
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
snapshot: Snapshot,
|
||||||
|
expected_units: Mapping[int, float] | None = None,
|
||||||
|
) -> list[AxisEvent]:
|
||||||
|
"""Compare one poll with the previous one; return and keep the new events."""
|
||||||
|
fresh: list[AxisEvent] = []
|
||||||
|
now = float(self.clock())
|
||||||
|
|
||||||
|
def emit(axis: int, kind: str, detail: str = "") -> None:
|
||||||
|
fresh.append(AxisEvent(now, axis, kind, detail))
|
||||||
|
|
||||||
|
if self._connected is not None and snapshot.connected != self._connected:
|
||||||
|
emit(NO_AXIS, CONNECTED if snapshot.connected else DISCONNECTED)
|
||||||
|
self._connected = snapshot.connected
|
||||||
|
|
||||||
|
if not snapshot.connected:
|
||||||
|
# Whatever the axes did while the link was down is unknown: forget them,
|
||||||
|
# so the first poll after a reconnect is a baseline and not a flood.
|
||||||
|
self._axes.clear()
|
||||||
|
else:
|
||||||
|
for state in snapshot.axes:
|
||||||
|
self._update_axis(state, expected_units, emit)
|
||||||
|
|
||||||
|
self._events.extend(fresh)
|
||||||
|
if len(self._events) > self.limit:
|
||||||
|
del self._events[: len(self._events) - self.limit]
|
||||||
|
return fresh
|
||||||
|
|
||||||
|
def _update_axis(self, state: AxisState, expected_units, emit) -> None:
|
||||||
|
axis = state.axis
|
||||||
|
memory = self._axes.get(axis)
|
||||||
|
units_ok = self._units_ok(state, expected_units)
|
||||||
|
current = _AxisMemory(
|
||||||
|
alarm=bool(state.read_ok and state.status.alarm),
|
||||||
|
enabled=bool(state.read_ok and state.enabled),
|
||||||
|
read_ok=bool(state.read_ok),
|
||||||
|
homing=bool(state.read_ok and state.status.home_in_progress),
|
||||||
|
units_ok=units_ok,
|
||||||
|
)
|
||||||
|
if memory is None:
|
||||||
|
# The first sight of an axis is a baseline, except for the two facts that
|
||||||
|
# matter whenever they are true: an alarm already raised, and a drive that
|
||||||
|
# cannot be read.
|
||||||
|
if current.alarm:
|
||||||
|
emit(axis, ALARM_ON)
|
||||||
|
if not current.read_ok:
|
||||||
|
emit(axis, READ_FAILED, state.error)
|
||||||
|
if units_ok is False:
|
||||||
|
emit(axis, UNITS_MISMATCH, _units_detail(state, expected_units))
|
||||||
|
self._axes[axis] = current
|
||||||
|
return
|
||||||
|
|
||||||
|
if current.read_ok != memory.read_ok:
|
||||||
|
if current.read_ok:
|
||||||
|
emit(axis, READ_OK)
|
||||||
|
else:
|
||||||
|
emit(axis, READ_FAILED, state.error)
|
||||||
|
if current.read_ok and memory.read_ok:
|
||||||
|
if current.alarm != memory.alarm:
|
||||||
|
emit(axis, ALARM_ON if current.alarm else ALARM_OFF)
|
||||||
|
if current.enabled != memory.enabled:
|
||||||
|
emit(axis, ENABLED if current.enabled else DISABLED)
|
||||||
|
if current.homing != memory.homing:
|
||||||
|
if current.homing:
|
||||||
|
emit(axis, HOME_STARTED)
|
||||||
|
else:
|
||||||
|
emit(axis, HOME_DONE if state.home_status == 1 else HOME_INCOMPLETE)
|
||||||
|
if units_ok is not None and units_ok != memory.units_ok:
|
||||||
|
if units_ok:
|
||||||
|
if memory.units_ok is False:
|
||||||
|
emit(axis, UNITS_OK)
|
||||||
|
else:
|
||||||
|
emit(axis, UNITS_MISMATCH, _units_detail(state, expected_units))
|
||||||
|
self._axes[axis] = current
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _units_ok(state: AxisState, expected_units) -> bool | None:
|
||||||
|
"""``None`` when there is nothing to compare, else whether the two agree."""
|
||||||
|
if expected_units is None or not state.read_ok:
|
||||||
|
return None
|
||||||
|
expected = expected_units.get(state.axis)
|
||||||
|
if expected is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
expected = float(expected)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if not math.isfinite(expected) or expected <= 0:
|
||||||
|
return None
|
||||||
|
observed = float(state.units)
|
||||||
|
return bool(
|
||||||
|
math.isfinite(observed)
|
||||||
|
and observed > 0
|
||||||
|
and math.isclose(expected, observed, rel_tol=1e-7, abs_tol=0.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _units_detail(state: AxisState, expected_units) -> str:
|
||||||
|
expected = (expected_units or {}).get(state.axis)
|
||||||
|
return f"{_g(expected)}|{_g(state.units)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _g(value: object) -> str:
|
||||||
|
try:
|
||||||
|
return f"{float(value):g}"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "—"
|
||||||
@@ -33,8 +33,11 @@ import numpy as np
|
|||||||
from ..sensor.frames import (
|
from ..sensor.frames import (
|
||||||
AXIS_MAP,
|
AXIS_MAP,
|
||||||
PITCH_AXIS,
|
PITCH_AXIS,
|
||||||
|
PITCH_COMMAND_SIGN,
|
||||||
TOOL_AXIS,
|
TOOL_AXIS,
|
||||||
YAW_AXIS,
|
YAW_AXIS,
|
||||||
|
YAW_COMMAND_SIGN,
|
||||||
|
machine_table_xy,
|
||||||
mount_rotation,
|
mount_rotation,
|
||||||
to_platform,
|
to_platform,
|
||||||
to_sensor,
|
to_sensor,
|
||||||
@@ -43,6 +46,13 @@ from ..sensor.frames import (
|
|||||||
#: A normal this close to the tool axis counts as already aligned.
|
#: A normal this close to the tool axis counts as already aligned.
|
||||||
ALIGNED_TOL_DEG = 0.01
|
ALIGNED_TOL_DEG = 0.01
|
||||||
|
|
||||||
|
#: A normal already within this many degrees of the tool axis is not yawed.
|
||||||
|
#: Pitch nods about the left-right axis, so a leftover lean along that axis can
|
||||||
|
#: only be removed by yawing a quarter-turn first. On the crown that leftover is
|
||||||
|
#: about 1.6°, and the quarter-turn turns a fore-aft table move into a sideways
|
||||||
|
#: one. Before the drive axes were measured, the same point was yaw 0 and a Y move.
|
||||||
|
CROWN_RESIDUAL_DEG = 3.0
|
||||||
|
|
||||||
#: Sweep step, degrees, for each stage of the alignment search. The sweep only
|
#: Sweep step, degrees, for each stage of the alignment search. The sweep only
|
||||||
#: has to land in the right basin — the polish that follows refines it to 1e-4 —
|
#: has to land in the right basin — the polish that follows refines it to 1e-4 —
|
||||||
#: so this trades a little precision for a sweep the eye can be told about.
|
#: so this trades a little precision for a sweep the eye can be told about.
|
||||||
@@ -72,6 +82,14 @@ _UPRIGHT_TIE = 1e-6
|
|||||||
#: has to absorb the sweep's own quantisation, not excuse a real misalignment.
|
#: has to absorb the sweep's own quantisation, not excuse a real misalignment.
|
||||||
_UPRIGHT_TOL_DEG = 0.05
|
_UPRIGHT_TOL_DEG = 0.05
|
||||||
|
|
||||||
|
#: Two exact poses whose residuals differ by less than this are the *same* alignment
|
||||||
|
#: to any tolerance the machine can hold, so the choice between them must not turn on
|
||||||
|
#: which one the arithmetic put a hair lower. They are compared as equals and the
|
||||||
|
#: machine decides (travel, reachability, which way up). The closed form puts both
|
||||||
|
#: within floating point — or reports the same residual twice — so this band is what
|
||||||
|
#: keeps the reported pose from moving when nothing moved.
|
||||||
|
_BRANCH_TIE_DEG = 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def rotation_about(axis: str, degrees: float) -> np.ndarray:
|
def rotation_about(axis: str, degrees: float) -> np.ndarray:
|
||||||
@@ -100,7 +118,11 @@ class Branch:
|
|||||||
|
|
||||||
Both branches put the normal on the tool axis to within floating point, so
|
Both branches put the normal on the tool axis to within floating point, so
|
||||||
what separates them is the machine: whether the pose is inside the stage
|
what separates them is the machine: whether the pose is inside the stage
|
||||||
travel, and which way up it leaves the sensing face.
|
travel, which way up it leaves the sensing face, and how far the stages have to
|
||||||
|
turn to get there. The two branches are not two similar poses: they are a
|
||||||
|
half-turn apart about the vertical (``R_z(180°)`` of each other), so they reach
|
||||||
|
the same contact height with different body orientations. A half-turn of yaw
|
||||||
|
does not by itself mean that the sensing face points down.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
yaw_deg: float
|
yaw_deg: float
|
||||||
@@ -109,10 +131,21 @@ class Branch:
|
|||||||
upright: bool
|
upright: bool
|
||||||
in_range: bool
|
in_range: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def travel_deg(self) -> float:
|
||||||
|
"""How far the two stages turn to reach this pose, degrees.
|
||||||
|
|
||||||
|
The sum of the absolute angles, which is what the machine pays: a half-turn
|
||||||
|
of yaw instead of a small one is the difference between the two branches,
|
||||||
|
and it is the number the operator needs to hear when it is chosen.
|
||||||
|
"""
|
||||||
|
return abs(self.yaw_deg) + abs(self.pitch_deg)
|
||||||
|
|
||||||
def describe(self) -> str:
|
def describe(self) -> str:
|
||||||
return (
|
return (
|
||||||
f"yaw {self.yaw_deg:+.3f} pitch {self.pitch_deg:+.3f} "
|
f"yaw {self.yaw_deg:+.3f} pitch {self.pitch_deg:+.3f} "
|
||||||
f"residual {self.residual_deg:.6f} "
|
f"residual {self.residual_deg:.6f} "
|
||||||
|
f"travel {self.travel_deg:.3f} "
|
||||||
f"{'upright' if self.upright else 'inverted'}"
|
f"{'upright' if self.upright else 'inverted'}"
|
||||||
f"{'' if self.in_range else ' out-of-range'}"
|
f"{'' if self.in_range else ' out-of-range'}"
|
||||||
)
|
)
|
||||||
@@ -123,13 +156,31 @@ class Pose:
|
|||||||
"""One solved platform pose, or the reason there is not one."""
|
"""One solved platform pose, or the reason there is not one."""
|
||||||
|
|
||||||
ok: bool
|
ok: bool
|
||||||
#: Platform-frame coordinates of the contact point, mm — where the point *is*,
|
#: Platform-frame XY of the contact point **as mounted**, mm: the sensor's
|
||||||
#: with the sensor's mounting pose applied.
|
#: mounting pose and the plate applied, the stages still at zero. It is where
|
||||||
|
#: the point sits on the machine, not where the machine has to go — the move is
|
||||||
|
#: :attr:`table_xy_mm`. The stages turn the point away from here, so this is
|
||||||
|
#: *not* its position while the pose is executed; that is
|
||||||
|
#: :attr:`contact_point_mm`.
|
||||||
target_xy_mm: tuple[float, float] = (0.0, 0.0)
|
target_xy_mm: tuple[float, float] = (0.0, 0.0)
|
||||||
#: Platform-frame Z of the contact point, mm. Carries the mounting plate and
|
#: Platform-frame coordinates of the contact point as mounted, mm: the same
|
||||||
#: any Z tool offset, which ``target_xy_mm`` cannot: without it those inputs
|
#: point as ``target_xy_mm`` with its Z, so the mounting geometry reads as one
|
||||||
#: are computed and then discarded, and nothing downstream can tell.
|
#: triple and can be checked against the setup.
|
||||||
|
mounted_point_mm: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||||
|
#: Platform-frame Z of the contact point **once the stages have turned**, mm:
|
||||||
|
#: the height the contact actually reaches at the solved pose, which is the
|
||||||
|
#: number any press-axis target has to be related to. Carries the mounting plate
|
||||||
|
#: and any Z tool offset. The unrotated height is ``mounted_point_mm``'s Z and
|
||||||
|
#: it is a different number: the stages turn the point about the table origin,
|
||||||
|
#: so a 47° pose lifts it by several millimetres. Reporting the unrotated Z here
|
||||||
|
#: is what made every commanded height wrong by that lift.
|
||||||
contact_z_mm: float = 0.0
|
contact_z_mm: float = 0.0
|
||||||
|
#: The contact point at the **end of the move**, mm: after the stages turn and
|
||||||
|
#: the table slides. Its XY is the tool position — bringing the point there is
|
||||||
|
#: what the move is for — and its Z is ``contact_z_mm``. Reported so the three
|
||||||
|
#: points in the chain (as mounted, after the stages, at the tool) cannot be
|
||||||
|
#: confused for one another again.
|
||||||
|
contact_point_mm: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||||
#: The move the XY table has to make, mm: the tool's position minus where the
|
#: The move the XY table has to make, mm: the tool's position minus where the
|
||||||
#: contact ends up after the stages turn. This is the number a run commands,
|
#: contact ends up after the stages turn. This is the number a run commands,
|
||||||
#: and it is what the preview animates — ``target_xy_mm`` says where the point
|
#: and it is what the preview animates — ``target_xy_mm`` says where the point
|
||||||
@@ -143,14 +194,25 @@ class Pose:
|
|||||||
residual_deg: float = 0.0
|
residual_deg: float = 0.0
|
||||||
#: Whether the yaw could be determined at all (indeterminate at the pole).
|
#: Whether the yaw could be determined at all (indeterminate at the pole).
|
||||||
yaw_determined: bool = True
|
yaw_determined: bool = True
|
||||||
#: Whether the pose leaves the sensing face pointing along the tool axis. A
|
#: Whether the pose leaves the *body* up — the axis the mount sends to the
|
||||||
#: pose can align the normal perfectly and still be turned over; the residual
|
#: platform's vertical — still pointing up. A pose can align the normal
|
||||||
#: alone cannot say which, so it is reported rather than inferred.
|
#: perfectly and still be turned over: the two branches of a measured point are
|
||||||
|
#: exactly that, half a turn apart about the vertical. The residual cannot tell
|
||||||
|
#: them apart, so this is measured and reported rather than inferred.
|
||||||
upright: bool = True
|
upright: bool = True
|
||||||
#: False when the chosen pose is outside the stage travel — reported, never
|
#: False when the chosen pose is outside the stage travel — reported, never
|
||||||
#: silently clamped.
|
#: silently clamped.
|
||||||
in_range: bool = True
|
in_range: bool = True
|
||||||
reason: str = ""
|
reason: str = ""
|
||||||
|
#: Which selection key decided this pose, as a key the panel names in words:
|
||||||
|
#: ``only``, ``alignment``, ``in_range``, ``upright`` or ``smallest``. The rule
|
||||||
|
#: is a machine convention, so it is shown with the pose instead of assumed.
|
||||||
|
branch_reason: str = ""
|
||||||
|
#: How far the stages turn for this pose, degrees.
|
||||||
|
branch_travel_deg: float = 0.0
|
||||||
|
#: Total travel of the pose that was *not* chosen, degrees, when the closed form
|
||||||
|
#: offered one. Zero when this pose was the only candidate.
|
||||||
|
other_travel_deg: float = 0.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def reached_tolerance(self) -> bool:
|
def reached_tolerance(self) -> bool:
|
||||||
@@ -162,17 +224,24 @@ class Pose:
|
|||||||
|
|
||||||
False means the geometry cannot bring the tool axis onto the normal, no
|
False means the geometry cannot bring the tool axis onto the normal, no
|
||||||
matter the angles — a statement about the machine, not about the search.
|
matter the angles — a statement about the machine, not about the search.
|
||||||
|
A crown point kept upright on purpose is reachable: the quarter-turn that
|
||||||
|
would erase the last couple of degrees is declined, not impossible.
|
||||||
"""
|
"""
|
||||||
return self.ok and self.residual_deg <= 0.5
|
if not self.ok:
|
||||||
|
return False
|
||||||
|
if self.branch_reason == "crown":
|
||||||
|
return True
|
||||||
|
return self.residual_deg <= 0.5
|
||||||
|
|
||||||
def describe(self) -> str:
|
def describe(self) -> str:
|
||||||
if not self.ok:
|
if not self.ok:
|
||||||
return f"no pose: {self.reason}"
|
return f"no pose: {self.reason}"
|
||||||
yaw = f"{self.yaw_deg:+.3f}" if self.yaw_determined else "indeterminate"
|
yaw = f"{self.yaw_deg:+.3f}" if self.yaw_determined else "indeterminate"
|
||||||
|
move_x, move_y = machine_table_xy(self.table_xy_mm)
|
||||||
return (
|
return (
|
||||||
f"X {self.target_xy_mm[0]:+.3f} Y {self.target_xy_mm[1]:+.3f} "
|
f"X {self.target_xy_mm[0]:+.3f} Y {self.target_xy_mm[1]:+.3f} "
|
||||||
f"Z {self.contact_z_mm:+.3f} move {self.table_xy_mm[0]:+.3f} "
|
f"Z {self.contact_z_mm:+.3f} move {move_x:+.3f} "
|
||||||
f"{self.table_xy_mm[1]:+.3f} yaw {yaw} pitch {self.pitch_deg:+.3f} "
|
f"{move_y:+.3f} yaw {yaw} pitch {self.pitch_deg:+.3f} "
|
||||||
f"residual {self.residual_deg:.4f} deg"
|
f"residual {self.residual_deg:.4f} deg"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -184,6 +253,12 @@ class PlatformStack:
|
|||||||
axis_map: dict[str, str] = field(default_factory=lambda: dict(AXIS_MAP))
|
axis_map: dict[str, str] = field(default_factory=lambda: dict(AXIS_MAP))
|
||||||
yaw_axis: str = YAW_AXIS
|
yaw_axis: str = YAW_AXIS
|
||||||
pitch_axis: str = PITCH_AXIS
|
pitch_axis: str = PITCH_AXIS
|
||||||
|
#: Commanded degrees times this is the right-hand angle about ``yaw_axis``.
|
||||||
|
#: ``-1`` matches the drive: positive yaw is clockwise from above.
|
||||||
|
yaw_sign: float = YAW_COMMAND_SIGN
|
||||||
|
#: Commanded degrees times this is the right-hand angle about ``pitch_axis``.
|
||||||
|
#: ``+1`` matches the drive: positive pitch nods the dome toward platform −Y.
|
||||||
|
pitch_sign: float = PITCH_COMMAND_SIGN
|
||||||
tool_axis: str = TOOL_AXIS
|
tool_axis: str = TOOL_AXIS
|
||||||
#: Which way along ``tool_axis`` the sensing face is aimed, relative to the
|
#: Which way along ``tool_axis`` the sensing face is aimed, relative to the
|
||||||
#: normal the mesh stores. It is ``+1``: the sensor sits at the bottom of its
|
#: normal the mesh stores. It is ``+1``: the sensor sits at the bottom of its
|
||||||
@@ -191,11 +266,15 @@ class PlatformStack:
|
|||||||
#: ``+tool_axis``, and the tool feeds down onto it.
|
#: ``+tool_axis``, and the tool feeds down onto it.
|
||||||
#:
|
#:
|
||||||
#: This was ``-1``, which asked the solver to bring every normal onto **-Z**.
|
#: This was ``-1``, which asked the solver to bring every normal onto **-Z**.
|
||||||
#: The residual could not see the difference (``arccos`` is sign-blind, so a
|
#: Nothing reported that as a fault, because the residual was measured against
|
||||||
#: face turned fully over still scored 0.0000 deg), so the mistake showed up
|
#: the same inverted aim: a pose that turned the sensor over aligned with ``-Z``
|
||||||
#: only as the model flipping upside down: **981 of 1032 vertices** ended with
|
#: *perfectly*, scored 0.0000 deg, and passed every test that asked only for a
|
||||||
#: the face pointing down. With ``+1`` that is **51**, and those are edge-on
|
#: small residual. The mistake showed up as the model flipping upside down:
|
||||||
#: rather than inverted (worst cosine -0.013).
|
#: **981 of 1032 vertices** ended with the face pointing down. With ``+1`` the
|
||||||
|
#: alignment is measured against the direction the face really aims, and the
|
||||||
|
#: count is 0 — every normal on the export is aligned to within 1.3e-06 deg,
|
||||||
|
#: with the body's own orientation reported separately by
|
||||||
|
#: :meth:`PlatformStack.uprightness`.
|
||||||
approach_sign: int = 1
|
approach_sign: int = 1
|
||||||
#: Where the tool is, in the table frame, mm. Default ``(0, 0)`` — straight
|
#: Where the tool is, in the table frame, mm. Default ``(0, 0)`` — straight
|
||||||
#: above the table origin, which is where a dialled-in machine has it. Every
|
#: above the table origin, which is where a dialled-in machine has it. Every
|
||||||
@@ -238,6 +317,9 @@ class PlatformStack:
|
|||||||
for axis in (self.yaw_axis, self.pitch_axis, self.tool_axis):
|
for axis in (self.yaw_axis, self.pitch_axis, self.tool_axis):
|
||||||
if axis not in ("x", "y", "z"):
|
if axis not in ("x", "y", "z"):
|
||||||
raise ValueError(f"unknown axis {axis!r}")
|
raise ValueError(f"unknown axis {axis!r}")
|
||||||
|
for name, sign in (("yaw_sign", self.yaw_sign), ("pitch_sign", self.pitch_sign)):
|
||||||
|
if float(sign) not in (1.0, -1.0):
|
||||||
|
raise ValueError(f"{name} must be +1 or -1, got {sign}")
|
||||||
|
|
||||||
# ------------------------------------------------------------------ frames
|
# ------------------------------------------------------------------ frames
|
||||||
|
|
||||||
@@ -325,16 +407,31 @@ class PlatformStack:
|
|||||||
length = float(np.linalg.norm(normal))
|
length = float(np.linalg.norm(normal))
|
||||||
return normal if length < 1e-12 else normal / length
|
return normal if length < 1e-12 else normal / length
|
||||||
|
|
||||||
|
def _command_sign(self, axis: str) -> float:
|
||||||
|
"""``+1`` or ``-1``: commanded degrees to the right-hand angle about ``axis``."""
|
||||||
|
if axis == self.yaw_axis:
|
||||||
|
return float(self.yaw_sign)
|
||||||
|
if axis == self.pitch_axis:
|
||||||
|
return float(self.pitch_sign)
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
def yaw_rotation(self, degrees: float) -> np.ndarray:
|
||||||
|
"""The rotation a yaw command of ``degrees`` produces on the drive."""
|
||||||
|
return rotation_about(self.yaw_axis, self.yaw_sign * float(degrees))
|
||||||
|
|
||||||
|
def pitch_rotation(self, degrees: float) -> np.ndarray:
|
||||||
|
"""The rotation a pitch command of ``degrees`` produces on the drive."""
|
||||||
|
return rotation_about(self.pitch_axis, self.pitch_sign * float(degrees))
|
||||||
|
|
||||||
def platform_rotation(self, yaw_deg: float, pitch_deg: float) -> np.ndarray:
|
def platform_rotation(self, yaw_deg: float, pitch_deg: float) -> np.ndarray:
|
||||||
"""``R_pitch · R_yaw``: the stage rotation written in the table frame.
|
"""``R_pitch · R_yaw``: the stage rotation written in the table frame.
|
||||||
|
|
||||||
:meth:`stage_rotation` is the *same* rotation in the sensor frame — the two
|
Both angles are the numbers sent to the drives. :meth:`stage_rotation` is
|
||||||
are conjugates by the mount. Anything that reasons about where a point of
|
the same rotation in the sensor frame — the two are conjugates by the
|
||||||
the table ends up (the contact, the table's own frame) needs this one.
|
mount. Anything that reasons about where a point of the table ends up
|
||||||
|
(the contact, the table's own frame) needs this one.
|
||||||
"""
|
"""
|
||||||
return rotation_about(self.pitch_axis, pitch_deg) @ rotation_about(
|
return self.pitch_rotation(pitch_deg) @ self.yaw_rotation(yaw_deg)
|
||||||
self.yaw_axis, yaw_deg
|
|
||||||
)
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------- solve
|
# -------------------------------------------------------------------- solve
|
||||||
|
|
||||||
@@ -386,6 +483,25 @@ class PlatformStack:
|
|||||||
residual = chosen.residual_deg
|
residual = chosen.residual_deg
|
||||||
upright = chosen.upright
|
upright = chosen.upright
|
||||||
in_range = True
|
in_range = True
|
||||||
|
branch_reason = self.branch_reason(usable, chosen)
|
||||||
|
branch_travel = chosen.travel_deg
|
||||||
|
other_travel = max(
|
||||||
|
(branch.travel_deg for branch in usable if branch is not chosen),
|
||||||
|
default=0.0,
|
||||||
|
)
|
||||||
|
yaw_deg, pitch_deg, residual, upright, branch_reason, branch_travel, other_travel = (
|
||||||
|
self._keep_a_crown_upright(
|
||||||
|
unit,
|
||||||
|
tool,
|
||||||
|
yaw_deg,
|
||||||
|
pitch_deg,
|
||||||
|
residual,
|
||||||
|
upright,
|
||||||
|
branch_reason,
|
||||||
|
branch_travel,
|
||||||
|
other_travel,
|
||||||
|
)
|
||||||
|
)
|
||||||
elif branches:
|
elif branches:
|
||||||
# Every exact pose needs more travel than the stage has. Report the
|
# Every exact pose needs more travel than the stage has. Report the
|
||||||
# best pose the limits *do* allow, with the residual it really
|
# best pose the limits *do* allow, with the residual it really
|
||||||
@@ -394,14 +510,23 @@ class PlatformStack:
|
|||||||
yaw_deg, pitch_deg, residual = self._best_within_limits(unit, tool)
|
yaw_deg, pitch_deg, residual = self._best_within_limits(unit, tool)
|
||||||
upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0
|
upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0
|
||||||
in_range = False
|
in_range = False
|
||||||
|
branch_reason = "in_range"
|
||||||
|
branch_travel = abs(yaw_deg) + abs(pitch_deg)
|
||||||
|
other_travel = 0.0
|
||||||
else:
|
else:
|
||||||
yaw_deg, pitch_deg, residual = self._search_alignment(unit, tool)
|
yaw_deg, pitch_deg, residual = self._search_alignment(unit, tool)
|
||||||
upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0
|
upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0
|
||||||
in_range = False
|
in_range = False
|
||||||
|
branch_reason = ""
|
||||||
|
branch_travel = abs(yaw_deg) + abs(pitch_deg)
|
||||||
|
other_travel = 0.0
|
||||||
elif method == "search":
|
elif method == "search":
|
||||||
yaw_deg, pitch_deg, residual = self._search_alignment(unit, tool)
|
yaw_deg, pitch_deg, residual = self._search_alignment(unit, tool)
|
||||||
upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0
|
upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0
|
||||||
in_range = True
|
in_range = True
|
||||||
|
branch_reason = ""
|
||||||
|
branch_travel = abs(yaw_deg) + abs(pitch_deg)
|
||||||
|
other_travel = 0.0
|
||||||
else: # pragma: no cover - guarded by the signature
|
else: # pragma: no cover - guarded by the signature
|
||||||
raise ValueError(f"unknown method {method!r}")
|
raise ValueError(f"unknown method {method!r}")
|
||||||
|
|
||||||
@@ -410,20 +535,32 @@ class PlatformStack:
|
|||||||
reason = "the normal lies on the tool axis; yaw is indeterminate"
|
reason = "the normal lies on the tool axis; yaw is indeterminate"
|
||||||
elif not in_range:
|
elif not in_range:
|
||||||
reason = "the pose is outside the stage travel"
|
reason = "the pose is outside the stage travel"
|
||||||
elif residual > self.tolerance_deg:
|
elif branch_reason != "crown" and residual > self.tolerance_deg:
|
||||||
reason = "no pose meets the tolerance"
|
reason = "no pose meets the tolerance"
|
||||||
|
|
||||||
# The stages turn the contact about the table origin; the table then slides
|
# Three points, and they are three different places:
|
||||||
# it under the tool. Reported rather than animated here, but computed from
|
#
|
||||||
# the same angles the pose reports, so a caller cannot disagree with it.
|
# * ``point`` — the contact as mounted, the stages at zero.
|
||||||
|
# * ``landed`` — the contact once the stages turn about the table origin.
|
||||||
|
# * ``landed + table`` — the contact at the end of the move, under the tool.
|
||||||
|
#
|
||||||
|
# The contact *height* is the middle one's Z. Reporting the first one's Z
|
||||||
|
# (as this did) drops the lift the stage rotation gives the point, which is
|
||||||
|
# several millimetres at the angles a real point needs, and that height is
|
||||||
|
# what any press-axis target is derived from.
|
||||||
landed = self.platform_rotation(yaw_deg, pitch_deg) @ point
|
landed = self.platform_rotation(yaw_deg, pitch_deg) @ point
|
||||||
tool_x, tool_y = (float(value) for value in self.tool_xy_mm)
|
tool_x, tool_y = (float(value) for value in self.tool_xy_mm)
|
||||||
|
table_x = tool_x - self._axis(landed, "x")
|
||||||
|
table_y = tool_y - self._axis(landed, "y")
|
||||||
|
contact_z = self._axis(landed, "z")
|
||||||
|
|
||||||
return Pose(
|
return Pose(
|
||||||
ok=True,
|
ok=True,
|
||||||
target_xy_mm=(self._axis(point, "x"), self._axis(point, "y")),
|
target_xy_mm=(self._axis(point, "x"), self._axis(point, "y")),
|
||||||
contact_z_mm=self._axis(point, "z"),
|
mounted_point_mm=(self._axis(point, "x"), self._axis(point, "y"), self._axis(point, "z")),
|
||||||
table_xy_mm=(tool_x - self._axis(landed, "x"), tool_y - self._axis(landed, "y")),
|
contact_z_mm=contact_z,
|
||||||
|
contact_point_mm=(tool_x, tool_y, contact_z),
|
||||||
|
table_xy_mm=(table_x, table_y),
|
||||||
yaw_deg=yaw_deg,
|
yaw_deg=yaw_deg,
|
||||||
pitch_deg=pitch_deg,
|
pitch_deg=pitch_deg,
|
||||||
tool_z_mm=float(tool_z_mm),
|
tool_z_mm=float(tool_z_mm),
|
||||||
@@ -432,6 +569,62 @@ class PlatformStack:
|
|||||||
upright=upright,
|
upright=upright,
|
||||||
in_range=in_range,
|
in_range=in_range,
|
||||||
reason=reason,
|
reason=reason,
|
||||||
|
branch_reason=branch_reason,
|
||||||
|
branch_travel_deg=branch_travel,
|
||||||
|
other_travel_deg=other_travel,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _keep_a_crown_upright(
|
||||||
|
self,
|
||||||
|
unit: np.ndarray,
|
||||||
|
tool: np.ndarray,
|
||||||
|
yaw_deg: float,
|
||||||
|
pitch_deg: float,
|
||||||
|
residual: float,
|
||||||
|
upright: bool,
|
||||||
|
branch_reason: str,
|
||||||
|
branch_travel: float,
|
||||||
|
other_travel: float,
|
||||||
|
) -> tuple[float, float, float, bool, str, float, float]:
|
||||||
|
"""Leave a nearly vertical normal unyawed, and move the table instead.
|
||||||
|
|
||||||
|
The exact pose of a crown point yaws about 90° so the pitch stage can nod
|
||||||
|
away a degree or two of sideways lean. That turns the point's fore-aft
|
||||||
|
offset into a sideways move. Yaw 0 and the pitch that actually helps —
|
||||||
|
none, when the lean is sideways — put the move back on Y.
|
||||||
|
"""
|
||||||
|
if abs(yaw_deg) <= 45.0:
|
||||||
|
return (
|
||||||
|
yaw_deg,
|
||||||
|
pitch_deg,
|
||||||
|
residual,
|
||||||
|
upright,
|
||||||
|
branch_reason,
|
||||||
|
branch_travel,
|
||||||
|
other_travel,
|
||||||
|
)
|
||||||
|
rest_pitch = self._pitch_for(unit, tool)
|
||||||
|
limit = self._pitch_limit_effective()
|
||||||
|
rest_pitch = max(-limit, min(limit, rest_pitch))
|
||||||
|
rest_residual = self._residual(unit, 0.0, rest_pitch)
|
||||||
|
if rest_residual > CROWN_RESIDUAL_DEG:
|
||||||
|
return (
|
||||||
|
yaw_deg,
|
||||||
|
pitch_deg,
|
||||||
|
residual,
|
||||||
|
upright,
|
||||||
|
branch_reason,
|
||||||
|
branch_travel,
|
||||||
|
other_travel,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
0.0,
|
||||||
|
rest_pitch,
|
||||||
|
rest_residual,
|
||||||
|
self.uprightness(0.0, rest_pitch) >= 0.0,
|
||||||
|
"crown",
|
||||||
|
abs(rest_pitch),
|
||||||
|
abs(yaw_deg) + abs(pitch_deg),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------- closed-form solve
|
# ------------------------------------------------------- closed-form solve
|
||||||
@@ -461,7 +654,7 @@ class PlatformStack:
|
|||||||
"""
|
"""
|
||||||
unit = np.asarray(unit, dtype=np.float64)
|
unit = np.asarray(unit, dtype=np.float64)
|
||||||
tool = np.asarray(tool, dtype=np.float64)
|
tool = np.asarray(tool, dtype=np.float64)
|
||||||
pitch_stationary = self._rotation_coefficients(self.pitch_axis)[0]
|
pitch_stationary = self._signed_coefficients(self.pitch_axis)[0]
|
||||||
yaw_stationary = self._yaw_stationary
|
yaw_stationary = self._yaw_stationary
|
||||||
if pitch_stationary == yaw_stationary: # pragma: no cover - axes perpendicular
|
if pitch_stationary == yaw_stationary: # pragma: no cover - axes perpendicular
|
||||||
return []
|
return []
|
||||||
@@ -485,7 +678,7 @@ class PlatformStack:
|
|||||||
if any(abs(yaw_deg - prior) < 1e-6 for prior in seen):
|
if any(abs(yaw_deg - prior) < 1e-6 for prior in seen):
|
||||||
continue
|
continue
|
||||||
seen.add(yaw_deg)
|
seen.add(yaw_deg)
|
||||||
yawed = rotation_about(self.yaw_axis, yaw_deg) @ unit
|
yawed = self.yaw_rotation(yaw_deg) @ unit
|
||||||
pitch_deg = self._pitch_for(yawed, tool)
|
pitch_deg = self._pitch_for(yawed, tool)
|
||||||
branches.append(
|
branches.append(
|
||||||
Branch(
|
Branch(
|
||||||
@@ -518,7 +711,7 @@ class PlatformStack:
|
|||||||
to transcribe wrong.
|
to transcribe wrong.
|
||||||
"""
|
"""
|
||||||
at_zero = np.asarray(unit, dtype=np.float64)
|
at_zero = np.asarray(unit, dtype=np.float64)
|
||||||
at_ninety = rotation_about(self.yaw_axis, 90.0) @ at_zero
|
at_ninety = self.yaw_rotation(90.0) @ at_zero
|
||||||
a = float(at_zero[pinned])
|
a = float(at_zero[pinned])
|
||||||
b = float(at_ninety[pinned])
|
b = float(at_ninety[pinned])
|
||||||
|
|
||||||
@@ -555,7 +748,7 @@ class PlatformStack:
|
|||||||
best: tuple[float, float, float] | None = None
|
best: tuple[float, float, float] | None = None
|
||||||
step = max(SWEEP_STEP, (2.0 * self._yaw_limit_effective()) / max(samples - 1, 1))
|
step = max(SWEEP_STEP, (2.0 * self._yaw_limit_effective()) / max(samples - 1, 1))
|
||||||
for yaw_deg in np.arange(-self._yaw_limit_effective(), self._yaw_limit_effective() + step, step):
|
for yaw_deg in np.arange(-self._yaw_limit_effective(), self._yaw_limit_effective() + step, step):
|
||||||
yawed = rotation_about(self.yaw_axis, float(yaw_deg)) @ unit
|
yawed = self.yaw_rotation(float(yaw_deg)) @ unit
|
||||||
pitch_deg = self._pitch_for(yawed, tool)
|
pitch_deg = self._pitch_for(yawed, tool)
|
||||||
pitch_deg = max(-pitch_limit, min(pitch_limit, pitch_deg))
|
pitch_deg = max(-pitch_limit, min(pitch_limit, pitch_deg))
|
||||||
residual = self._residual(unit, float(yaw_deg), pitch_deg)
|
residual = self._residual(unit, float(yaw_deg), pitch_deg)
|
||||||
@@ -571,44 +764,19 @@ class PlatformStack:
|
|||||||
two ``atan2`` arguments are the dot and cross of those two components
|
two ``atan2`` arguments are the dot and cross of those two components
|
||||||
against the tool's.
|
against the tool's.
|
||||||
"""
|
"""
|
||||||
_, first, second, sign_first, sign_second = self._rotation_coefficients(
|
stationary = self._signed_coefficients(self.pitch_axis)[0]
|
||||||
self.pitch_axis
|
vector = np.asarray(yawed, dtype=np.float64)
|
||||||
)
|
# R(θ) = cos(θ)·I + sin(θ)·R(90) on the plane this stage turns, so the
|
||||||
u1, u2 = float(yawed[first]), float(yawed[second])
|
# angle that lands on the tool is the atan2 of those two probes. The
|
||||||
w1, w2 = float(tool[first]), float(tool[second])
|
# probes are the commanded rotation, which is what keeps a sign change
|
||||||
# turned[first] = u1·c + sign_first ·u2·s
|
# on the drive from needing its own copy of the matrix.
|
||||||
# turned[second] = sign_second·u1·s + u2·c
|
before = vector.copy()
|
||||||
cosine_term = u1 * w1 + sign_second * u2 * w2
|
after = self.pitch_rotation(90.0) @ vector
|
||||||
sine_term = sign_first * u2 * w1 - u1 * w2
|
before[stationary] = 0.0
|
||||||
return math.degrees(math.atan2(sine_term, cosine_term))
|
after[stationary] = 0.0
|
||||||
|
target = np.asarray(tool, dtype=np.float64).copy()
|
||||||
@staticmethod
|
target[stationary] = 0.0
|
||||||
def select_branch(branches: list[Branch]) -> Branch:
|
return math.degrees(math.atan2(float(after @ target), float(before @ target)))
|
||||||
"""Pick from :meth:`alignment_branches`: reachable, then upright, then best.
|
|
||||||
|
|
||||||
Every branch aligns to within floating point, so alignment itself rarely
|
|
||||||
separates them. What does is the machine: a pose inside the stage travel
|
|
||||||
beats one outside it, and — among those — a pose that leaves the sensing
|
|
||||||
face up beats one that turns the sensor over.
|
|
||||||
|
|
||||||
Alignment is still the third key rather than ignored: if one branch really
|
|
||||||
is materially better than another, that wins, because the point of the
|
|
||||||
stages is to align. The tie only decides between poses that are the same
|
|
||||||
to any tolerance the machine can hold.
|
|
||||||
"""
|
|
||||||
def rank(branch: Branch) -> tuple:
|
|
||||||
# Alignment first: if one branch genuinely aligns better, that is the
|
|
||||||
# answer, because aligning is what the stages are for. Reachability and
|
|
||||||
# orientation decide between branches that align the same, which is the
|
|
||||||
# usual case — the closed form puts both within floating point of exact.
|
|
||||||
return (
|
|
||||||
branch.residual_deg,
|
|
||||||
not branch.in_range,
|
|
||||||
not branch.upright,
|
|
||||||
abs(branch.yaw_deg) + abs(branch.pitch_deg),
|
|
||||||
)
|
|
||||||
|
|
||||||
return min(branches, key=rank)
|
|
||||||
|
|
||||||
def reachable(self, unit: np.ndarray, tool: np.ndarray) -> tuple[bool, str]:
|
def reachable(self, unit: np.ndarray, tool: np.ndarray) -> tuple[bool, str]:
|
||||||
"""Whether any pose can align this normal, and why not if none can.
|
"""Whether any pose can align this normal, and why not if none can.
|
||||||
@@ -635,15 +803,27 @@ class PlatformStack:
|
|||||||
@property
|
@property
|
||||||
def _yaw_stationary(self) -> int:
|
def _yaw_stationary(self) -> int:
|
||||||
"""The component the yaw leaves alone (its own axis)."""
|
"""The component the yaw leaves alone (its own axis)."""
|
||||||
return self._rotation_coefficients(self.yaw_axis)[0]
|
return self._signed_coefficients(self.yaw_axis)[0]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _yaw_sign_first(self) -> float:
|
def _yaw_sign_first(self) -> float:
|
||||||
return self._rotation_coefficients(self.yaw_axis)[3]
|
return self._signed_coefficients(self.yaw_axis)[3]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _yaw_sign_second(self) -> float:
|
def _yaw_sign_second(self) -> float:
|
||||||
return self._rotation_coefficients(self.yaw_axis)[4]
|
return self._signed_coefficients(self.yaw_axis)[4]
|
||||||
|
|
||||||
|
def _signed_coefficients(
|
||||||
|
self, axis: str
|
||||||
|
) -> tuple[int, int, int, float, float]:
|
||||||
|
"""Right-hand coefficients with the drive's command sign folded in.
|
||||||
|
|
||||||
|
A command sign of ``-1`` flips the two sine terms, which is the same
|
||||||
|
rotation as feeding ``rotation_about`` the negated angle.
|
||||||
|
"""
|
||||||
|
stationary, first, second, sign_first, sign_second = self._rotation_coefficients(axis)
|
||||||
|
sign = self._command_sign(axis)
|
||||||
|
return stationary, first, second, sign_first * sign, sign_second * sign
|
||||||
|
|
||||||
def _rotation_coefficients(
|
def _rotation_coefficients(
|
||||||
self, axis: str
|
self, axis: str
|
||||||
@@ -675,7 +855,7 @@ class PlatformStack:
|
|||||||
yaw = math.radians(yaw_deg)
|
yaw = math.radians(yaw_deg)
|
||||||
yaw_cos, yaw_sin = math.cos(yaw), math.sin(yaw)
|
yaw_cos, yaw_sin = math.cos(yaw), math.sin(yaw)
|
||||||
yawed = [0.0, 0.0, 0.0]
|
yawed = [0.0, 0.0, 0.0]
|
||||||
stationary, first, second, sign_first, sign_second = self._rotation_coefficients(
|
stationary, first, second, sign_first, sign_second = self._signed_coefficients(
|
||||||
self.yaw_axis
|
self.yaw_axis
|
||||||
)
|
)
|
||||||
yawed[stationary] = unit[stationary]
|
yawed[stationary] = unit[stationary]
|
||||||
@@ -685,7 +865,7 @@ class PlatformStack:
|
|||||||
pitch = math.radians(pitch_deg)
|
pitch = math.radians(pitch_deg)
|
||||||
pitch_cos, pitch_sin = math.cos(pitch), math.sin(pitch)
|
pitch_cos, pitch_sin = math.cos(pitch), math.sin(pitch)
|
||||||
turned = [0.0, 0.0, 0.0]
|
turned = [0.0, 0.0, 0.0]
|
||||||
stationary, first, second, sign_first, sign_second = self._rotation_coefficients(
|
stationary, first, second, sign_first, sign_second = self._signed_coefficients(
|
||||||
self.pitch_axis
|
self.pitch_axis
|
||||||
)
|
)
|
||||||
turned[stationary] = yawed[stationary]
|
turned[stationary] = yawed[stationary]
|
||||||
@@ -702,12 +882,13 @@ class PlatformStack:
|
|||||||
"""
|
"""
|
||||||
turned = self._cell(unit, yaw_deg, pitch_deg)
|
turned = self._cell(unit, yaw_deg, pitch_deg)
|
||||||
tool = self.tool_direction
|
tool = self.tool_direction
|
||||||
cosine = (
|
x, y, z = turned
|
||||||
turned[0] * float(tool[0])
|
tx, ty, tz = (float(value) for value in tool)
|
||||||
+ turned[1] * float(tool[1])
|
cosine = x * tx + y * ty + z * tz
|
||||||
+ turned[2] * float(tool[2])
|
# acos loses angular precision near a dot product of 1, which can make
|
||||||
)
|
# equivalent exact branches appear different. atan2 stays well conditioned.
|
||||||
return float(math.degrees(math.acos(max(-1.0, min(1.0, cosine)))))
|
sine = math.hypot(y * tz - z * ty, z * tx - x * tz, x * ty - y * tx)
|
||||||
|
return float(math.degrees(math.atan2(sine, cosine)))
|
||||||
|
|
||||||
def _turn(self, axis: str, angle_deg: np.ndarray, vector: np.ndarray) -> np.ndarray:
|
def _turn(self, axis: str, angle_deg: np.ndarray, vector: np.ndarray) -> np.ndarray:
|
||||||
"""Rotate ``vector`` about a platform axis by each of ``angle_deg``.
|
"""Rotate ``vector`` about a platform axis by each of ``angle_deg``.
|
||||||
@@ -718,7 +899,7 @@ class PlatformStack:
|
|||||||
"""
|
"""
|
||||||
angle = np.radians(np.asarray(angle_deg, dtype=np.float64))
|
angle = np.radians(np.asarray(angle_deg, dtype=np.float64))
|
||||||
vector = np.asarray(vector, dtype=np.float64)
|
vector = np.asarray(vector, dtype=np.float64)
|
||||||
stationary, first, second, sign_first, sign_second = self._rotation_coefficients(axis)
|
stationary, first, second, sign_first, sign_second = self._signed_coefficients(axis)
|
||||||
cosine, sine = np.cos(angle), np.sin(angle)
|
cosine, sine = np.cos(angle), np.sin(angle)
|
||||||
|
|
||||||
turned = np.empty((angle.size, 3), dtype=np.float64)
|
turned = np.empty((angle.size, 3), dtype=np.float64)
|
||||||
@@ -780,27 +961,20 @@ class PlatformStack:
|
|||||||
``O(n_yaw)`` instead of ``O(n_yaw * n_pitch)``, which is the whole cost of
|
``O(n_yaw)`` instead of ``O(n_yaw * n_pitch)``, which is the whole cost of
|
||||||
the exhaustive grid below.
|
the exhaustive grid below.
|
||||||
|
|
||||||
The coefficients are written out by axis name rather than through the
|
The sine coefficient is the same probe :meth:`_pitch_for` uses: a
|
||||||
``first``/``second`` rotation ordering, because it is the *cross* term
|
commanded 90° pitch. Writing it out per axis name returned the antipode
|
||||||
that carries the sign and getting that backwards returns the antipode —
|
once the pitch axis moved off ``y``.
|
||||||
an angle 180 deg away that looks entirely plausible. The signs here were
|
|
||||||
fixed against a direct numeric scan, and
|
|
||||||
``test_the_closed_form_pitch_beats_a_direct_scan`` keeps them that way.
|
|
||||||
"""
|
"""
|
||||||
tool = np.asarray(self.tool_direction, dtype=np.float64)
|
tool = np.asarray(self.tool_direction, dtype=np.float64)
|
||||||
axis = self.pitch_axis
|
|
||||||
others = [name for name in ("x", "y", "z") if name != axis]
|
|
||||||
leading, trailing = others[0], others[1]
|
|
||||||
lead, trail = ("x", "y", "z").index(leading), ("x", "y", "z").index(trailing)
|
|
||||||
|
|
||||||
yawed = self._turn(self.yaw_axis, yaws, unit)
|
yawed = self._turn(self.yaw_axis, yaws, unit)
|
||||||
cosine_coefficient = (
|
stationary = self._signed_coefficients(self.pitch_axis)[0]
|
||||||
yawed[:, lead] * tool[lead] + yawed[:, trail] * tool[trail]
|
before = np.array(yawed, copy=True)
|
||||||
)
|
after = yawed @ self.pitch_rotation(90.0).T
|
||||||
sine_coefficient = (
|
before[:, stationary] = 0.0
|
||||||
yawed[:, trail] * tool[lead] - yawed[:, lead] * tool[trail]
|
after[:, stationary] = 0.0
|
||||||
)
|
target = np.array(tool, copy=True)
|
||||||
return np.degrees(np.arctan2(sine_coefficient, cosine_coefficient))
|
target[stationary] = 0.0
|
||||||
|
return np.degrees(np.arctan2(after @ target, before @ target))
|
||||||
|
|
||||||
def _grid_reference(
|
def _grid_reference(
|
||||||
self, unit: np.ndarray, yaws: np.ndarray, pitches: np.ndarray
|
self, unit: np.ndarray, yaws: np.ndarray, pitches: np.ndarray
|
||||||
@@ -825,7 +999,7 @@ class PlatformStack:
|
|||||||
yawed = self._turn(self.yaw_axis, yaws, unit)
|
yawed = self._turn(self.yaw_axis, yaws, unit)
|
||||||
pitch = np.radians(np.asarray(pitches, dtype=np.float64))
|
pitch = np.radians(np.asarray(pitches, dtype=np.float64))
|
||||||
cosine, sine = np.cos(pitch), np.sin(pitch)
|
cosine, sine = np.cos(pitch), np.sin(pitch)
|
||||||
stationary, first, second, sign_first, sign_second = self._rotation_coefficients(
|
stationary, first, second, sign_first, sign_second = self._signed_coefficients(
|
||||||
self.pitch_axis
|
self.pitch_axis
|
||||||
)
|
)
|
||||||
turned = np.empty_like(yawed)
|
turned = np.empty_like(yawed)
|
||||||
@@ -962,28 +1136,35 @@ class PlatformStack:
|
|||||||
this way. One definition, so the two cannot disagree.
|
this way. One definition, so the two cannot disagree.
|
||||||
"""
|
"""
|
||||||
mount = mount_rotation(self.axis_map)
|
mount = mount_rotation(self.axis_map)
|
||||||
return (
|
return mount.T @ self.platform_rotation(yaw_deg, pitch_deg) @ mount
|
||||||
mount.T
|
|
||||||
@ rotation_about(self.pitch_axis, pitch_deg)
|
|
||||||
@ rotation_about(self.yaw_axis, yaw_deg)
|
|
||||||
@ mount
|
|
||||||
)
|
|
||||||
|
|
||||||
def uprightness(self, yaw_deg: float, pitch_deg: float) -> float:
|
def uprightness(self, yaw_deg: float, pitch_deg: float) -> float:
|
||||||
"""How much the sensing face still points up, in -1..1.
|
"""How much the sensing face still points up, in -1..1.
|
||||||
|
|
||||||
Measured, not derived: the sensor's own up axis (mesh ``+Y``, which
|
Measured, not derived: the body's own up axis — the mesh axis the mount
|
||||||
``AXIS_MAP`` sends to platform ``+Z``) is put through the composed stage
|
sends to the platform frame's vertical, i.e. mesh ``+Y`` under the default
|
||||||
rotation and compared with that same axis *unrotated*. ``+1`` is the
|
``AXIS_MAP`` — is put through the composed stage rotation and dotted with
|
||||||
nominal pose — no rotation, face straight up — ``-1`` is fully turned over.
|
that same axis *unrotated*. ``+1`` is the nominal pose (no rotation, face
|
||||||
|
straight up), ``-1`` is fully turned over.
|
||||||
|
|
||||||
Both vectors are expressed in the same frame, so this is the angle between
|
Both vectors are expressed in the same frame, so this is the angle between
|
||||||
the face and where it started, and it does not depend on which way the tool
|
the face and where it started, and it does not depend on which way the tool
|
||||||
points. An earlier version dotted the turned axis against the tool
|
points. An earlier version dotted the turned axis against the tool
|
||||||
direction, which quietly moved with ``approach_sign`` and so reported the
|
direction, which quietly moved with ``approach_sign`` and so reported the
|
||||||
polarity as an orientation.
|
polarity as an orientation.
|
||||||
|
|
||||||
|
The reference axis is the whole point of the measurement, so it is taken
|
||||||
|
from the mount rather than assumed: ``tool_axis_direction`` is the platform
|
||||||
|
axis the face aims along (``+Z`` here), and ``to_sensor`` maps it back
|
||||||
|
through the same ``AXIS_MAP`` the geometry uses. An earlier version again
|
||||||
|
passed ``eye(3)[1]`` — the platform frame's *Y*, a horizontal axis — so the
|
||||||
|
number measured how far the body leaned sideways and went on reporting
|
||||||
|
rolled-over poses (a half-turn about the vertical) as upright. That is the
|
||||||
|
second half of the false branch decision: with the axis corrected, the two
|
||||||
|
branches of a measured point are both upright, and the choice falls to the
|
||||||
|
machine instead of to a mis-measured cosine.
|
||||||
"""
|
"""
|
||||||
reference = to_sensor(np.eye(3)[1], self.axis_map)
|
reference = to_sensor(self.tool_axis_direction, self.axis_map)
|
||||||
reference = reference / max(float(np.linalg.norm(reference)), 1e-12)
|
reference = reference / max(float(np.linalg.norm(reference)), 1e-12)
|
||||||
turned = self.stage_rotation(yaw_deg, pitch_deg) @ reference
|
turned = self.stage_rotation(yaw_deg, pitch_deg) @ reference
|
||||||
turned = turned / max(float(np.linalg.norm(turned)), 1e-12)
|
turned = turned / max(float(np.linalg.norm(turned)), 1e-12)
|
||||||
@@ -996,10 +1177,71 @@ class PlatformStack:
|
|||||||
return True
|
return True
|
||||||
if lean < -_UPRIGHT_TIE:
|
if lean < -_UPRIGHT_TIE:
|
||||||
return False
|
return False
|
||||||
# Indeterminate: the two poses are the same alignment to any tolerance the
|
# Edge-on: the body's up is perpendicular to the vertical to within the
|
||||||
# machine has, so take the one needing the smaller move.
|
# noise floor, so neither answer is wrong and the sign is rounding. Prefer
|
||||||
|
# the pose that asks for the smaller pitch. Arbitrary, but stable, and it
|
||||||
|
# only ever decides poses no machine tolerance can tell apart.
|
||||||
return abs(pitch_deg) <= 90.0
|
return abs(pitch_deg) <= 90.0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def select_branch(branches: list[Branch]) -> Branch:
|
||||||
|
"""Pick from :meth:`alignment_branches`: reachable, upright, then smallest.
|
||||||
|
|
||||||
|
Every branch aligns to within floating point, so alignment itself rarely
|
||||||
|
separates them. What does is the machine: a pose inside the stage travel
|
||||||
|
beats one outside it, and — among those — a pose that leaves the body up
|
||||||
|
beats one that rolls it over, and then the smaller turn wins.
|
||||||
|
|
||||||
|
**Equivalent poses are compared as equals.** Only branches within
|
||||||
|
:data:`_BRANCH_TIE_DEG` of the best residual compete on the machine keys:
|
||||||
|
two exact poses of a measured point differ by floating point at most, and
|
||||||
|
letting that difference choose between them would move the reported pose —
|
||||||
|
and the machine — for a reason nobody could see or defend. A branch that is
|
||||||
|
*materially* better aligned still wins, because aligning is what the stages
|
||||||
|
are for.
|
||||||
|
|
||||||
|
Which key decided the answer is reported separately by
|
||||||
|
:meth:`branch_reason`, so the panel can say why rather than only what.
|
||||||
|
"""
|
||||||
|
if not branches:
|
||||||
|
raise ValueError("no branches to choose from")
|
||||||
|
|
||||||
|
best = min(branch.residual_deg for branch in branches)
|
||||||
|
equivalent = [
|
||||||
|
branch
|
||||||
|
for branch in branches
|
||||||
|
if branch.residual_deg <= best + _BRANCH_TIE_DEG
|
||||||
|
]
|
||||||
|
return min(
|
||||||
|
equivalent,
|
||||||
|
key=lambda branch: (
|
||||||
|
not branch.in_range,
|
||||||
|
not branch.upright,
|
||||||
|
branch.travel_deg,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def branch_reason(branches: list[Branch], chosen: Branch) -> str:
|
||||||
|
"""Which selection key decided ``chosen``, as a key for the panel to name.
|
||||||
|
|
||||||
|
Returns ``only`` when there was nothing to choose between, otherwise the
|
||||||
|
first key that rules the other candidates out: ``alignment`` (a materially
|
||||||
|
better residual), ``in_range``, ``upright``, or ``smallest`` when the poses
|
||||||
|
are equivalent and the shorter turn won.
|
||||||
|
"""
|
||||||
|
others = [branch for branch in branches if branch is not chosen]
|
||||||
|
if not others:
|
||||||
|
return "only"
|
||||||
|
best = min(branch.residual_deg for branch in branches)
|
||||||
|
if all(branch.residual_deg > best + _BRANCH_TIE_DEG for branch in others):
|
||||||
|
return "alignment"
|
||||||
|
if all(not branch.in_range for branch in others):
|
||||||
|
return "in_range"
|
||||||
|
if all(not branch.upright for branch in others):
|
||||||
|
return "upright"
|
||||||
|
return "smallest"
|
||||||
|
|
||||||
def _prefer_smallest(
|
def _prefer_smallest(
|
||||||
self,
|
self,
|
||||||
unit: np.ndarray,
|
unit: np.ndarray,
|
||||||
@@ -1104,11 +1346,7 @@ def residual_for(
|
|||||||
normal = stack.normal_to_platform(np.asarray(normal_sensor, dtype=np.float64))
|
normal = stack.normal_to_platform(np.asarray(normal_sensor, dtype=np.float64))
|
||||||
length = max(float(np.linalg.norm(normal)), 1e-12)
|
length = max(float(np.linalg.norm(normal)), 1e-12)
|
||||||
unit = normal / length
|
unit = normal / length
|
||||||
rotated = (
|
rotated = stack.platform_rotation(yaw_deg, pitch_deg) @ unit
|
||||||
rotation_about(stack.pitch_axis, pitch_deg)
|
|
||||||
@ rotation_about(stack.yaw_axis, yaw_deg)
|
|
||||||
@ unit
|
|
||||||
)
|
|
||||||
return float(
|
return float(
|
||||||
np.degrees(np.arccos(np.clip(float(rotated @ stack.tool_direction), -1.0, 1.0)))
|
np.degrees(np.arccos(np.clip(float(rotated @ stack.tool_direction), -1.0, 1.0)))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Moving the platform to a simulated pose, one axis at a time.
|
"""Moving the platform to a simulated pose.
|
||||||
|
|
||||||
The finger screen solves a pose — rotate the stages, slide the table — and shows it.
|
The finger screen solves a pose — rotate the stages, slide the table — and shows it.
|
||||||
Executing it is a *sequence* of coordinated moves, and where that sequence lives
|
Executing it is a *sequence* of coordinated moves, and where that sequence lives
|
||||||
@@ -10,9 +10,10 @@ matters:
|
|||||||
:class:`~orisys_autocal.motion.poller.MotionPoller` is built, and it is why a stop
|
:class:`~orisys_autocal.motion.poller.MotionPoller` is built, and it is why a stop
|
||||||
is possible at all: a mover that sat in a loop inside one command could not be
|
is possible at all: a mover that sat in a loop inside one command could not be
|
||||||
interrupted, because the stop would have to wait for the loop it is meant to abort.
|
interrupted, because the stop would have to wait for the loop it is meant to abort.
|
||||||
* **This module** owns the order and the waiting. It watches the snapshots the poller
|
* **This module** owns the waiting. Execute and return-to-zero both start yaw, pitch,
|
||||||
already produces, issues the next axis when the current one has arrived, and gives
|
table X/Y and the press axis in one command so the five axes move together. The
|
||||||
up on its own if a step stops making progress.
|
mover watches the snapshots the poller already produces and gives up on its own
|
||||||
|
if an axis stops short of its target.
|
||||||
|
|
||||||
That shape is the whole reason for the class: ``stop`` is submitted as an ordinary
|
That shape is the whole reason for the class: ``stop`` is submitted as an ordinary
|
||||||
command, so it runs at the next tick and stops the axis that is moving *now*.
|
command, so it runs at the next tick and stops the axis that is moving *now*.
|
||||||
@@ -25,15 +26,15 @@ translation is what the rotations are resolved against.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
import time
|
import time
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Signal
|
from PySide6.QtCore import QObject, Signal, Slot
|
||||||
|
|
||||||
from .models import MotionResult, Snapshot
|
from .models import MotionResult, Snapshot
|
||||||
from .service import LINEAR_ROLES, MotionService
|
from .service import LINEAR_ROLES, MotionService
|
||||||
from .units import user_units_to_deg
|
|
||||||
|
|
||||||
#: How close an axis must be to its target before the next one starts, per unit:
|
#: How close an axis must be to its target before the next one starts, per unit:
|
||||||
#: millimetres for the linear roles, degrees for the rotary ones. One user unit is
|
#: millimetres for the linear roles, degrees for the rotary ones. One user unit is
|
||||||
@@ -50,11 +51,79 @@ STALL_GRACE_S = 2.0
|
|||||||
#: The order a role move is issued in, for the reason in the module docstring.
|
#: The order a role move is issued in, for the reason in the module docstring.
|
||||||
MOVE_ORDER: tuple[str, ...] = ("yaw", "pitch", "x", "y", "z")
|
MOVE_ORDER: tuple[str, ...] = ("yaw", "pitch", "x", "y", "z")
|
||||||
|
|
||||||
#: The order "return to the initial state" retracts in: the press axis first, so the
|
#: Axes a pose execute and a return-to-zero command together, including the press axis.
|
||||||
#: tool is clear of the sensor before anything rotates, then the stages, then the
|
ALIGN_ORDER: tuple[str, ...] = ("yaw", "pitch", "x", "y", "z")
|
||||||
#: table. Returning in one coordinated swoop would move five axes through poses
|
|
||||||
#: nobody has simulated.
|
|
||||||
RETURN_ORDER: tuple[str, ...] = ("z", "pitch", "yaw", "x", "y")
|
@dataclass(frozen=True)
|
||||||
|
class PressAxis:
|
||||||
|
"""How the press axis's coordinate relates to the geometric contact height.
|
||||||
|
|
||||||
|
A solved pose reports ``contact_z_mm``: how high the contact sits above the
|
||||||
|
table's origin plane, in geometry. The press axis is commanded in its own
|
||||||
|
coordinate, counted from its own mechanical zero. They are two different
|
||||||
|
numbers, and only a bench measurement relates them:
|
||||||
|
|
||||||
|
* ``zero_mm`` — the press axis reading at which the tool tip meets the table's
|
||||||
|
origin plane. The tip's own height is inside this number, which is why it is
|
||||||
|
not a separate field.
|
||||||
|
* ``rising`` — the drive's sign: true when a larger coordinate lifts the tip.
|
||||||
|
* ``calibrated`` — whether somebody has measured the two on *this* machine.
|
||||||
|
Until then :meth:`target_for` returns ``None`` and the caller holds the press
|
||||||
|
axis rather than driving it to a geometric height nobody has anchored.
|
||||||
|
"""
|
||||||
|
|
||||||
|
zero_mm: float = 0.0
|
||||||
|
rising: bool = True
|
||||||
|
calibrated: bool = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_settings(cls, settings: object) -> PressAxis:
|
||||||
|
"""Malformed or incomplete calibration settings keep the press axis held."""
|
||||||
|
if not isinstance(settings, Mapping):
|
||||||
|
return cls()
|
||||||
|
if isinstance(settings.get("zero_mm"), bool):
|
||||||
|
return cls()
|
||||||
|
try:
|
||||||
|
zero = float(settings["zero_mm"])
|
||||||
|
rising = settings["rising"]
|
||||||
|
except (KeyError, TypeError, ValueError, OverflowError):
|
||||||
|
return cls()
|
||||||
|
if not math.isfinite(zero) or not isinstance(rising, bool):
|
||||||
|
return cls()
|
||||||
|
return cls(zero_mm=zero, rising=rising, calibrated=settings.get("calibrated") is True)
|
||||||
|
|
||||||
|
def target_for(self, contact_z_mm: float) -> float | None:
|
||||||
|
"""The axis coordinate that puts the tool tip at ``contact_z_mm``.
|
||||||
|
|
||||||
|
``None`` means the relation is not calibrated on this machine, and the
|
||||||
|
caller must hold the press axis: passing the geometric number through as an
|
||||||
|
absolute target is what this class exists to stop.
|
||||||
|
"""
|
||||||
|
if not self.calibrated:
|
||||||
|
return None
|
||||||
|
height = float(contact_z_mm)
|
||||||
|
return self.zero_mm + (height if self.rising else -height)
|
||||||
|
|
||||||
|
|
||||||
|
def press_targets(
|
||||||
|
targets: Mapping[str, float], press: PressAxis
|
||||||
|
) -> tuple[dict[str, float], tuple[str, ...]]:
|
||||||
|
"""Resolve the press role in ``targets``, or hand it back as a held role.
|
||||||
|
|
||||||
|
Returns the roles to command and the roles to hold. Everything but ``z`` passes
|
||||||
|
through untouched: the stage angles and the table move are already in the units
|
||||||
|
their axes report, while the contact height is geometry.
|
||||||
|
"""
|
||||||
|
resolved = dict(targets)
|
||||||
|
if "z" not in resolved:
|
||||||
|
return resolved, ()
|
||||||
|
coordinate = press.target_for(resolved["z"])
|
||||||
|
if coordinate is None:
|
||||||
|
resolved.pop("z")
|
||||||
|
return resolved, ("z",)
|
||||||
|
resolved["z"] = float(coordinate)
|
||||||
|
return resolved, ()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -71,6 +140,33 @@ class MoveStep:
|
|||||||
return f"{self.role} {self.target:+.4f} {self.unit}"
|
return f"{self.role} {self.target:+.4f} {self.unit}"
|
||||||
|
|
||||||
|
|
||||||
|
def _check_steps(
|
||||||
|
service: MotionService, steps: Sequence[MoveStep], *, refresh_units: bool = False
|
||||||
|
) -> MotionResult | None:
|
||||||
|
"""Reject stale axis/scale bindings; never reinterpret an existing plan."""
|
||||||
|
refusal = service.check_axes_move(
|
||||||
|
{step.role: step.target for step in steps}, refresh_units=refresh_units
|
||||||
|
)
|
||||||
|
if refusal is not None:
|
||||||
|
return refusal
|
||||||
|
for step in steps:
|
||||||
|
if step.axis != service.role_axis(step.role) or step.units != service.to_units(
|
||||||
|
step.role, step.target
|
||||||
|
):
|
||||||
|
return MotionResult.refused(
|
||||||
|
f"{step.role} 的轴映射或单位已改变,请重新规划 / axis mapping or units changed; replan"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_steps(service: MotionService, steps: Sequence[MoveStep]) -> MotionResult:
|
||||||
|
"""Worker-only read check, then the original absolute targets in their order."""
|
||||||
|
refusal = _check_steps(service, steps, refresh_units=True)
|
||||||
|
if refusal is not None:
|
||||||
|
return refusal
|
||||||
|
return service.move_abs_many(tuple((step.axis, step.units) for step in steps))
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class MovePlan:
|
class MovePlan:
|
||||||
"""A sequence of steps, and what it is for."""
|
"""A sequence of steps, and what it is for."""
|
||||||
@@ -80,6 +176,8 @@ class MovePlan:
|
|||||||
#: Roles deliberately left alone. Reported, because "z did not move" is
|
#: Roles deliberately left alone. Reported, because "z did not move" is
|
||||||
#: information the operator needs, not an omission.
|
#: information the operator needs, not an omission.
|
||||||
held: tuple[str, ...] = ()
|
held: tuple[str, ...] = ()
|
||||||
|
#: When set, every step is commanded in one call and they travel together.
|
||||||
|
together: bool = False
|
||||||
|
|
||||||
def describe(self) -> str:
|
def describe(self) -> str:
|
||||||
parts = [step.label() for step in self.steps]
|
parts = [step.label() for step in self.steps]
|
||||||
@@ -100,6 +198,7 @@ class MoveState:
|
|||||||
failure: str = ""
|
failure: str = ""
|
||||||
cancelled: bool = False
|
cancelled: bool = False
|
||||||
_idle_since: float | None = None
|
_idle_since: float | None = None
|
||||||
|
_group_idle: dict = field(default_factory=dict)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def step(self) -> MoveStep | None:
|
def step(self) -> MoveStep | None:
|
||||||
@@ -119,6 +218,7 @@ def plan_pose_move(
|
|||||||
title: str = "",
|
title: str = "",
|
||||||
hold: Sequence[str] = (),
|
hold: Sequence[str] = (),
|
||||||
order: Sequence[str] = MOVE_ORDER,
|
order: Sequence[str] = MOVE_ORDER,
|
||||||
|
together: bool = False,
|
||||||
) -> MovePlan:
|
) -> MovePlan:
|
||||||
"""Turn per-role targets into a stepped plan, in ``order``.
|
"""Turn per-role targets into a stepped plan, in ``order``.
|
||||||
|
|
||||||
@@ -143,15 +243,21 @@ def plan_pose_move(
|
|||||||
unit="mm" if role in LINEAR_ROLES else "°",
|
unit="mm" if role in LINEAR_ROLES else "°",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return MovePlan(steps=tuple(steps), title=title, held=tuple(hold))
|
return MovePlan(steps=tuple(steps), title=title, held=tuple(hold), together=together)
|
||||||
|
|
||||||
|
|
||||||
def plan_return_to_initial(service: MotionService) -> MovePlan:
|
def plan_return_to_initial(
|
||||||
"""Every axis to zero, press axis first, in :data:`RETURN_ORDER`."""
|
service: MotionService, *, x_mm: float = 0.0, y_mm: float = 0.0
|
||||||
|
) -> MovePlan:
|
||||||
|
"""Yaw, pitch and the press axis back to zero. Table X/Y park at ``x_mm`` and ``y_mm``."""
|
||||||
|
targets = {role: 0.0 for role in ALIGN_ORDER}
|
||||||
|
targets["x"] = float(x_mm)
|
||||||
|
targets["y"] = float(y_mm)
|
||||||
return plan_pose_move(
|
return plan_pose_move(
|
||||||
service,
|
service,
|
||||||
{role: 0.0 for role in RETURN_ORDER},
|
targets,
|
||||||
order=RETURN_ORDER,
|
order=ALIGN_ORDER,
|
||||||
|
together=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -174,6 +280,7 @@ class PoseMover(QObject):
|
|||||||
self._service = service
|
self._service = service
|
||||||
self._submit = submit
|
self._submit = submit
|
||||||
self.state = MoveState()
|
self.state = MoveState()
|
||||||
|
self._command_token: int | None = None
|
||||||
|
|
||||||
# ------------------------------------------------------------------ control
|
# ------------------------------------------------------------------ control
|
||||||
|
|
||||||
@@ -191,9 +298,7 @@ class PoseMover(QObject):
|
|||||||
"""Validate and begin. Refusal is returned, not raised."""
|
"""Validate and begin. Refusal is returned, not raised."""
|
||||||
if self.running:
|
if self.running:
|
||||||
return MotionResult.refused("已有运动在执行 / a move is already running")
|
return MotionResult.refused("已有运动在执行 / a move is already running")
|
||||||
refusal = self._service.check_axes_move(
|
refusal = _check_steps(self._service, plan.steps)
|
||||||
{step.role: step.target for step in plan.steps}
|
|
||||||
)
|
|
||||||
if refusal is not None:
|
if refusal is not None:
|
||||||
return refusal
|
return refusal
|
||||||
if not plan.steps:
|
if not plan.steps:
|
||||||
@@ -220,6 +325,10 @@ class PoseMover(QObject):
|
|||||||
self.cancelled.emit()
|
self.cancelled.emit()
|
||||||
|
|
||||||
def _issue_current(self) -> None:
|
def _issue_current(self) -> None:
|
||||||
|
plan = self.state.plan
|
||||||
|
if plan is not None and plan.together:
|
||||||
|
self._issue_together(plan)
|
||||||
|
return
|
||||||
step = self.state.step
|
step = self.state.step
|
||||||
if step is None:
|
if step is None:
|
||||||
self._complete()
|
self._complete()
|
||||||
@@ -228,9 +337,24 @@ class PoseMover(QObject):
|
|||||||
if not step.units == step.units: # NaN: no calibration for this role
|
if not step.units == step.units: # NaN: no calibration for this role
|
||||||
self._fail(f"{step.role} 无法换算成用户单位 / cannot convert {step.role}")
|
self._fail(f"{step.role} 无法换算成用户单位 / cannot convert {step.role}")
|
||||||
return
|
return
|
||||||
self._submit(lambda service, step=step: service.move_abs(step.axis, step.units))
|
self._command_token = self._submit(
|
||||||
|
lambda service, step=step: _execute_steps(service, (step,))
|
||||||
|
)
|
||||||
self.stepStarted.emit(step)
|
self.stepStarted.emit(step)
|
||||||
|
|
||||||
|
def _issue_together(self, plan: MovePlan) -> None:
|
||||||
|
"""Command every step in one worker call."""
|
||||||
|
for step in plan.steps:
|
||||||
|
if not step.units == step.units:
|
||||||
|
self._fail(f"{step.role} 无法换算成用户单位 / cannot convert {step.role}")
|
||||||
|
return
|
||||||
|
self.state._group_idle = {}
|
||||||
|
self._command_token = self._submit(
|
||||||
|
lambda service, steps=plan.steps: _execute_steps(service, steps)
|
||||||
|
)
|
||||||
|
if plan.steps:
|
||||||
|
self.stepStarted.emit(plan.steps[0])
|
||||||
|
|
||||||
def _complete(self) -> None:
|
def _complete(self) -> None:
|
||||||
self.state.finished = True
|
self.state.finished = True
|
||||||
plan = self.state.plan
|
plan = self.state.plan
|
||||||
@@ -242,12 +366,30 @@ class PoseMover(QObject):
|
|||||||
self.state.plan = None
|
self.state.plan = None
|
||||||
self.failed.emit(message)
|
self.failed.emit(message)
|
||||||
|
|
||||||
|
@Slot(int, object)
|
||||||
|
def on_command_finished(self, token: int, result: object) -> None:
|
||||||
|
"""A rejected queued move must not later be reported as an arrival."""
|
||||||
|
if self.running and token == self._command_token:
|
||||||
|
self._command_token = None
|
||||||
|
if isinstance(result, MotionResult) and not result.ok:
|
||||||
|
self._fail(result.message)
|
||||||
|
|
||||||
|
@Slot(int, str)
|
||||||
|
def on_command_failed(self, token: int, message: str) -> None:
|
||||||
|
if self.running and token == self._command_token:
|
||||||
|
self._command_token = None
|
||||||
|
self._fail(message)
|
||||||
|
|
||||||
# ---------------------------------------------------------------- progress
|
# ---------------------------------------------------------------- progress
|
||||||
|
|
||||||
def on_snapshot(self, snapshot: Snapshot) -> None:
|
def on_snapshot(self, snapshot: Snapshot) -> None:
|
||||||
"""Advance the plan from a poll. Called on every refresh."""
|
"""Advance the plan from a poll. Called on every refresh."""
|
||||||
if not self.running:
|
if not self.running:
|
||||||
return
|
return
|
||||||
|
plan = self.state.plan
|
||||||
|
if plan is not None and plan.together:
|
||||||
|
self._on_snapshot_together(snapshot)
|
||||||
|
return
|
||||||
step = self.state.step
|
step = self.state.step
|
||||||
if step is None:
|
if step is None:
|
||||||
self._complete()
|
self._complete()
|
||||||
@@ -263,12 +405,11 @@ class PoseMover(QObject):
|
|||||||
self._fail(f"{step.role} 报警 / alarm on {step.role}")
|
self._fail(f"{step.role} 报警 / alarm on {step.role}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# A linear role reads in millimetres, a rotary one in degrees; the service
|
# Arrival uses the same verified units as the planned physical target.
|
||||||
# converts lengths, the units module converts calibrated angles.
|
|
||||||
if step.unit == "mm":
|
if step.unit == "mm":
|
||||||
travelled = self._service.to_mm(step.axis, state.dpos)
|
travelled = self._service.to_mm(step.axis, state.dpos)
|
||||||
else:
|
else:
|
||||||
travelled = user_units_to_deg(step.axis, state.dpos)
|
travelled = self._service.to_deg(step.axis, state.dpos)
|
||||||
tolerance = SETTLE_MM if step.unit == "mm" else SETTLE_DEG
|
tolerance = SETTLE_MM if step.unit == "mm" else SETTLE_DEG
|
||||||
|
|
||||||
if not state.idle:
|
if not state.idle:
|
||||||
@@ -289,12 +430,58 @@ class PoseMover(QObject):
|
|||||||
f"{step.target:.4f}{step.unit} / stopped short of target"
|
f"{step.target:.4f}{step.unit} / stopped short of target"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _on_snapshot_together(self, snapshot: Snapshot) -> None:
|
||||||
|
"""Finish when every commanded axis has arrived."""
|
||||||
|
plan = self.state.plan
|
||||||
|
if plan is None:
|
||||||
|
return
|
||||||
|
now = time.monotonic()
|
||||||
|
pending = False
|
||||||
|
for step in plan.steps:
|
||||||
|
state = snapshot.axis(step.axis)
|
||||||
|
if state is None:
|
||||||
|
return
|
||||||
|
if state.error or not state.read_ok:
|
||||||
|
self._fail(f"{step.role}: {state.error or 'read failed'}")
|
||||||
|
return
|
||||||
|
if state.status.alarm:
|
||||||
|
self._fail(f"{step.role} 报警 / alarm on {step.role}")
|
||||||
|
return
|
||||||
|
if step.unit == "mm":
|
||||||
|
travelled = self._service.to_mm(step.axis, state.dpos)
|
||||||
|
else:
|
||||||
|
travelled = self._service.to_deg(step.axis, state.dpos)
|
||||||
|
tolerance = SETTLE_MM if step.unit == "mm" else SETTLE_DEG
|
||||||
|
arrived = state.idle and abs(travelled - step.target) <= tolerance
|
||||||
|
if arrived:
|
||||||
|
self.state._group_idle.pop(step.axis, None)
|
||||||
|
continue
|
||||||
|
pending = True
|
||||||
|
if not state.idle:
|
||||||
|
self.state._group_idle.pop(step.axis, None)
|
||||||
|
continue
|
||||||
|
since = self.state._group_idle.get(step.axis)
|
||||||
|
if since is None:
|
||||||
|
self.state._group_idle[step.axis] = now
|
||||||
|
elif now - since > STALL_GRACE_S:
|
||||||
|
self._fail(
|
||||||
|
f"{step.role} 停在 {travelled:.4f}{step.unit},目标 "
|
||||||
|
f"{step.target:.4f}{step.unit} / stopped short of target"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not pending:
|
||||||
|
self._complete()
|
||||||
|
|
||||||
def progress_text(self) -> str:
|
def progress_text(self) -> str:
|
||||||
"""A one-line status for the panel."""
|
"""A one-line status for the panel."""
|
||||||
if self.state.failure:
|
if self.state.failure:
|
||||||
return f"✗ {self.state.failure}"
|
return f"✗ {self.state.failure}"
|
||||||
if self.state.finished:
|
if self.state.finished:
|
||||||
return "✓ 已完成 / done"
|
return "✓ 已完成 / done"
|
||||||
|
plan = self.state.plan
|
||||||
|
if plan is not None and plan.together:
|
||||||
|
roles = " ".join(step.role for step in plan.steps)
|
||||||
|
return f"同时 {roles}"
|
||||||
step = self.state.step
|
step = self.state.step
|
||||||
if step is None:
|
if step is None:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import logging
|
|||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable, Iterable, Mapping
|
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||||
|
|
||||||
from orisys_app_shared.zmotion import ZAuxError
|
from orisys_app_shared.zmotion import ZAuxError
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@ from .units import (
|
|||||||
mm_to_user_units,
|
mm_to_user_units,
|
||||||
safe_float,
|
safe_float,
|
||||||
units_per_degree,
|
units_per_degree,
|
||||||
|
user_units_to_deg,
|
||||||
user_units_to_mm,
|
user_units_to_mm,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,16 +65,16 @@ AXIS_ROLES: tuple[str, ...] = ("x", "y", "z", "yaw", "pitch")
|
|||||||
LINEAR_ROLES: tuple[str, ...] = ("x", "y", "z")
|
LINEAR_ROLES: tuple[str, ...] = ("x", "y", "z")
|
||||||
ROTARY_ROLES: tuple[str, ...] = ("yaw", "pitch")
|
ROTARY_ROLES: tuple[str, ...] = ("yaw", "pitch")
|
||||||
|
|
||||||
#: Default mapping, from the bench: drives 0-4 are x, y, pitch, yaw, z.
|
#: Default mapping, from the bench: drives 0-4 are x, y, yaw, pitch, z.
|
||||||
#:
|
#:
|
||||||
#: Deliberately *not* the same as the model's own order. It is written the way the
|
#: Pitch and yaw were first wired the other way around. Jogging the machine
|
||||||
#: machine is wired, so reading it against the terminal strip is a check rather than
|
#: showed drive 2 is yaw (回转) and drive 3 is pitch (俯仰). The degree scales
|
||||||
#: a translation.
|
#: stay on the drive numbers in ``units.py``.
|
||||||
DEFAULT_AXIS_ROLES: dict[str, int] = {
|
DEFAULT_AXIS_ROLES: dict[str, int] = {
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 1,
|
"y": 1,
|
||||||
"pitch": 2,
|
"pitch": 3,
|
||||||
"yaw": 3,
|
"yaw": 2,
|
||||||
"z": 4,
|
"z": 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +176,7 @@ class MotionService:
|
|||||||
self._limits: dict[str, float] = dict(limits or {})
|
self._limits: dict[str, float] = dict(limits or {})
|
||||||
|
|
||||||
self._spec: ConnectionSpec | None = None
|
self._spec: ConnectionSpec | None = None
|
||||||
|
self._last_axes: dict[int, AxisState] = {}
|
||||||
self._bus = BusStatus()
|
self._bus = BusStatus()
|
||||||
self._params: dict[int, AxisParams] = {
|
self._params: dict[int, AxisParams] = {
|
||||||
axis: AxisParams() for axis in range(self.axis_count)
|
axis: AxisParams() for axis in range(self.axis_count)
|
||||||
@@ -273,25 +275,62 @@ class MotionService:
|
|||||||
key = "max_travel_mm" if role in LINEAR_ROLES else f"max_{role}_deg"
|
key = "max_travel_mm" if role in LINEAR_ROLES else f"max_{role}_deg"
|
||||||
return safe_float(self._limits.get(key), 0.0)
|
return safe_float(self._limits.get(key), 0.0)
|
||||||
|
|
||||||
def units_problem(self, role: str, axis: int) -> str:
|
def units_problem(self, role: str, axis: int, *, refresh: bool = False) -> str:
|
||||||
"""Why a role cannot be commanded in its own unit, or ``""``."""
|
"""Validate expected versus observed units, without changing either.
|
||||||
if role in LINEAR_ROLES:
|
|
||||||
return ""
|
``refresh`` is only for the controller worker immediately before execution.
|
||||||
scale = units_per_degree(axis)
|
UI/planning checks use the last poll and never call the transport.
|
||||||
if not math.isfinite(scale) or scale <= 0:
|
"""
|
||||||
|
expected = self.params(axis).units
|
||||||
|
if not math.isfinite(expected) or expected <= 0:
|
||||||
return self._text(
|
return self._text(
|
||||||
f"{role} 用轴 {axis},但该轴没有角度标定(用户单位/度)",
|
f"轴 {axis} 的 UNITS 无效", f"Axis {axis} has invalid expected UNITS"
|
||||||
f"{role} uses axis {axis}, which has no user-units-per-degree "
|
)
|
||||||
f"calibration",
|
if role not in LINEAR_ROLES and not math.isfinite(
|
||||||
|
units_per_degree(axis, units=expected)
|
||||||
|
):
|
||||||
|
return self._text(
|
||||||
|
f"{role} 用轴 {axis},但该轴没有当前 UNITS={expected:g} 对应的角度标定",
|
||||||
|
f"{role} uses axis {axis}, which has no rotary calibration for UNITS={expected:g}",
|
||||||
|
)
|
||||||
|
if refresh:
|
||||||
|
try:
|
||||||
|
observed = self._transport.get_units(axis)
|
||||||
|
except (ZAuxError, OSError) as exc:
|
||||||
|
return self._text(
|
||||||
|
f"无法读取轴 {axis} 的 UNITS:{exc}",
|
||||||
|
f"Cannot read axis {axis} UNITS: {exc}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
state = self._last_axes.get(axis)
|
||||||
|
if state is None or not state.read_ok:
|
||||||
|
return self._text(
|
||||||
|
f"轴 {axis} 的 UNITS 尚未成功读回",
|
||||||
|
f"Axis {axis} UNITS has no successful readback",
|
||||||
|
)
|
||||||
|
observed = state.units
|
||||||
|
if (
|
||||||
|
not math.isfinite(observed)
|
||||||
|
or observed <= 0
|
||||||
|
or not math.isclose(expected, observed, rel_tol=1e-7, abs_tol=0.0)
|
||||||
|
):
|
||||||
|
return self._text(
|
||||||
|
f"轴 {axis} 的 UNITS 不一致:配置 {expected:g},读回 {observed:g};未重新换算运动目标",
|
||||||
|
f"Axis {axis} UNITS mismatch: expected {expected:g}, read {observed:g}; motion was not rescaled",
|
||||||
)
|
)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def check_axes_move(self, targets: Mapping[str, float]) -> MotionResult | None:
|
def check_axes_move(
|
||||||
|
self, targets: Mapping[str, float], *, refresh_units: bool = False
|
||||||
|
) -> MotionResult | None:
|
||||||
"""Refuse a coordinated pose move, or return ``None`` to allow it.
|
"""Refuse a coordinated pose move, or return ``None`` to allow it.
|
||||||
|
|
||||||
``targets`` is per role, in the role's own unit: millimetres for x / y / z,
|
``targets`` is per role, in the role's own unit: millimetres for x / y / z,
|
||||||
degrees for yaw / pitch.
|
degrees for yaw / pitch.
|
||||||
|
|
||||||
|
``refresh_units`` reads the current pulse equivalents and is worker-only.
|
||||||
|
It never writes them or rescales any already planned target.
|
||||||
|
|
||||||
Every reason a move can be wrong is checked *before* the first axis is
|
Every reason a move can be wrong is checked *before* the first axis is
|
||||||
commanded. A pose move is several coordinated moves, and discovering at axis
|
commanded. A pose move is several coordinated moves, and discovering at axis
|
||||||
three that axis four is out of range would leave the machine stopped
|
three that axis four is out of range would leave the machine stopped
|
||||||
@@ -321,7 +360,7 @@ class MotionService:
|
|||||||
self._text(f"{role} 目标不是有效数字", f"{role} target is not a number")
|
self._text(f"{role} 目标不是有效数字", f"{role} target is not a number")
|
||||||
)
|
)
|
||||||
axis = self._axis_roles[role]
|
axis = self._axis_roles[role]
|
||||||
units_problem = self.units_problem(role, axis)
|
units_problem = self.units_problem(role, axis, refresh=refresh_units)
|
||||||
if units_problem:
|
if units_problem:
|
||||||
return MotionResult.refused(units_problem)
|
return MotionResult.refused(units_problem)
|
||||||
for role, value in targets.items():
|
for role, value in targets.items():
|
||||||
@@ -350,11 +389,11 @@ class MotionService:
|
|||||||
if axis is None:
|
if axis is None:
|
||||||
return math.nan
|
return math.nan
|
||||||
if role in LINEAR_ROLES:
|
if role in LINEAR_ROLES:
|
||||||
units = self.params(axis).units or self._units_per_mm
|
units = self.params(axis).units
|
||||||
return mm_to_user_units(
|
return mm_to_user_units(
|
||||||
value, units=units, pulses_per_mm=self._pulses_per_mm
|
value, units=units, pulses_per_mm=self._pulses_per_mm
|
||||||
)
|
)
|
||||||
return deg_to_user_units(axis, value)
|
return deg_to_user_units(axis, value, units=self.params(axis).units)
|
||||||
|
|
||||||
def set_language(self, language: str) -> None:
|
def set_language(self, language: str) -> None:
|
||||||
self.language = language
|
self.language = language
|
||||||
@@ -399,12 +438,21 @@ class MotionService:
|
|||||||
return MotionResult.failed(code, message)
|
return MotionResult.failed(code, message)
|
||||||
|
|
||||||
self._spec = spec
|
self._spec = spec
|
||||||
logger.info("Connected: %s via %s", spec.describe(), self._transport.source)
|
self._last_axes.clear()
|
||||||
# A controller may already have a BAS program running from a previous
|
# A controller may already have a BAS program running from a previous
|
||||||
# session; adopt it rather than claiming the bus is uninitialised. The
|
# session; adopt it rather than claiming the bus is uninitialised. The
|
||||||
# adopted state is *not* marked verified: its globals are a latch, and
|
# adopted state is *not* marked verified: its globals are a latch, and
|
||||||
# nothing in this session has yet seen a scan happen.
|
# nothing in this session has yet seen a scan happen.
|
||||||
|
# The remembered file and the preference-file axis parameters stay on
|
||||||
|
# the PC. Writing either here would replace configuration set from the
|
||||||
|
# vendor software, so connect only reads.
|
||||||
self._adopt_existing_bas(bas_path=bas_path)
|
self._adopt_existing_bas(bas_path=bas_path)
|
||||||
|
logger.info(
|
||||||
|
"Connected: %s via %s. BAS was not downloaded and axis parameters were not written.",
|
||||||
|
spec.describe(),
|
||||||
|
self._transport.source,
|
||||||
|
)
|
||||||
|
self._log_axis_readback("connect")
|
||||||
return MotionResult.success(f"已连接 {spec.describe()}" if self._is_zh else f"Connected {spec.describe()}")
|
return MotionResult.success(f"已连接 {spec.describe()}" if self._is_zh else f"Connected {spec.describe()}")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -602,6 +650,7 @@ class MotionService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._last_axes.update({state.axis: state for state in states})
|
||||||
return Snapshot(
|
return Snapshot(
|
||||||
connected=True,
|
connected=True,
|
||||||
spec=self._spec,
|
spec=self._spec,
|
||||||
@@ -716,7 +765,6 @@ class MotionService:
|
|||||||
home_status = 0
|
home_status = 0
|
||||||
self._home_done[axis] = home_status == 1
|
self._home_done[axis] = home_status == 1
|
||||||
|
|
||||||
params = self.params(axis)
|
|
||||||
return AxisState(
|
return AxisState(
|
||||||
axis=axis,
|
axis=axis,
|
||||||
enabled=transport.get_axis_enable(axis) != 0,
|
enabled=transport.get_axis_enable(axis) != 0,
|
||||||
@@ -740,6 +788,8 @@ class MotionService:
|
|||||||
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
|
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
|
||||||
if not self._known_axis(axis):
|
if not self._known_axis(axis):
|
||||||
return MotionResult.refused(self._axis_range_message(axis))
|
return MotionResult.refused(self._axis_range_message(axis))
|
||||||
|
# A partial write may already change UNITS even if speed/accel then fails.
|
||||||
|
self._last_axes.pop(axis, None)
|
||||||
try:
|
try:
|
||||||
self._transport.set_units(axis, params.units)
|
self._transport.set_units(axis, params.units)
|
||||||
self._transport.set_speed(axis, params.speed)
|
self._transport.set_speed(axis, params.speed)
|
||||||
@@ -748,7 +798,17 @@ class MotionService:
|
|||||||
code = getattr(exc, "code", 0)
|
code = getattr(exc, "code", 0)
|
||||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
||||||
self._params[axis] = params
|
self._params[axis] = params
|
||||||
return MotionResult.success()
|
result = MotionResult.success()
|
||||||
|
logger.info(
|
||||||
|
"Wrote units/speed/accel on axis %s (units=%s speed=%s accel=%s); "
|
||||||
|
"drive profile and following-error limit were not written. %s",
|
||||||
|
axis,
|
||||||
|
params.units,
|
||||||
|
params.speed,
|
||||||
|
params.accel,
|
||||||
|
self._position_text(axis),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
def set_axis_enable(self, axis: int, enabled: bool) -> MotionResult:
|
def set_axis_enable(self, axis: int, enabled: bool) -> MotionResult:
|
||||||
if not self.connected:
|
if not self.connected:
|
||||||
@@ -759,10 +819,18 @@ class MotionService:
|
|||||||
self._transport.set_axis_enable(axis, enabled)
|
self._transport.set_axis_enable(axis, enabled)
|
||||||
except (ZAuxError, OSError) as exc:
|
except (ZAuxError, OSError) as exc:
|
||||||
code = getattr(exc, "code", 0)
|
code = getattr(exc, "code", 0)
|
||||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
return self._logged(
|
||||||
return MotionResult.success(
|
"enable",
|
||||||
|
axis,
|
||||||
|
MotionResult.failed(code, self._msg(code) if code else str(exc)),
|
||||||
|
)
|
||||||
|
return self._logged(
|
||||||
|
"enable",
|
||||||
|
axis,
|
||||||
|
MotionResult.success(
|
||||||
self._text(f"轴{axis} 已{'使能' if enabled else '关闭'}",
|
self._text(f"轴{axis} 已{'使能' if enabled else '关闭'}",
|
||||||
f"Axis {axis} {'enabled' if enabled else 'disabled'}")
|
f"Axis {axis} {'enabled' if enabled else 'disabled'}")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def clear_alarm(self, axis: int) -> MotionResult:
|
def clear_alarm(self, axis: int) -> MotionResult:
|
||||||
@@ -774,8 +842,16 @@ class MotionService:
|
|||||||
self._transport.bus_drive_clear(axis, 0)
|
self._transport.bus_drive_clear(axis, 0)
|
||||||
except (ZAuxError, OSError) as exc:
|
except (ZAuxError, OSError) as exc:
|
||||||
code = getattr(exc, "code", 0)
|
code = getattr(exc, "code", 0)
|
||||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
return self._logged(
|
||||||
return MotionResult.success(self._text("已清除报警", "Alarm cleared"))
|
"clear_alarm",
|
||||||
|
axis,
|
||||||
|
MotionResult.failed(code, self._msg(code) if code else str(exc)),
|
||||||
|
)
|
||||||
|
return self._logged(
|
||||||
|
"clear_alarm",
|
||||||
|
axis,
|
||||||
|
MotionResult.success(self._text("已清除报警", "Alarm cleared")),
|
||||||
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ motion
|
# ------------------------------------------------------------------ motion
|
||||||
|
|
||||||
@@ -797,15 +873,59 @@ class MotionService:
|
|||||||
def jog(self, axis: int, forward: bool) -> MotionResult:
|
def jog(self, axis: int, forward: bool) -> MotionResult:
|
||||||
refusal = self._motion_guard(axis)
|
refusal = self._motion_guard(axis)
|
||||||
if refusal is not None:
|
if refusal is not None:
|
||||||
return refusal
|
return self._logged("jog", axis, refusal)
|
||||||
try:
|
try:
|
||||||
self._transport.jog(axis, forward)
|
self._transport.jog(axis, forward)
|
||||||
except (ZAuxError, OSError) as exc:
|
except (ZAuxError, OSError) as exc:
|
||||||
code = getattr(exc, "code", 0)
|
code = getattr(exc, "code", 0)
|
||||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
return self._logged(
|
||||||
return MotionResult.success(
|
"jog",
|
||||||
self._text("正转" if forward else "反转", "Forward" if forward else "Reverse")
|
axis,
|
||||||
|
MotionResult.failed(code, self._msg(code) if code else str(exc)),
|
||||||
)
|
)
|
||||||
|
return self._logged(
|
||||||
|
"jog",
|
||||||
|
axis,
|
||||||
|
MotionResult.success(
|
||||||
|
self._text("正转" if forward else "反转", "Forward" if forward else "Reverse")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def move_abs_many(self, moves: Sequence[tuple[int, float]]) -> MotionResult:
|
||||||
|
"""Absolute-move several axes in one call so they travel together.
|
||||||
|
|
||||||
|
Guards are checked for every axis before the first command is sent, so a
|
||||||
|
refusal does not leave the earlier axes already moving. Each move still
|
||||||
|
uses the same absolute-move call as a single axis.
|
||||||
|
"""
|
||||||
|
pending = [(int(axis), float(value)) for axis, value in moves]
|
||||||
|
if not pending:
|
||||||
|
return MotionResult.refused(self._text("没有要移动的轴", "Nothing to move"))
|
||||||
|
for axis, value in pending:
|
||||||
|
refusal = self._motion_guard(axis)
|
||||||
|
if refusal is not None:
|
||||||
|
return self._logged("move_abs", axis, refusal)
|
||||||
|
if not math.isfinite(value):
|
||||||
|
return self._logged(
|
||||||
|
"move_abs",
|
||||||
|
axis,
|
||||||
|
MotionResult.refused(
|
||||||
|
self._text("目标位置请输入数字", "Enter a numeric position")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
last = MotionResult.success()
|
||||||
|
for axis, value in pending:
|
||||||
|
try:
|
||||||
|
self._transport.move_abs(axis, value)
|
||||||
|
except (ZAuxError, OSError) as exc:
|
||||||
|
code = getattr(exc, "code", 0)
|
||||||
|
return self._logged(
|
||||||
|
"move_abs",
|
||||||
|
axis,
|
||||||
|
MotionResult.failed(code, self._msg(code) if code else str(exc)),
|
||||||
|
)
|
||||||
|
last = self._logged("move_abs", axis, MotionResult.success())
|
||||||
|
return last
|
||||||
|
|
||||||
def move_abs(self, axis: int, target: float) -> MotionResult:
|
def move_abs(self, axis: int, target: float) -> MotionResult:
|
||||||
return self._move(axis, target, absolute=True)
|
return self._move(axis, target, absolute=True)
|
||||||
@@ -814,22 +934,32 @@ class MotionService:
|
|||||||
return self._move(axis, distance, absolute=False)
|
return self._move(axis, distance, absolute=False)
|
||||||
|
|
||||||
def _move(self, axis: int, value: float, *, absolute: bool) -> MotionResult:
|
def _move(self, axis: int, value: float, *, absolute: bool) -> MotionResult:
|
||||||
|
action = "move_abs" if absolute else "move_rel"
|
||||||
refusal = self._motion_guard(axis)
|
refusal = self._motion_guard(axis)
|
||||||
if refusal is not None:
|
if refusal is not None:
|
||||||
return refusal
|
return self._logged(action, axis, refusal)
|
||||||
if not isinstance(value, (int, float)) or not math.isfinite(float(value)):
|
if not isinstance(value, (int, float)) or not math.isfinite(float(value)):
|
||||||
return MotionResult.refused(
|
return self._logged(
|
||||||
|
action,
|
||||||
|
axis,
|
||||||
|
MotionResult.refused(
|
||||||
self._text("目标位置请输入数字", "Enter a numeric position")
|
self._text("目标位置请输入数字", "Enter a numeric position")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
sent = float(value)
|
||||||
if absolute:
|
if absolute:
|
||||||
self._transport.move_abs(axis, float(value))
|
self._transport.move_abs(axis, sent)
|
||||||
else:
|
else:
|
||||||
self._transport.move_rel(axis, float(value))
|
self._transport.move_rel(axis, sent)
|
||||||
except (ZAuxError, OSError) as exc:
|
except (ZAuxError, OSError) as exc:
|
||||||
code = getattr(exc, "code", 0)
|
code = getattr(exc, "code", 0)
|
||||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
return self._logged(
|
||||||
return MotionResult.success()
|
action,
|
||||||
|
axis,
|
||||||
|
MotionResult.failed(code, self._msg(code) if code else str(exc)),
|
||||||
|
)
|
||||||
|
return self._logged(action, axis, MotionResult.success())
|
||||||
|
|
||||||
def stop(self, axis: int) -> MotionResult:
|
def stop(self, axis: int) -> MotionResult:
|
||||||
"""Stop one axis (decelerating stop, mode 2)."""
|
"""Stop one axis (decelerating stop, mode 2)."""
|
||||||
@@ -841,8 +971,16 @@ class MotionService:
|
|||||||
self._transport.stop(axis, 2)
|
self._transport.stop(axis, 2)
|
||||||
except (ZAuxError, OSError) as exc:
|
except (ZAuxError, OSError) as exc:
|
||||||
code = getattr(exc, "code", 0)
|
code = getattr(exc, "code", 0)
|
||||||
return MotionResult.failed(code, self._msg(code) if code else str(exc))
|
return self._logged(
|
||||||
return MotionResult.success(self._text("已停止", "Stopped"))
|
"stop",
|
||||||
|
axis,
|
||||||
|
MotionResult.failed(code, self._msg(code) if code else str(exc)),
|
||||||
|
)
|
||||||
|
return self._logged(
|
||||||
|
"stop",
|
||||||
|
axis,
|
||||||
|
MotionResult.success(self._text("已停止", "Stopped")),
|
||||||
|
)
|
||||||
|
|
||||||
def stop_all(self) -> list[MotionResult]:
|
def stop_all(self) -> list[MotionResult]:
|
||||||
"""Stop every axis. Used before disconnecting."""
|
"""Stop every axis. Used before disconnecting."""
|
||||||
@@ -882,6 +1020,40 @@ class MotionService:
|
|||||||
self._home_done[axis] = False
|
self._home_done[axis] = False
|
||||||
return MotionResult.success(self._text("回零已启动", "Homing started"))
|
return MotionResult.success(self._text("回零已启动", "Homing started"))
|
||||||
|
|
||||||
|
def _log_axis_readback(self, action: str) -> None:
|
||||||
|
"""Log the last poll without issuing extra controller reads."""
|
||||||
|
for axis in range(self.axis_count):
|
||||||
|
self._log_command(action, axis, MotionResult.success("read"))
|
||||||
|
|
||||||
|
def _logged(self, action: str, axis: int, result: MotionResult) -> MotionResult:
|
||||||
|
self._log_command(action, axis, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _log_command(self, action: str, axis: int, result: MotionResult) -> None:
|
||||||
|
text = (
|
||||||
|
f"{action} axis {axis} -> ok={result.ok} code={result.code} "
|
||||||
|
f"message={result.message!r} {self._position_text(axis)}"
|
||||||
|
)
|
||||||
|
if result.ok:
|
||||||
|
logger.info(text)
|
||||||
|
else:
|
||||||
|
logger.warning(text)
|
||||||
|
|
||||||
|
def _position_text(self, axis: int) -> str:
|
||||||
|
"""Commanded and measured position, for the command log.
|
||||||
|
|
||||||
|
These are cached poll values, not a synchronous post-command read. Logging
|
||||||
|
must not access the controller from the calling thread.
|
||||||
|
"""
|
||||||
|
state = self._last_axes.get(axis)
|
||||||
|
if not self.connected or state is None or not state.read_ok:
|
||||||
|
return "dpos=unread mpos=unread status=unread enable=unread"
|
||||||
|
return (
|
||||||
|
f"last_poll dpos={state.dpos} mpos={state.mpos} "
|
||||||
|
f"status={state.status.raw} enable={state.enabled} "
|
||||||
|
f"atype={state.atype} units={state.units} speed={state.speed}"
|
||||||
|
)
|
||||||
|
|
||||||
def _known_axis(self, axis: int) -> bool:
|
def _known_axis(self, axis: int) -> bool:
|
||||||
return 0 <= int(axis) < self.axis_count
|
return 0 <= int(axis) < self.axis_count
|
||||||
|
|
||||||
@@ -963,11 +1135,19 @@ class MotionService:
|
|||||||
|
|
||||||
def to_mm(self, axis: int, user_units: float) -> float:
|
def to_mm(self, axis: int, user_units: float) -> float:
|
||||||
"""Displayed millimetres for a reading. Derived, never a measurement."""
|
"""Displayed millimetres for a reading. Derived, never a measurement."""
|
||||||
units = self.params(axis).units or self._units_per_mm
|
if self.units_problem("x", axis):
|
||||||
|
return math.nan
|
||||||
|
units = self.params(axis).units
|
||||||
return user_units_to_mm(
|
return user_units_to_mm(
|
||||||
user_units, units=units, pulses_per_mm=self._pulses_per_mm
|
user_units, units=units, pulses_per_mm=self._pulses_per_mm
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def to_deg(self, axis: int, user_units: float) -> float:
|
||||||
|
"""Rotary readback in degrees, or NaN without matching calibrated units."""
|
||||||
|
if self.units_problem("yaw", axis):
|
||||||
|
return math.nan
|
||||||
|
return user_units_to_deg(axis, user_units, units=self.params(axis).units)
|
||||||
|
|
||||||
|
|
||||||
def _replace_bus_type(bus: BusStatus, bus_type: BusType) -> BusStatus:
|
def _replace_bus_type(bus: BusStatus, bus_type: BusType) -> BusStatus:
|
||||||
return BusStatus(
|
return BusStatus(
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""What the platform's unit settings say, one row per direction (Qt-free).
|
||||||
|
|
||||||
|
The motion guards already refuse a move when the configured pulse equivalent
|
||||||
|
(``UNITS``) and the one the controller reports disagree. This module answers the
|
||||||
|
operator's question *before* a move is refused: which direction uses which drive,
|
||||||
|
what ``UNITS`` is configured, what the controller reads back, what one user unit
|
||||||
|
means in millimetres or degrees, and whether all of it holds together.
|
||||||
|
|
||||||
|
The verdicts follow :meth:`MotionService.units_problem` in the same order, so the
|
||||||
|
page and the guard cannot disagree about what is wrong. Nothing here writes to a
|
||||||
|
controller or changes a setting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from .models import Snapshot
|
||||||
|
from .service import AXIS_ROLES, LINEAR_ROLES
|
||||||
|
from .units import PULSES_PER_MM, units_per_degree
|
||||||
|
|
||||||
|
#: Relative tolerance of the expected-versus-read-back comparison. The guard uses
|
||||||
|
#: the same number; a different one here would show "ok" for a move it refuses.
|
||||||
|
UNITS_TOLERANCE = 1e-7
|
||||||
|
|
||||||
|
OK = "ok"
|
||||||
|
UNREAD = "unread"
|
||||||
|
MISMATCH = "mismatch"
|
||||||
|
UNCALIBRATED = "uncalibrated"
|
||||||
|
INVALID = "invalid"
|
||||||
|
UNMAPPED = "unmapped"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UnitRow:
|
||||||
|
"""One direction's unit facts.
|
||||||
|
|
||||||
|
``per_unit`` is millimetres (linear) or degrees (rotary) per controller user
|
||||||
|
unit, ``per_mm`` the inverse (user units per millimetre or per degree). Both
|
||||||
|
are NaN when the setting does not define them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
role: str
|
||||||
|
axis: int
|
||||||
|
kind: str
|
||||||
|
expected: float
|
||||||
|
observed: float | None
|
||||||
|
status: str
|
||||||
|
per_unit: float
|
||||||
|
per_mm: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self.status == OK
|
||||||
|
|
||||||
|
|
||||||
|
def build_unit_rows(
|
||||||
|
roles: Mapping[str, int],
|
||||||
|
expected_of: Callable[[int], float],
|
||||||
|
snapshot: Snapshot | None,
|
||||||
|
) -> list[UnitRow]:
|
||||||
|
"""One row per platform direction, in the order the machine is described."""
|
||||||
|
rows: list[UnitRow] = []
|
||||||
|
for role in AXIS_ROLES:
|
||||||
|
kind = "linear" if role in LINEAR_ROLES else "rotary"
|
||||||
|
axis = roles.get(role)
|
||||||
|
if axis is None:
|
||||||
|
rows.append(UnitRow(role, -1, kind, math.nan, None, UNMAPPED, math.nan, math.nan))
|
||||||
|
continue
|
||||||
|
axis = int(axis)
|
||||||
|
expected = _number(expected_of(axis))
|
||||||
|
observed = _observed(snapshot, axis)
|
||||||
|
per_unit, per_mm = _scale(kind, axis, expected)
|
||||||
|
status = _status(kind, axis, expected, observed)
|
||||||
|
rows.append(UnitRow(role, axis, kind, expected, observed, status, per_unit, per_mm))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def summarise(rows: list[UnitRow]) -> tuple[int, int, list[UnitRow]]:
|
||||||
|
"""``(rows that are fine, rows in all, the rows that are not)``."""
|
||||||
|
problems = [row for row in rows if not row.ok]
|
||||||
|
return len(rows) - len(problems), len(rows), problems
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: object) -> float:
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return math.nan
|
||||||
|
|
||||||
|
|
||||||
|
def _observed(snapshot: Snapshot | None, axis: int) -> float | None:
|
||||||
|
if snapshot is None or not snapshot.connected:
|
||||||
|
return None
|
||||||
|
state = snapshot.axis(axis)
|
||||||
|
if state is None or not state.read_ok:
|
||||||
|
return None
|
||||||
|
return float(state.units)
|
||||||
|
|
||||||
|
|
||||||
|
def _scale(kind: str, axis: int, expected: float) -> tuple[float, float]:
|
||||||
|
"""Per-unit and per-millimetre (or per-degree) factors for the configured value."""
|
||||||
|
if not math.isfinite(expected) or expected <= 0:
|
||||||
|
return math.nan, math.nan
|
||||||
|
if kind == "linear":
|
||||||
|
return expected / PULSES_PER_MM, PULSES_PER_MM / expected
|
||||||
|
per_degree = units_per_degree(axis, units=expected)
|
||||||
|
if not math.isfinite(per_degree) or per_degree <= 0:
|
||||||
|
return math.nan, math.nan
|
||||||
|
return 1.0 / per_degree, per_degree
|
||||||
|
|
||||||
|
|
||||||
|
def _status(kind: str, axis: int, expected: float, observed: float | None) -> str:
|
||||||
|
if not math.isfinite(expected) or expected <= 0:
|
||||||
|
return INVALID
|
||||||
|
if kind == "rotary" and not math.isfinite(units_per_degree(axis, units=expected)):
|
||||||
|
return UNCALIBRATED
|
||||||
|
if observed is None:
|
||||||
|
return UNREAD
|
||||||
|
if (
|
||||||
|
not math.isfinite(observed)
|
||||||
|
or observed <= 0
|
||||||
|
or not math.isclose(expected, observed, rel_tol=UNITS_TOLERANCE, abs_tol=0.0)
|
||||||
|
):
|
||||||
|
return MISMATCH
|
||||||
|
return OK
|
||||||
@@ -34,6 +34,10 @@ ROTARY_UNITS_PER_90_DEG: dict[int, float] = {
|
|||||||
3: 29500.0,
|
3: 29500.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#: Pulse equivalent at which both rotary user-unit scales were measured.
|
||||||
|
#: Other equivalents require a new calibration; do not rescale commissioned moves.
|
||||||
|
ROTARY_CALIBRATION_UNITS = 10.0
|
||||||
|
|
||||||
|
|
||||||
def safe_float(text: object, fallback: float) -> float:
|
def safe_float(text: object, fallback: float) -> float:
|
||||||
"""Parse ``text`` as a finite float, falling back on anything unusable.
|
"""Parse ``text`` as a finite float, falling back on anything unusable.
|
||||||
@@ -80,25 +84,33 @@ def mm_to_user_units(
|
|||||||
return float(value) * float(pulses_per_mm) / float(units)
|
return float(value) * float(pulses_per_mm) / float(units)
|
||||||
|
|
||||||
|
|
||||||
def units_per_degree(axis: int) -> float:
|
def units_per_degree(axis: int, *, units: float = ROTARY_CALIBRATION_UNITS) -> float:
|
||||||
"""User units per degree for a rotary axis, or NaN if not calibrated."""
|
"""User units per degree for a rotary axis, or NaN if not calibrated."""
|
||||||
per_90 = ROTARY_UNITS_PER_90_DEG.get(int(axis))
|
per_90 = ROTARY_UNITS_PER_90_DEG.get(int(axis))
|
||||||
if per_90 is None or per_90 <= 0:
|
if (
|
||||||
|
per_90 is None
|
||||||
|
or per_90 <= 0
|
||||||
|
or not math.isclose(units, ROTARY_CALIBRATION_UNITS, rel_tol=1e-7, abs_tol=0.0)
|
||||||
|
):
|
||||||
return math.nan
|
return math.nan
|
||||||
return float(per_90) / 90.0
|
return float(per_90) / 90.0
|
||||||
|
|
||||||
|
|
||||||
def user_units_to_deg(axis: int, value: float) -> float:
|
def user_units_to_deg(
|
||||||
|
axis: int, value: float, *, units: float = ROTARY_CALIBRATION_UNITS
|
||||||
|
) -> float:
|
||||||
"""Convert controller user units to degrees for a calibrated rotary axis."""
|
"""Convert controller user units to degrees for a calibrated rotary axis."""
|
||||||
scale = units_per_degree(axis)
|
scale = units_per_degree(axis, units=units)
|
||||||
if not math.isfinite(scale) or scale == 0.0:
|
if not math.isfinite(scale) or scale == 0.0:
|
||||||
return math.nan
|
return math.nan
|
||||||
return float(value) / scale
|
return float(value) / scale
|
||||||
|
|
||||||
|
|
||||||
def deg_to_user_units(axis: int, degrees: float) -> float:
|
def deg_to_user_units(
|
||||||
|
axis: int, degrees: float, *, units: float = ROTARY_CALIBRATION_UNITS
|
||||||
|
) -> float:
|
||||||
"""Inverse of :func:`user_units_to_deg`."""
|
"""Inverse of :func:`user_units_to_deg`."""
|
||||||
scale = units_per_degree(axis)
|
scale = units_per_degree(axis, units=units)
|
||||||
if not math.isfinite(scale):
|
if not math.isfinite(scale):
|
||||||
return math.nan
|
return math.nan
|
||||||
return float(degrees) * scale
|
return float(degrees) * scale
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,11 @@ from .motion.service import (
|
|||||||
clamp_bas_task,
|
clamp_bas_task,
|
||||||
)
|
)
|
||||||
from .motion.units import DEFAULT_ACCEL, DEFAULT_SPEED, DEFAULT_UNITS
|
from .motion.units import DEFAULT_ACCEL, DEFAULT_SPEED, DEFAULT_UNITS
|
||||||
|
from .recording_options import (
|
||||||
|
DEFAULT_CODEC,
|
||||||
|
DEFAULT_LOSSLESS_FRAMES,
|
||||||
|
DEFAULT_VIDEO,
|
||||||
|
)
|
||||||
|
|
||||||
#: Connection modes, in the order the combo offers them.
|
#: Connection modes, in the order the combo offers them.
|
||||||
CONNECTION_MODES = ("eth", "com", "pci", "local", "demo")
|
CONNECTION_MODES = ("eth", "com", "pci", "local", "demo")
|
||||||
@@ -99,11 +104,33 @@ def _default_safety_limits() -> dict[str, float]:
|
|||||||
#: How far the axis may descend looking for contact before the run is
|
#: How far the axis may descend looking for contact before the run is
|
||||||
#: aborted (workflow P7, no-contact abort).
|
#: aborted (workflow P7, no-contact abort).
|
||||||
"contact_search_mm": 3.0,
|
"contact_search_mm": 3.0,
|
||||||
#: Stage envelope, from the confirmed bench values: the yaw stage turns a
|
#: Stage envelope. Yaw turns ±180. Pitch is limited to ±90 on this bench:
|
||||||
#: half-turn either way, the pitch stage a full turn. These are the limits a
|
#: past that the fixture interferes, so a pose move is refused.
|
||||||
#: pose move is checked against before any axis is commanded.
|
|
||||||
"max_yaw_deg": 180.0,
|
"max_yaw_deg": 180.0,
|
||||||
"max_pitch_deg": 360.0,
|
"max_pitch_deg": 90.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _default_press_axis() -> dict[str, Any]:
|
||||||
|
"""How the press axis's coordinate relates to the geometric contact height.
|
||||||
|
|
||||||
|
The solved contact sits at ``Pose.contact_z_mm`` above the table's origin
|
||||||
|
plane, in millimetres of *geometry*. The press axis reports its own coordinate,
|
||||||
|
in its own units, counted from its own mechanical zero — the two are different
|
||||||
|
numbers, and only a measurement on the bench relates them.
|
||||||
|
|
||||||
|
``zero_mm`` is the press axis reading at which the tool tip meets the table's
|
||||||
|
origin plane (so the tool tip's own height is already inside it), and
|
||||||
|
``rising`` is the drive's sign: true when a larger coordinate lifts the tip.
|
||||||
|
``calibrated`` stays false until an operator has measured both against the
|
||||||
|
machine, and until then the pose-move path holds the press axis instead of
|
||||||
|
driving it to a geometric height nobody has anchored — the previous behaviour,
|
||||||
|
which passed the geometric number straight through as an absolute target.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"zero_mm": 0.0,
|
||||||
|
"rising": True,
|
||||||
|
"calibrated": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -154,6 +181,20 @@ def _default_axis_params() -> dict[str, dict[str, float | int]]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _default_recording() -> dict[str, Any]:
|
||||||
|
"""Plan P2: what a capture records.
|
||||||
|
|
||||||
|
The bench default stays ``mp4v`` until the P2b equivalence report is accepted
|
||||||
|
(decision D2); video is captured by default because it is the evidence the
|
||||||
|
review and the video page read.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"video": DEFAULT_VIDEO,
|
||||||
|
"codec": DEFAULT_CODEC,
|
||||||
|
"lossless_frames": DEFAULT_LOSSLESS_FRAMES,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def default_preferences() -> dict[str, Any]:
|
def default_preferences() -> dict[str, Any]:
|
||||||
"""AutoCal's default preference document."""
|
"""AutoCal's default preference document."""
|
||||||
return {
|
return {
|
||||||
@@ -205,10 +246,20 @@ def default_preferences() -> dict[str, Any]:
|
|||||||
"sensor_setup": _default_sensor_setup(),
|
"sensor_setup": _default_sensor_setup(),
|
||||||
#: P5: grid generation and run parameters.
|
#: P5: grid generation and run parameters.
|
||||||
"sequence_profile": _default_sequence_profile(),
|
"sequence_profile": _default_sequence_profile(),
|
||||||
|
#: P2: video on/off, codec and lossless frames for a new capture session.
|
||||||
|
"recording": _default_recording(),
|
||||||
|
#: The press axis's mechanical zero and sign, so a solved contact height
|
||||||
|
#: can become a controller target. Uncalibrated means the press axis is
|
||||||
|
#: held rather than driven.
|
||||||
|
"press_axis": _default_press_axis(),
|
||||||
# --- finger debug screen ---------------------------------------------
|
# --- finger debug screen ---------------------------------------------
|
||||||
#: The export bundle the debug screen loads. Empty means "ask", and the
|
#: The export bundle the debug screen loads. Empty means "ask", and the
|
||||||
#: default below points at the revision the bench confirmed.
|
#: default below points at the revision the bench confirmed.
|
||||||
"finger_bundle_path": DEFAULT_FINGER_BUNDLE,
|
"finger_bundle_path": DEFAULT_FINGER_BUNDLE,
|
||||||
|
"finger_video_path": "",
|
||||||
|
"finger_config_ref": "",
|
||||||
|
"finger_source_kind": "video",
|
||||||
|
"finger_camera_index": 0,
|
||||||
#: The UV-space texture for the flat picker. Preferred over the derived
|
#: The UV-space texture for the flat picker. Preferred over the derived
|
||||||
#: path when it exists; empty falls back to the layout map.
|
#: path when it exists; empty falls back to the layout map.
|
||||||
"finger_texture_path": DEFAULT_FINGER_TEXTURE,
|
"finger_texture_path": DEFAULT_FINGER_TEXTURE,
|
||||||
@@ -216,21 +267,27 @@ def default_preferences() -> dict[str, Any]:
|
|||||||
#: x/y/z translation along the table's axes and a yaw about its z. **The z
|
#: x/y/z translation along the table's axes and a yaw about its z. **The z
|
||||||
#: is the mounting plate**, so the plate is not a setting of its own — it
|
#: is the mounting plate**, so the plate is not a setting of its own — it
|
||||||
#: lifts the sensing face and does nothing else, and two fields for one
|
#: lifts the sensing face and does nothing else, and two fields for one
|
||||||
#: number drift apart.
|
#: number drift apart. The bench plate under the finger is 8 mm thick.
|
||||||
"finger_sensor_offset_mm": [0.0, 0.0, 0.0],
|
"finger_sensor_offset_mm": [0.0, 0.0, 8.0],
|
||||||
|
# Tip points forward, away from the operator. 180° turned it back toward
|
||||||
|
# the operator; the preview then showed the green tip on the near side.
|
||||||
"finger_sensor_yaw_deg": 0.0,
|
"finger_sensor_yaw_deg": 0.0,
|
||||||
#: Where the tool is, in the table frame, mm. Straight above the table
|
#: Where the tool is, in the table frame, mm. A tool-tip offset belongs
|
||||||
#: origin unless the real machine says otherwise; every XY move is measured
|
#: here. The bench X shift is the initial position, not this number.
|
||||||
#: against it, so it is what makes the reported move match the bench. A tool
|
"finger_tool_xy_mm": [0.0, 3.0],
|
||||||
#: tip offset belongs here — it used to be a separate field, and having two
|
# How the 2D map and its dots are drawn: a grey map with yellow dots reads
|
||||||
#: of them is what let the picture and the numbers disagree.
|
# on the multi-coloured texture where the original does not.
|
||||||
"finger_tool_xy_mm": [0.0, 0.0],
|
"finger_view_background": "gray",
|
||||||
#: Stage travel, degrees. Confirmed at the bench: the vertical stage turns
|
"finger_marker_colour": "yellow",
|
||||||
#: a full half-turn either way (±180), and the horizontal stage turns
|
#: Where 「回初始位」 parks the table, mm. After the power cycle the
|
||||||
#: continuously. An earlier value of 90 was wrong and made half the patch
|
#: coordinate origin sits on the previous park, so this is the origin:
|
||||||
#: look unreachable when it is not.
|
#: one millimetre back in X and 15 mm back in Y from the old target.
|
||||||
|
"table_initial_x_mm": 0.0,
|
||||||
|
"table_initial_y_mm": 0.0,
|
||||||
|
#: Stage travel, degrees. Yaw turns ±180. Pitch is held to ±90 because the
|
||||||
|
#: fixture interferes outside that.
|
||||||
"finger_yaw_limit_deg": 180.0,
|
"finger_yaw_limit_deg": 180.0,
|
||||||
"finger_pitch_limit_deg": 360.0,
|
"finger_pitch_limit_deg": 90.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -240,7 +297,13 @@ def default_preferences() -> dict[str, Any]:
|
|||||||
#: a cosmetic problem: the move is refused with "the limit is not set" while the file
|
#: a cosmetic problem: the move is refused with "the limit is not set" while the file
|
||||||
#: in front of the operator says nothing about it. Filling the block key by key keeps
|
#: in front of the operator says nothing about it. Filling the block key by key keeps
|
||||||
#: what was chosen and supplies only what was never there.
|
#: what was chosen and supplies only what was never there.
|
||||||
_MERGED_BLOCKS = ("connection", "safety_limits", "sensor_setup", "sequence_profile")
|
_MERGED_BLOCKS = (
|
||||||
|
"connection",
|
||||||
|
"safety_limits",
|
||||||
|
"sensor_setup",
|
||||||
|
"sequence_profile",
|
||||||
|
"recording",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_preferences() -> dict[str, Any]:
|
def load_preferences() -> dict[str, Any]:
|
||||||
@@ -273,7 +336,15 @@ def load_preferences() -> dict[str, Any]:
|
|||||||
offset.append(0.0)
|
offset.append(0.0)
|
||||||
if plate and not offset[2]:
|
if plate and not offset[2]:
|
||||||
offset[2] = plate
|
offset[2] = plate
|
||||||
|
# A session started before the plate was entered saves Z as 0 and would put
|
||||||
|
# that 0 back over the 8 mm bench plate. Keep a stored non-zero thickness.
|
||||||
|
if not offset[2]:
|
||||||
|
offset[2] = float(defaults["finger_sensor_offset_mm"][2])
|
||||||
prefs["finger_sensor_offset_mm"] = offset
|
prefs["finger_sensor_offset_mm"] = offset
|
||||||
|
# The mounted shell needs no extra yaw: the tip points forward, away from
|
||||||
|
# the operator. A running session still has 180° in memory and writes it
|
||||||
|
# back on close, which would turn the tip toward the operator again.
|
||||||
|
prefs["finger_sensor_yaw_deg"] = float(defaults["finger_sensor_yaw_deg"])
|
||||||
|
|
||||||
# The tool tip offset used to be a vector added to the reported coordinates
|
# The tool tip offset used to be a vector added to the reported coordinates
|
||||||
# while the drawing ignored it, so the picture and the numbers disagreed by
|
# while the drawing ignored it, so the picture and the numbers disagreed by
|
||||||
@@ -295,6 +366,34 @@ def load_preferences() -> dict[str, Any]:
|
|||||||
if value and not tool[index]:
|
if value and not tool[index]:
|
||||||
tool[index] = value
|
tool[index] = value
|
||||||
prefs["finger_tool_xy_mm"] = tool
|
prefs["finger_tool_xy_mm"] = tool
|
||||||
|
# Closing AutoCal writes the whole in-memory file back. A session that started
|
||||||
|
# on the previous initial position would otherwise put that old Y over this one.
|
||||||
|
prefs["table_initial_x_mm"] = defaults["table_initial_x_mm"]
|
||||||
|
prefs["table_initial_y_mm"] = defaults["table_initial_y_mm"]
|
||||||
|
# 360° was the old pitch envelope. This fixture interferes outside ±90, and a
|
||||||
|
# session started on 360 would write that envelope back on close.
|
||||||
|
limits = prefs.get("safety_limits")
|
||||||
|
if isinstance(limits, dict):
|
||||||
|
try:
|
||||||
|
if float(limits.get("max_pitch_deg")) == 360.0:
|
||||||
|
limits["max_pitch_deg"] = 90.0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limits["max_pitch_deg"] = 90.0
|
||||||
|
try:
|
||||||
|
if float(prefs.get("finger_pitch_limit_deg")) == 360.0:
|
||||||
|
prefs["finger_pitch_limit_deg"] = 90.0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
prefs["finger_pitch_limit_deg"] = 90.0
|
||||||
|
# Yaw travel is ±180. The same-sign pitch solution sits near ±150° of yaw,
|
||||||
|
# and a stored ±90 drops it, leaving the opposite pitch sign on one side.
|
||||||
|
prefs["finger_yaw_limit_deg"] = float(defaults["finger_yaw_limit_deg"])
|
||||||
|
# The solver's envelope and the move guard's are one bench fact written twice: a
|
||||||
|
# measured point has two exact solutions, the second past 118° of yaw, and a file
|
||||||
|
# saved on the old ±90 would have the solver call a pose reachable while the guard
|
||||||
|
# refused the 118° the move needs. Normalise both spellings, as the pitch envelope
|
||||||
|
# above is normalised, so "cannot reach" can only mean the stage really cannot.
|
||||||
|
if isinstance(limits, dict):
|
||||||
|
limits["max_yaw_deg"] = float(defaults["safety_limits"]["max_yaw_deg"])
|
||||||
# A hand-edited task number must not reach the controller as typed.
|
# A hand-edited task number must not reach the controller as typed.
|
||||||
prefs["bas_task"] = clamp_bas_task(prefs.get("bas_task"))
|
prefs["bas_task"] = clamp_bas_task(prefs.get("bas_task"))
|
||||||
prefs = _migrate_axis_roles(prefs)
|
prefs = _migrate_axis_roles(prefs)
|
||||||
@@ -329,6 +428,11 @@ def _migrate_axis_roles(prefs: dict[str, Any]) -> dict[str, Any]:
|
|||||||
merged[role] = int(value)
|
merged[role] = int(value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
|
# The first bench note had these two drives swapped. A session that still
|
||||||
|
# has that pair would otherwise put it back on close.
|
||||||
|
if merged.get("pitch") == 2 and merged.get("yaw") == 3:
|
||||||
|
merged["pitch"] = 3
|
||||||
|
merged["yaw"] = 2
|
||||||
prefs["axis_roles"] = merged
|
prefs["axis_roles"] = merged
|
||||||
return prefs
|
return prefs
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Read trial evidence for inspection, including attempts excluded from fitting."""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def trial_diagnostics(root: Path, trial: dict) -> dict:
|
||||||
|
result = {"trial_id": trial["trial_id"], "status": trial["status"],
|
||||||
|
"reason": trial.get("reason", ""), "trace": [], "references": [], "events": [],
|
||||||
|
"frames": 0, "invalid": 0, "dropped": 0, "max_gap_s": None, "errors": [], "breaks_ns": []}
|
||||||
|
path = (root / trial.get("directory", "__missing_trial__")).resolve()
|
||||||
|
if not path.is_relative_to(root.resolve()):
|
||||||
|
raise ValueError("trial directory is outside the calibration session")
|
||||||
|
frame_path = path
|
||||||
|
try:
|
||||||
|
manifest = json.loads((root / "session.json").read_text(encoding="utf-8"))
|
||||||
|
if manifest.get("format_version") == "autocal-multipoint-1.0":
|
||||||
|
frame_path = (path / "capture").resolve()
|
||||||
|
if not frame_path.is_relative_to(root.resolve()):
|
||||||
|
raise ValueError("frame directory is outside the calibration session")
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
result["errors"].append(str(exc))
|
||||||
|
|
||||||
|
def rows(name, directory=path):
|
||||||
|
try:
|
||||||
|
with (directory / name).open(encoding="utf-8-sig", newline="") as handle:
|
||||||
|
return list(csv.DictReader(handle))
|
||||||
|
except OSError:
|
||||||
|
result["errors"].append(name)
|
||||||
|
return []
|
||||||
|
|
||||||
|
frames = rows("frames.csv", frame_path)
|
||||||
|
frame_by_id = {row["frame_id"]: row for row in frames if row.get("frame_id")}
|
||||||
|
result["frames"] = len(frames)
|
||||||
|
result["dropped"] = sum(r.get("dropped") == "1" for r in frames)
|
||||||
|
stamps = {}
|
||||||
|
try:
|
||||||
|
for row in frames:
|
||||||
|
if row.get("t_host_mono_ns") and row.get("dropped") == "0":
|
||||||
|
stamps[int(row["frame_id"])] = int(row["t_host_mono_ns"])
|
||||||
|
ordered = sorted(stamps.values())
|
||||||
|
result["max_gap_s"] = max((b - a for a, b in zip(ordered, ordered[1:])), default=0) / 1e9
|
||||||
|
for row in rows("forces.csv", frame_path):
|
||||||
|
stamp = stamps.get(int(row["frame_id"]))
|
||||||
|
raw = float(row["FN_raw"]) if row.get("FN_raw") else math.nan
|
||||||
|
if row.get("valid") != "1" or not math.isfinite(raw) or stamp is None:
|
||||||
|
result["invalid"] += 1
|
||||||
|
frame = frame_by_id.get(row["frame_id"])
|
||||||
|
if frame and frame.get("t_host_mono_ns"):
|
||||||
|
result["breaks_ns"].append(int(frame["t_host_mono_ns"]))
|
||||||
|
else:
|
||||||
|
result["trace"].append([stamp, raw])
|
||||||
|
result["trace"].sort()
|
||||||
|
result["breaks_ns"].sort()
|
||||||
|
result["references"] = rows("reference.csv")
|
||||||
|
result["events"] = rows("events.csv")
|
||||||
|
metadata = json.loads((path / "session.json").read_text(encoding="utf-8"))
|
||||||
|
result["source"] = metadata.get("input_source", {})
|
||||||
|
result["time_basis"] = metadata.get("time_basis", "")
|
||||||
|
if frame_path != path:
|
||||||
|
capture = json.loads((frame_path / "session.json").read_text(encoding="utf-8"))
|
||||||
|
result["source"] = capture.get("input_source", {})
|
||||||
|
bases = {row.get("time_basis") for row in frames if row.get("time_basis")}
|
||||||
|
result["time_basis"] = next(iter(bases)) if len(bases) == 1 else ""
|
||||||
|
if not result["source"]:
|
||||||
|
kind = {"host_camera": "camera", "host_replay": "video"}.get(result["time_basis"])
|
||||||
|
result["source"] = {"kind": kind} if kind else {}
|
||||||
|
result["reason"] = metadata.get("trial_reason") or result["reason"]
|
||||||
|
except (OSError, ValueError, KeyError, TypeError) as exc:
|
||||||
|
result["errors"].append(str(exc))
|
||||||
|
return result
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Coverage and planned-point comparison over the existing review contract."""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .calibration_session import write_json
|
||||||
|
from .session_review import load_review, save_review
|
||||||
|
|
||||||
|
|
||||||
|
def plan_points(plan: dict, definition: dict) -> list[dict]:
|
||||||
|
points = {}
|
||||||
|
origin_x, origin_y = definition["origin_raster_xy_px"]
|
||||||
|
height, width = definition["raster_size_hw"]
|
||||||
|
orders = set()
|
||||||
|
for row in plan["rows"]:
|
||||||
|
if (row["mapping_sha256"] != definition["mapping_sha256"]
|
||||||
|
or row["u_positive"] != definition["u_positive"]
|
||||||
|
or row["v_positive"] != definition["v_positive"]
|
||||||
|
or (int(row["origin_raster_x_px"]), int(row["origin_raster_y_px"])) != (origin_x, origin_y)
|
||||||
|
or (int(row["raster_height_px"]), int(row["raster_width_px"])) != (height, width)):
|
||||||
|
raise ValueError("planned points use a different UV mapping, origin or axes")
|
||||||
|
u, v, order = int(row["u_px"]), int(row["v_px"]), int(row["order"])
|
||||||
|
position = [float(row[f"{axis}_sensor_mm"]) for axis in "xyz"]
|
||||||
|
if (not 0 <= origin_x + u < width or not 0 <= origin_y - v < height
|
||||||
|
or not all(math.isfinite(x) for x in position) or order <= 0 or order in orders):
|
||||||
|
raise ValueError("invalid planned point or duplicate order")
|
||||||
|
orders.add(order)
|
||||||
|
point = points.setdefault((u, v), {"uv_xy_px": [u, v], "position_sensor_mm": position, "orders": []})
|
||||||
|
if point["position_sensor_mm"] != position:
|
||||||
|
raise ValueError("one UV point has inconsistent planned 3D coordinates")
|
||||||
|
point["orders"].append(order)
|
||||||
|
if not points:
|
||||||
|
raise ValueError("the planned-point file contains no points")
|
||||||
|
return sorted(points.values(), key=lambda p: min(p["orders"]))
|
||||||
|
|
||||||
|
|
||||||
|
def read_plan(path: Path, definition: dict) -> dict:
|
||||||
|
with path.open(encoding="utf-8-sig", newline="") as handle:
|
||||||
|
rows = list(csv.DictReader(handle))
|
||||||
|
plan = {"source": str(path), "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "rows": rows}
|
||||||
|
plan_points(plan, definition)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def overview_rows(review: dict, plan: dict | None = None) -> list[dict]:
|
||||||
|
planned = plan_points(plan, review["uv_definition"]) if plan is not None else []
|
||||||
|
planned_uv = {tuple(p["uv_xy_px"]) for p in planned}
|
||||||
|
actual = {tuple(site["uv_xy_px"]): site for site in review["sites"] if site.get("uv_xy_px")}
|
||||||
|
rows = []
|
||||||
|
for point in planned + [s for uv, s in actual.items() if uv not in planned_uv]:
|
||||||
|
site = actual.get(tuple(point["uv_xy_px"]))
|
||||||
|
if site is not None and point.get("orders") and site.get("position_sensor_mm"):
|
||||||
|
if not all(math.isclose(a, b, rel_tol=0, abs_tol=1e-6)
|
||||||
|
for a, b in zip(point["position_sensor_mm"], site["position_sensor_mm"])):
|
||||||
|
raise ValueError("planned and recorded 3D coordinates do not match")
|
||||||
|
trials = [t for t in review["trials"] if site is not None and t.get("site_id") == site["site_id"]]
|
||||||
|
complete = [t for t in trials if t["status"] == "complete"]
|
||||||
|
windows = site["windows"] if site else []
|
||||||
|
confirmed = [w for w in windows if w["stability_verified"] and w["status"] == "candidate"]
|
||||||
|
all_reviewed = bool(complete) and all(
|
||||||
|
sum(w["trial_id"] == t["trial_id"] for w in confirmed)
|
||||||
|
== len(t.get("plan", {}).get("steps", [w for w in windows if w["trial_id"] == t["trial_id"]]))
|
||||||
|
and any(w["trial_id"] == t["trial_id"] for w in confirmed) for t in complete)
|
||||||
|
state = ("uncollected" if not trials else "incomplete" if len(complete) != len(trials)
|
||||||
|
else "reviewed" if all_reviewed else "pending")
|
||||||
|
fit = (site["confirmed_fit"] or site["fit"]) if site else None
|
||||||
|
training = [w for w in windows if w["branch"] in {"zero", "load"} and w["status"] == "candidate"
|
||||||
|
and (not site["confirmed_fit"] or w["stability_verified"])] if site else []
|
||||||
|
rows.append({
|
||||||
|
"site_id": site["site_id"] if site else None, "uv_xy_px": point["uv_xy_px"],
|
||||||
|
"position_sensor_mm": site.get("position_sensor_mm") if site else point["position_sensor_mm"],
|
||||||
|
"orders": point.get("orders", []), "planned": bool(point.get("orders")),
|
||||||
|
"status": state, "trials": len(trials), "complete_trials": len(complete),
|
||||||
|
"confirmed_windows": len(confirmed), "windows": len(windows),
|
||||||
|
"confirmed_levels_n": sorted({w["reference_n"] for w in confirmed if w["branch"] in {"zero", "load"}}),
|
||||||
|
"fit": fit, "fit_basis": ("confirmed" if site["confirmed_fit"] else "candidate") if fit else "none",
|
||||||
|
"fit_trials": len({w["trial_id"] for w in training}) if fit else 0,
|
||||||
|
"fit_range_n": [min(w["reference_n"] for w in training), max(w["reference_n"] for w in training)] if fit else None,
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def save_overview(review: dict, plan: dict | None) -> Path:
|
||||||
|
rows = overview_rows(review, plan)
|
||||||
|
path = save_review(review)
|
||||||
|
write_json(path.parent / "overview.json", {
|
||||||
|
"format_version": "autocal-overview-1.0", "session_id": review["session_id"],
|
||||||
|
"plan": plan, "sites": rows,
|
||||||
|
})
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def load_overview(directory: Path, path: Path) -> tuple[dict, dict | None]:
|
||||||
|
review = load_review(directory, path)
|
||||||
|
overview_path = path.parent / "overview.json"
|
||||||
|
plan = None
|
||||||
|
if overview_path.is_file():
|
||||||
|
saved = json.loads(overview_path.read_text(encoding="utf-8"))
|
||||||
|
if saved["format_version"] != "autocal-overview-1.0" or saved["session_id"] != review["session_id"]:
|
||||||
|
raise ValueError("overview identity mismatch")
|
||||||
|
plan = saved["plan"]
|
||||||
|
overview_rows(review, plan)
|
||||||
|
return review, plan
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""What a recording captures, and how those settings reach the shared recorder.
|
||||||
|
|
||||||
|
The options are a small, explicit block rather than loose arguments for two
|
||||||
|
reasons. They are written into the session manifest once, so a later reader can
|
||||||
|
tell whether a missing video was a failure or the operator's choice. And they are
|
||||||
|
checked against this machine's encoders **before** recording starts: a codec that
|
||||||
|
cannot be opened is refused, never silently replaced by another one.
|
||||||
|
|
||||||
|
Defaults are the confirmed bench decision (plan D2): video on, ``mp4v``, lossless
|
||||||
|
frames off. ``mp4v`` measured far more bitrate than H.264, but switching the
|
||||||
|
default is a separate decision that needs the equivalence report, so this module
|
||||||
|
keeps the current default until then.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
#: Default recording settings. These constants are the single definition; the
|
||||||
|
#: preference schema re-exports them instead of spelling the values again.
|
||||||
|
DEFAULT_VIDEO = True
|
||||||
|
DEFAULT_CODEC = "mp4v"
|
||||||
|
DEFAULT_LOSSLESS_FRAMES = False
|
||||||
|
|
||||||
|
#: Codecs the startup probe writes a test clip for, in the order the UI offers them.
|
||||||
|
CODEC_CANDIDATES: tuple[str, ...] = ("mp4v", "avc1", "H264")
|
||||||
|
|
||||||
|
#: Probe clip shape: small and short, but a real encode and a real decode.
|
||||||
|
PROBE_DIRECTORY = "codec_probe"
|
||||||
|
PROBE_FRAMES = 3
|
||||||
|
PROBE_WIDTH = 64
|
||||||
|
PROBE_HEIGHT = 48
|
||||||
|
PROBE_FPS = 10.0
|
||||||
|
|
||||||
|
#: One continuous recording per trial, as the shared recorder already writes it.
|
||||||
|
TRIAL_SEGMENT_SECONDS = 24 * 3600.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RecordingOptions:
|
||||||
|
"""The recording settings one session was established with."""
|
||||||
|
|
||||||
|
video: bool = DEFAULT_VIDEO
|
||||||
|
codec: str = DEFAULT_CODEC
|
||||||
|
lossless_frames: bool = DEFAULT_LOSSLESS_FRAMES
|
||||||
|
|
||||||
|
def as_block(self) -> dict[str, object]:
|
||||||
|
"""The ``recording`` block frozen for ``session.json``."""
|
||||||
|
return {
|
||||||
|
"video": bool(self.video),
|
||||||
|
"codec": self.codec,
|
||||||
|
"lossless_frames": bool(self.lossless_frames),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_block(cls, block: object | None) -> "RecordingOptions":
|
||||||
|
"""Read a stored block; a missing or malformed value keeps its default.
|
||||||
|
|
||||||
|
A session recorded before the block existed has no answer to "was the
|
||||||
|
video expected?". Its recorder always wrote video, so the absent block
|
||||||
|
means video was expected, which is the default here.
|
||||||
|
"""
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
return cls()
|
||||||
|
video = block.get("video")
|
||||||
|
codec = block.get("codec")
|
||||||
|
lossless = block.get("lossless_frames")
|
||||||
|
return cls(
|
||||||
|
video=video if isinstance(video, bool) else DEFAULT_VIDEO,
|
||||||
|
codec=codec.strip() if isinstance(codec, str) and codec.strip() else DEFAULT_CODEC,
|
||||||
|
lossless_frames=(
|
||||||
|
lossless if isinstance(lossless, bool) else DEFAULT_LOSSLESS_FRAMES
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_record_config(options: RecordingOptions, fps: float):
|
||||||
|
"""The shared recorder's config for one trial under these options.
|
||||||
|
|
||||||
|
The import stays inside the function so this module (and the preference
|
||||||
|
schema that reads its defaults) does not pull in the recorder's NumPy and
|
||||||
|
OpenCV dependencies at start-up.
|
||||||
|
"""
|
||||||
|
from orisys_app_shared.record import RecordConfig
|
||||||
|
|
||||||
|
return RecordConfig(
|
||||||
|
fps=float(fps),
|
||||||
|
segment_seconds=TRIAL_SEGMENT_SECONDS,
|
||||||
|
preserve_all_frames=True,
|
||||||
|
video=bool(options.video),
|
||||||
|
codec=str(options.codec),
|
||||||
|
lossless_frames=bool(options.lossless_frames),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_options(prefs: Mapping[str, Any] | None) -> RecordingOptions:
|
||||||
|
"""The recording settings stored under ``prefs["recording"]``, defaults filled.
|
||||||
|
|
||||||
|
Every key falls back independently, so a block saved by an older build (one
|
||||||
|
that did not have the key yet) is not read as an absent recording choice.
|
||||||
|
"""
|
||||||
|
block = prefs.get("recording") if isinstance(prefs, Mapping) else None
|
||||||
|
return RecordingOptions.from_block(block)
|
||||||
|
|
||||||
|
|
||||||
|
def probe_codecs(scratch: Path) -> dict[str, bool]:
|
||||||
|
"""Encode a few frames with each candidate codec and report which ones work.
|
||||||
|
|
||||||
|
The probe writes real clips into ``scratch`` and reads one frame back, so a
|
||||||
|
writer that opens but produces an empty file is not called available. Probe
|
||||||
|
files are removed afterwards; the caller keeps only the result. Nothing is
|
||||||
|
substituted when a codec fails: the caller disables it and says so.
|
||||||
|
"""
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
directory = Path(scratch) / PROBE_DIRECTORY
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
frame = np.zeros((PROBE_HEIGHT, PROBE_WIDTH, 3), dtype=np.uint8)
|
||||||
|
frame[:, :, 1] = 128
|
||||||
|
availability: dict[str, bool] = {}
|
||||||
|
for codec in CODEC_CANDIDATES:
|
||||||
|
target = directory / f"{codec}.mp4"
|
||||||
|
available = False
|
||||||
|
try:
|
||||||
|
writer = cv2.VideoWriter(
|
||||||
|
str(target),
|
||||||
|
cv2.VideoWriter_fourcc(*codec),
|
||||||
|
PROBE_FPS,
|
||||||
|
(PROBE_WIDTH, PROBE_HEIGHT),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if writer.isOpened():
|
||||||
|
for _ in range(PROBE_FRAMES):
|
||||||
|
writer.write(frame)
|
||||||
|
finally:
|
||||||
|
writer.release()
|
||||||
|
available = _probe_reads_back(target)
|
||||||
|
except Exception: # noqa: BLE001 - an encoder failure is a result, not a crash
|
||||||
|
available = False
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
target.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
availability[codec] = available
|
||||||
|
try:
|
||||||
|
directory.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return availability
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_reads_back(path: Path) -> bool:
|
||||||
|
"""A written probe is only available when an encoded frame can be decoded."""
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
if not path.is_file() or path.stat().st_size <= 0:
|
||||||
|
return False
|
||||||
|
capture = cv2.VideoCapture(str(path))
|
||||||
|
try:
|
||||||
|
return bool(capture.isOpened() and capture.read()[0])
|
||||||
|
finally:
|
||||||
|
capture.release()
|
||||||
|
|
||||||
|
|
||||||
|
def codec_is_available(options: RecordingOptions, availability: Mapping[str, bool] | None) -> bool:
|
||||||
|
"""Whether these options can be recorded given a probe result.
|
||||||
|
|
||||||
|
``None`` means nothing was probed (a headless caller that is not the UI), and
|
||||||
|
the answer is left to the recorder rather than guessing.
|
||||||
|
"""
|
||||||
|
if not options.video:
|
||||||
|
return True
|
||||||
|
if availability is None:
|
||||||
|
return True
|
||||||
|
return availability.get(options.codec) is True
|
||||||
|
|
||||||
|
|
||||||
|
def require_codec_available(
|
||||||
|
options: RecordingOptions, availability: Mapping[str, bool] | None
|
||||||
|
) -> None:
|
||||||
|
"""Refuse to start with an encoder this machine cannot open.
|
||||||
|
|
||||||
|
There is deliberately no fallback to another codec: a recording made under a
|
||||||
|
silently different codec is not the recording the session block will claim.
|
||||||
|
"""
|
||||||
|
if codec_is_available(options, availability):
|
||||||
|
return
|
||||||
|
raise ValueError(
|
||||||
|
f"the selected video codec is not available on this machine: {options.codec}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Reference-force boundary in N / N m, independent of acquisition and motion.
|
||||||
|
|
||||||
|
Adapters own open/tare/calibration and convert device clocks to host monotonic
|
||||||
|
nanoseconds at receipt. A missing reading is None, never a zero-force sample.
|
||||||
|
One acquisition thread owns read/close; pass immutable samples to consumers.
|
||||||
|
Keep raw wrench samples alongside projected ones when recording an experiment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Protocol
|
||||||
|
import math
|
||||||
|
|
||||||
|
from .sensor.frames import PLATFORM_AXES, TOOL_AXIS
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NormalForceSample:
|
||||||
|
"""Calibrated normal force, positive in compression along the tool axis."""
|
||||||
|
|
||||||
|
force_n: float
|
||||||
|
t_host_mono_ns: int
|
||||||
|
source_id: str
|
||||||
|
calibration_id: str
|
||||||
|
valid: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WrenchSample:
|
||||||
|
"""Force and optional torque in the named transducer frame (not tactile raw)."""
|
||||||
|
|
||||||
|
force_n: tuple[float, float, float]
|
||||||
|
t_host_mono_ns: int
|
||||||
|
source_id: str
|
||||||
|
calibration_id: str
|
||||||
|
frame: str
|
||||||
|
torque_nm: tuple[float, float, float] | None = None
|
||||||
|
valid: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceForceSensor(Protocol):
|
||||||
|
"""A configured single-axis or FT adapter; read must be nonblocking."""
|
||||||
|
|
||||||
|
def read(self) -> NormalForceSample | WrenchSample | None: ...
|
||||||
|
|
||||||
|
def close(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
def project_wrench(
|
||||||
|
sample: WrenchSample,
|
||||||
|
*,
|
||||||
|
rotation: tuple[tuple[float, float, float], ...],
|
||||||
|
expected_frame: str,
|
||||||
|
compression_sign: int,
|
||||||
|
) -> NormalForceSample:
|
||||||
|
"""Apply transducer-to-tool rotation and explicit reaction/load polarity.
|
||||||
|
|
||||||
|
Translation does not affect force. Torque is retained in the raw sample;
|
||||||
|
transforming moments about a different origin also needs the lever arm.
|
||||||
|
A transducer attached to a rotating stage needs a pose-dependent rotation.
|
||||||
|
"""
|
||||||
|
if not expected_frame or sample.frame != expected_frame:
|
||||||
|
raise ValueError("reference force frame mismatch")
|
||||||
|
if compression_sign not in (-1, 1):
|
||||||
|
raise ValueError("compression_sign must be +1 or -1")
|
||||||
|
parsed = parse_rotation([value for row in rotation for value in row])
|
||||||
|
if len(sample.force_n) != 3:
|
||||||
|
raise ValueError("force needs 3 components in N")
|
||||||
|
force = normal_force_n(*sample.force_n, parsed) * compression_sign
|
||||||
|
valid = sample.valid
|
||||||
|
if sample.torque_nm is not None:
|
||||||
|
valid = (
|
||||||
|
valid
|
||||||
|
and len(sample.torque_nm) == 3
|
||||||
|
and all(math.isfinite(value) for value in sample.torque_nm)
|
||||||
|
)
|
||||||
|
return NormalForceSample(
|
||||||
|
force, sample.t_host_mono_ns, sample.source_id, sample.calibration_id, valid
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rotation(values: list[float]) -> tuple[tuple[float, float, float], ...]:
|
||||||
|
"""Force-sensor frame to tool frame, row major. Nothing is filled in for you."""
|
||||||
|
if len(values) != 9:
|
||||||
|
raise ValueError("the force-sensor to tool rotation needs 9 numbers, row major")
|
||||||
|
numbers = tuple(_finite(value, "rotation") for value in values)
|
||||||
|
rows = (numbers[0:3], numbers[3:6], numbers[6:9])
|
||||||
|
columns = tuple(tuple(row[index] for row in rows) for index in range(3))
|
||||||
|
|
||||||
|
def dot(left: tuple[float, ...], right: tuple[float, ...]) -> float:
|
||||||
|
return sum(a * b for a, b in zip(left, right))
|
||||||
|
|
||||||
|
for index, column in enumerate(columns):
|
||||||
|
if abs(dot(column, column) - 1.0) > 1e-6:
|
||||||
|
raise ValueError("each column of the rotation must have length 1")
|
||||||
|
for earlier in columns[:index]:
|
||||||
|
if abs(dot(column, earlier)) > 1e-6:
|
||||||
|
raise ValueError("the columns of the rotation must be perpendicular")
|
||||||
|
determinant = (
|
||||||
|
columns[0][0] * (columns[1][1] * columns[2][2] - columns[1][2] * columns[2][1])
|
||||||
|
- columns[0][1]
|
||||||
|
* (columns[1][0] * columns[2][2] - columns[1][2] * columns[2][0])
|
||||||
|
+ columns[0][2]
|
||||||
|
* (columns[1][0] * columns[2][1] - columns[1][1] * columns[2][0])
|
||||||
|
)
|
||||||
|
if abs(determinant - 1.0) > 1e-6:
|
||||||
|
raise ValueError("the transform must be a rotation with determinant +1")
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def normal_force_n(
|
||||||
|
fx: float,
|
||||||
|
fy: float,
|
||||||
|
fz: float,
|
||||||
|
rotation: tuple[tuple[float, float, float], ...],
|
||||||
|
) -> float:
|
||||||
|
"""Project a raw force onto the tool axis after the entered rotation.
|
||||||
|
|
||||||
|
``F_tool = R @ F_sensor`` and ``F_normal = F_tool · tool_axis``. ``tool_axis``
|
||||||
|
is the platform axis the pose solver aligns the surface normal to. The raw
|
||||||
|
components are not readable as the normal without that product.
|
||||||
|
"""
|
||||||
|
raw = (_finite(fx, "fx"), _finite(fy, "fy"), _finite(fz, "fz"))
|
||||||
|
transformed = tuple(sum(a * b for a, b in zip(row, raw)) for row in rotation)
|
||||||
|
return transformed[PLATFORM_AXES.index(TOOL_AXIS)]
|
||||||
|
|
||||||
|
|
||||||
|
def _finite(value: float, label: str) -> float:
|
||||||
|
number = float(value)
|
||||||
|
if not math.isfinite(number):
|
||||||
|
raise ValueError(f"{label} must be a finite number")
|
||||||
|
return number
|
||||||
@@ -200,14 +200,21 @@ def load_bundle(
|
|||||||
|
|
||||||
housing_vertices, housing_faces, part_names = _load_housing(root / "housing.npz", warnings)
|
housing_vertices, housing_faces, part_names = _load_housing(root / "housing.npz", warnings)
|
||||||
camera_k, camera_size = _load_intrinsics(root / "camera_intrinsics.npz")
|
camera_k, camera_size = _load_intrinsics(root / "camera_intrinsics.npz")
|
||||||
|
vertices_mm = vertices * mm_per_unit
|
||||||
|
housing_mm = housing_vertices * mm_per_unit
|
||||||
|
# The mesh zero sits near the tail. Move it onto the tail's mounting-face
|
||||||
|
# midpoint, which is the point aligned with the platform origin.
|
||||||
|
origin = housing_tail_origin_mm(housing_mm, vertices_mm)
|
||||||
|
vertices_mm = vertices_mm - origin
|
||||||
|
housing_mm = housing_mm - origin
|
||||||
|
|
||||||
return SensorBundle(
|
return SensorBundle(
|
||||||
path=root,
|
path=root,
|
||||||
vertices_mm=vertices * mm_per_unit,
|
vertices_mm=vertices_mm,
|
||||||
faces=faces,
|
faces=faces,
|
||||||
uv_data=uv_data,
|
uv_data=uv_data,
|
||||||
loop_to_vertex=loop_to_vertex,
|
loop_to_vertex=loop_to_vertex,
|
||||||
housing_vertices_mm=housing_vertices * mm_per_unit,
|
housing_vertices_mm=housing_mm,
|
||||||
housing_faces=housing_faces,
|
housing_faces=housing_faces,
|
||||||
housing_part_names=part_names,
|
housing_part_names=part_names,
|
||||||
camera_k=camera_k,
|
camera_k=camera_k,
|
||||||
@@ -218,6 +225,37 @@ def load_bundle(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def housing_tail_origin_mm(housing_mm: np.ndarray, sensing_mm: np.ndarray) -> np.ndarray:
|
||||||
|
"""Mounting-face center of the housing end the sensing surface does not cover.
|
||||||
|
|
||||||
|
That end is the shell tail. Its base midpoint is the sensor origin: the
|
||||||
|
point placed on the platform's rotation axis.
|
||||||
|
"""
|
||||||
|
housing_mm = np.asarray(housing_mm, dtype=np.float64)
|
||||||
|
sensing_mm = np.asarray(sensing_mm, dtype=np.float64)
|
||||||
|
if housing_mm.size == 0 or sensing_mm.size == 0:
|
||||||
|
return np.zeros(3, dtype=np.float64)
|
||||||
|
depth = housing_mm[:, 2]
|
||||||
|
low = housing_mm[depth <= float(depth.min()) + 1.0]
|
||||||
|
high = housing_mm[depth >= float(depth.max()) - 1.0]
|
||||||
|
sense_depth = sensing_mm[:, 2]
|
||||||
|
low_gap = float(sense_depth.min() - low[:, 2].mean())
|
||||||
|
high_gap = float(high[:, 2].mean() - sense_depth.max())
|
||||||
|
end = low if low_gap >= high_gap else high
|
||||||
|
base_height = float(end[:, 1].min())
|
||||||
|
base = end[end[:, 1] <= base_height + 0.3]
|
||||||
|
if base.size == 0:
|
||||||
|
base = end
|
||||||
|
return np.array(
|
||||||
|
[
|
||||||
|
0.5 * (float(base[:, 0].min()) + float(base[:, 0].max())),
|
||||||
|
float(np.median(base[:, 1])),
|
||||||
|
float(np.median(base[:, 2])),
|
||||||
|
],
|
||||||
|
dtype=np.float64,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def mm_per_unit_from_pose(world_to_cam: np.ndarray) -> float:
|
def mm_per_unit_from_pose(world_to_cam: np.ndarray) -> float:
|
||||||
"""The millimetres-per-mesh-unit factor, derived from the pose.
|
"""The millimetres-per-mesh-unit factor, derived from the pose.
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,47 @@ from __future__ import annotations
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
#: Which platform axis each sensor axis points along, and in which sign.
|
#: Which platform axis each sensor axis points along, and in which sign.
|
||||||
#: ``"z": "-y"`` keeps the frame right-handed: sensor (x, y, z) → platform
|
#: The bench (2026-10-15) showed both table commands reversed: a point further
|
||||||
#: (x, z, -y), whose determinant is +1 (verified by a test).
|
#: along the shell (sensor +Z, toward the tip) must move the table toward
|
||||||
AXIS_MAP: dict[str, str] = {"x": "x", "y": "z", "z": "-y"}
|
#: machine −Y, and a point on sensor +X must move it toward machine +X.
|
||||||
|
#: Sensor +Z is machine +Y and sensor +Y is machine +Z. Sensor +X is machine
|
||||||
|
#: −X, which is what keeps this a rotation (det +1) rather than a mirror.
|
||||||
|
#: Sensor (x, y, z) → platform (−x, z, y).
|
||||||
|
AXIS_MAP: dict[str, str] = {"x": "-x", "y": "z", "z": "y"}
|
||||||
|
|
||||||
#: Which platform axis each stage turns about. The stages are stacked on the XY
|
#: Which platform axis each stage turns about. The stages are stacked on the XY
|
||||||
#: table, so both are expressed in the *platform* frame.
|
#: table, so both are expressed in the *platform* frame.
|
||||||
|
#:
|
||||||
|
#: The positive direction is the controller's, measured 2026-10-15 from the
|
||||||
|
#: commissioned zero (face up, tip toward platform +Y):
|
||||||
|
#:
|
||||||
|
#: * yaw (axis 2) about ``z``. ``+10°`` turned the tip clockwise viewed from
|
||||||
|
#: above, which is the opposite of the right-hand sense about ``+Z``.
|
||||||
|
#: * pitch (axis 3) about ``x``, the left-right axis. It is not a roll about
|
||||||
|
#: the tip axis. An executed pose matched the preview only with this sign
|
||||||
|
#: reversed, so positive pitch is the right-hand sense about ``+X``: the dome
|
||||||
|
#: nods toward the operator (platform ``−Y``).
|
||||||
|
#:
|
||||||
|
#: ``YAW_COMMAND_SIGN`` and ``PITCH_COMMAND_SIGN`` convert a commanded angle into
|
||||||
|
#: the right-hand angle :func:`orisys_autocal.motion.kinematics.rotation_about`
|
||||||
|
#: expects. The preview, the solver and the drive all use the commanded angle.
|
||||||
YAW_AXIS = "z"
|
YAW_AXIS = "z"
|
||||||
PITCH_AXIS = "y"
|
YAW_COMMAND_SIGN = -1.0
|
||||||
|
PITCH_AXIS = "x"
|
||||||
|
PITCH_COMMAND_SIGN = 1.0
|
||||||
|
|
||||||
|
#: Model table X to the X motor. Yaw and pitch already match the drives, and Y
|
||||||
|
#: was checked (+5 mm toward the gantry). The X slide did not: cancelling the
|
||||||
|
#: yaw swing in model +X pushed the contact further off the probe. The preview
|
||||||
|
#: keeps the model move, so the drawn contact stays on the tool; only the number
|
||||||
|
#: sent to the drive, and the readout compared with the live axis, uses this sign.
|
||||||
|
TABLE_X_COMMAND_SIGN = -1.0
|
||||||
|
|
||||||
|
#: How much of the model X slide the motor should actually travel. On the two
|
||||||
|
#: side points already run (table X about 11 mm) the probe stopped about 1 mm
|
||||||
|
#: past the contact, on both sides, in the direction of that slide. 10/11 takes
|
||||||
|
#: that millimetre off. The preview still draws the full model slide.
|
||||||
|
TABLE_X_GAIN = 10.0 / 11.0
|
||||||
|
|
||||||
#: Platform axes, in the order the pose reports them.
|
#: Platform axes, in the order the pose reports them.
|
||||||
PLATFORM_AXES: tuple[str, ...] = ("x", "y", "z")
|
PLATFORM_AXES: tuple[str, ...] = ("x", "y", "z")
|
||||||
@@ -81,8 +114,22 @@ def to_sensor(vectors_mm: np.ndarray, axis_map: dict[str, str] | None = None) ->
|
|||||||
return array @ matrix
|
return array @ matrix
|
||||||
|
|
||||||
|
|
||||||
|
def machine_table_xy(table_xy_mm: tuple[float, float]) -> tuple[float, float]:
|
||||||
|
"""The table move the drives must make, in millimetres.
|
||||||
|
|
||||||
|
``table_xy_mm`` is the solver's move in the model frame. X is multiplied by
|
||||||
|
:data:`TABLE_X_COMMAND_SIGN` because the motor's positive direction is
|
||||||
|
opposite that frame, then by :data:`TABLE_X_GAIN` because that slide runs
|
||||||
|
about a millimetre past the real contact. Y is unchanged.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
TABLE_X_COMMAND_SIGN * TABLE_X_GAIN * float(table_xy_mm[0]),
|
||||||
|
float(table_xy_mm[1]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def describe(axis_map: dict[str, str] | None = None) -> str:
|
def describe(axis_map: dict[str, str] | None = None) -> str:
|
||||||
"""``sensor x→platform x, y→z, z→-y`` — shown in the UI so a flip is visible."""
|
"""``sensor x→-x, y→z, z→y`` — shown in the UI so a flip is visible."""
|
||||||
mapping = dict(axis_map or AXIS_MAP)
|
mapping = dict(axis_map or AXIS_MAP)
|
||||||
return "sensor " + ", ".join(
|
return "sensor " + ", ".join(
|
||||||
f"{axis}->{mapping.get(axis, '?')}" for axis in ("x", "y", "z")
|
f"{axis}->{mapping.get(axis, '?')}" for axis in ("x", "y", "z")
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ direction) and triangle structure rather than photorealism.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -96,6 +97,63 @@ class FlatSurface:
|
|||||||
return max(0, min(self.width - 1, x)), max(0, min(self.height - 1, y))
|
return max(0, min(self.width - 1, x)), max(0, min(self.height - 1, y))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Site:
|
||||||
|
u_px: int
|
||||||
|
v_px: int
|
||||||
|
position_sensor_mm: tuple[float, float, float]
|
||||||
|
face_index: int
|
||||||
|
raster_xy_px: tuple[int, int]
|
||||||
|
uv_origin_raster_xy_px: tuple[int, int]
|
||||||
|
raster_size_hw: tuple[int, int]
|
||||||
|
mapping_sha256: str
|
||||||
|
|
||||||
|
|
||||||
|
def uv_origin(flat) -> tuple[int, int]:
|
||||||
|
"""Lowest valid pixel in the valid column nearest the image midpoint."""
|
||||||
|
rows, columns = np.nonzero(flat.face_index >= 0)
|
||||||
|
if not len(rows):
|
||||||
|
raise ValueError("UV map has no valid sensor pixels")
|
||||||
|
middle = (flat.width - 1) / 2
|
||||||
|
column = int(columns[np.argmin(np.abs(columns - middle))])
|
||||||
|
row = int(rows[columns == column].max())
|
||||||
|
return column, row
|
||||||
|
|
||||||
|
|
||||||
|
def site_at(
|
||||||
|
flat, u_px: int, v_px: int, *, origin: tuple[int, int] | None = None,
|
||||||
|
mapping_digest: str | None = None,
|
||||||
|
) -> Site:
|
||||||
|
"""Resolve a precise raster pixel; empty mask pixels cannot become sites."""
|
||||||
|
if flat is None:
|
||||||
|
raise ValueError("UV map is not loaded")
|
||||||
|
origin_x, origin_y = origin if origin is not None else uv_origin(flat)
|
||||||
|
raster_x, raster_y = origin_x + int(u_px), origin_y - int(v_px)
|
||||||
|
sample = flat.sample(raster_x, raster_y)
|
||||||
|
if sample is None:
|
||||||
|
raise ValueError("UV pixel is outside the valid sensor surface")
|
||||||
|
point, _normal, face = sample
|
||||||
|
return Site(
|
||||||
|
int(u_px),
|
||||||
|
int(v_px),
|
||||||
|
tuple(float(v) for v in point),
|
||||||
|
int(face),
|
||||||
|
(raster_x, raster_y),
|
||||||
|
(origin_x, origin_y),
|
||||||
|
(flat.height, flat.width),
|
||||||
|
mapping_digest if mapping_digest is not None else mapping_sha256(flat),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mapping_sha256(flat) -> str:
|
||||||
|
"""Identify the actual pixel-to-mesh lookup, including its validity mask."""
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
for array in (flat.positions_mm, flat.face_index):
|
||||||
|
digest.update(str((array.shape, array.dtype.str)).encode('ascii'))
|
||||||
|
digest.update(np.ascontiguousarray(array).tobytes())
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def load_uv_texture(path: str | Path) -> np.ndarray | None:
|
def load_uv_texture(path: str | Path) -> np.ndarray | None:
|
||||||
"""Decode a UV-space texture to ``[H, W, 4]`` uint8, or ``None``.
|
"""Decode a UV-space texture to ``[H, W, 4]`` uint8, or ``None``.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Recorded UV region outlines; geometry recovery never substitutes a texture."""
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .export_bundle import load_bundle
|
||||||
|
from .uv_flat import mapping_sha256, uv_origin, rasterise
|
||||||
|
|
||||||
|
|
||||||
|
def outline_snapshot(flat) -> dict:
|
||||||
|
def contours(mask):
|
||||||
|
rings, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
return [cv2.approxPolyDP(ring, .5, True).reshape(-1, 2).tolist()
|
||||||
|
for ring in rings if len(ring) >= 3]
|
||||||
|
|
||||||
|
valid = flat.face_index >= 0
|
||||||
|
return {
|
||||||
|
"mapping_sha256": mapping_sha256(flat), "origin_raster_xy_px": list(uv_origin(flat)),
|
||||||
|
"raster_size_hw": list(flat.size_hw), "surface": contours(valid),
|
||||||
|
# External contours intentionally omit individual white marker cells.
|
||||||
|
"patch": contours(valid & (flat.image_rgb.min(axis=2) < 235)) if flat.textured else [],
|
||||||
|
"patch_available": bool(flat.textured),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def recorded_outline(review: dict) -> tuple[dict | None, str]:
|
||||||
|
definition = review["uv_definition"]
|
||||||
|
snapshot = review.get("uv_outline")
|
||||||
|
if snapshot:
|
||||||
|
if any(snapshot[key] != definition[key] for key in (
|
||||||
|
"mapping_sha256", "origin_raster_xy_px", "raster_size_hw")):
|
||||||
|
return None, "mismatch"
|
||||||
|
return snapshot, "snapshot" if snapshot["patch_available"] else "surface_only"
|
||||||
|
site = next((s for s in review["sites"] if s.get("bundle_path")), None)
|
||||||
|
if site is None:
|
||||||
|
return None, "unavailable"
|
||||||
|
try:
|
||||||
|
flat = rasterise(load_bundle(site["bundle_path"]), tuple(definition["raster_size_hw"]))
|
||||||
|
snapshot = outline_snapshot(flat)
|
||||||
|
except (OSError, ValueError, KeyError, RuntimeError):
|
||||||
|
return None, "unavailable"
|
||||||
|
if any(snapshot[key] != definition[key] for key in (
|
||||||
|
"mapping_sha256", "origin_raster_xy_px", "raster_size_hw")):
|
||||||
|
return None, "mismatch"
|
||||||
|
return snapshot, "surface_only"
|
||||||
@@ -92,6 +92,10 @@ class SequencePoint:
|
|||||||
id: int
|
id: int
|
||||||
target_mm: dict[str, float]
|
target_mm: dict[str, float]
|
||||||
sensor_mm: dict[str, float] | None = None
|
sensor_mm: dict[str, float] | None = None
|
||||||
|
#: Where the point is on the flat map (AutoCal's U/V, pixels from the map
|
||||||
|
#: origin), as ``{"u": .., "v": ..}``. Optional: the capture page takes its
|
||||||
|
#: points by U/V, so a point that has them can be sent there without a search.
|
||||||
|
uv_px: dict[str, float] | None = None
|
||||||
clearance_mm: float = 0.0
|
clearance_mm: float = 0.0
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
note: str = ""
|
note: str = ""
|
||||||
@@ -106,6 +110,8 @@ class SequencePoint:
|
|||||||
}
|
}
|
||||||
if self.sensor_mm is not None:
|
if self.sensor_mm is not None:
|
||||||
payload["sensor_mm"] = {axis: float(self.sensor_mm.get(axis, 0.0)) for axis in "xyz"}
|
payload["sensor_mm"] = {axis: float(self.sensor_mm.get(axis, 0.0)) for axis in "xyz"}
|
||||||
|
if self.uv_px is not None:
|
||||||
|
payload["uv_px"] = {axis: float(self.uv_px.get(axis, 0.0)) for axis in "uv"}
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@@ -414,6 +420,7 @@ def _parse_points(
|
|||||||
if axis not in target:
|
if axis not in target:
|
||||||
raise SequenceFormatError(f"{where}: target_mm is missing '{axis}'")
|
raise SequenceFormatError(f"{where}: target_mm is missing '{axis}'")
|
||||||
sensor = entry.get("sensor_mm")
|
sensor = entry.get("sensor_mm")
|
||||||
|
uv = entry.get("uv_px")
|
||||||
points.append(
|
points.append(
|
||||||
SequencePoint(
|
SequencePoint(
|
||||||
id=int(_number(entry.get("id"), index)),
|
id=int(_number(entry.get("id"), index)),
|
||||||
@@ -423,6 +430,11 @@ def _parse_points(
|
|||||||
if isinstance(sensor, dict)
|
if isinstance(sensor, dict)
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
uv_px=(
|
||||||
|
{axis: _number(uv.get(axis), 0.0) for axis in "uv"}
|
||||||
|
if isinstance(uv, dict) and "u" in uv and "v" in uv
|
||||||
|
else None
|
||||||
|
),
|
||||||
clearance_mm=_number(entry.get("clearance_mm"), 0.0),
|
clearance_mm=_number(entry.get("clearance_mm"), 0.0),
|
||||||
enabled=bool(entry.get("enabled", True)),
|
enabled=bool(entry.get("enabled", True)),
|
||||||
note=str(entry.get("note") or ""),
|
note=str(entry.get("note") or ""),
|
||||||
|
|||||||
@@ -0,0 +1,687 @@
|
|||||||
|
"""Read-only diagnostics of recorded hold windows, never runtime calibration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import hashlib
|
||||||
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .calibration_session import write_json
|
||||||
|
from .frames_index import read_frame_index, unique_frames
|
||||||
|
from .recording_options import RecordingOptions
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(path: Path) -> list[dict]:
|
||||||
|
with path.open(encoding="utf-8-sig", newline="") as handle:
|
||||||
|
return list(csv.DictReader(handle))
|
||||||
|
|
||||||
|
|
||||||
|
def _fit(windows: list[dict]) -> dict | None:
|
||||||
|
raw = np.array([w["raw_mean"] for w in windows], dtype=float)
|
||||||
|
loads = np.array([w["reference_n"] for w in windows], dtype=float)
|
||||||
|
if (len(set(loads)) < 2 or raw.size < 2
|
||||||
|
or np.allclose(raw, raw[0], rtol=1e-9, atol=1e-12)):
|
||||||
|
return None
|
||||||
|
gain, offset = np.polyfit(raw, loads, 1)
|
||||||
|
if not math.isfinite(gain) or not math.isfinite(offset) or abs(gain) <= 1e-12:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"gain_n_per_raw": float(gain), "offset_n": float(offset),
|
||||||
|
"sensitivity_raw_per_n": float(1 / gain),
|
||||||
|
"rmse_n": float(np.sqrt(np.mean((gain * raw + offset - loads) ** 2))),
|
||||||
|
"windows": len(windows), "status": "exploratory",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _quality(windows: list[dict]) -> dict:
|
||||||
|
"""Compare confirmed window means, never random frames from the same trial."""
|
||||||
|
confirmed = [w for w in windows if w["stability_verified"] and w["status"] == "candidate"]
|
||||||
|
trial_ids = sorted({w["trial_id"] for w in confirmed})
|
||||||
|
groups = {}
|
||||||
|
for window in confirmed:
|
||||||
|
key = (window["branch"], window["reference_n"])
|
||||||
|
groups.setdefault(key, {}).setdefault(window["trial_id"], []).append(window["raw_mean"])
|
||||||
|
repeatability = []
|
||||||
|
for (branch, load), trials in sorted(groups.items()):
|
||||||
|
means = {trial: float(np.mean(values)) for trial, values in trials.items()}
|
||||||
|
values = list(means.values())
|
||||||
|
repeatability.append({
|
||||||
|
"branch": branch, "reference_n": load, "trials": len(means),
|
||||||
|
"trial_means_raw": means,
|
||||||
|
"raw_range": float(np.ptp(values)) if len(values) >= 2 else None,
|
||||||
|
"raw_std_between_trials": float(np.std(values, ddof=1)) if len(values) >= 2 else None,
|
||||||
|
})
|
||||||
|
hysteresis, zero_return, folds = [], [], []
|
||||||
|
errors = []
|
||||||
|
for trial_id in trial_ids:
|
||||||
|
own = [w for w in confirmed if w["trial_id"] == trial_id]
|
||||||
|
# Exact declared reference forces only; no nearest-load interpolation.
|
||||||
|
branches = {}
|
||||||
|
for window in own:
|
||||||
|
branches.setdefault((window["branch"], window["reference_n"]), []).append(window["raw_mean"])
|
||||||
|
branch_means = {key: float(np.mean(values)) for key, values in branches.items()}
|
||||||
|
for (branch, load), raw in sorted(branch_means.items()):
|
||||||
|
if branch == "load" and load > 0 and ("unload", load) in branch_means:
|
||||||
|
unload = branch_means[("unload", load)]
|
||||||
|
hysteresis.append({"trial_id": trial_id, "reference_n": load,
|
||||||
|
"load_raw": raw, "unload_raw": unload,
|
||||||
|
"unload_minus_load_raw": unload - raw})
|
||||||
|
if ("zero", 0.0) in branch_means and ("unload", 0.0) in branch_means:
|
||||||
|
zero_return.append({"trial_id": trial_id,
|
||||||
|
"initial_zero_raw": branch_means[("zero", 0.0)],
|
||||||
|
"final_zero_raw": branch_means[("unload", 0.0)],
|
||||||
|
"final_minus_initial_raw": branch_means[("unload", 0.0)] - branch_means[("zero", 0.0)]})
|
||||||
|
training = [w for w in confirmed if w["trial_id"] != trial_id and w["branch"] in {"zero", "load"}]
|
||||||
|
fit = _fit(training)
|
||||||
|
fold = {"held_out_trial": trial_id,
|
||||||
|
"training_trials": sorted({w["trial_id"] for w in training}),
|
||||||
|
"fit": fit, "windows": [], "status": "insufficient_training"}
|
||||||
|
if fit is not None:
|
||||||
|
low, high = min(w["raw_mean"] for w in training), max(w["raw_mean"] for w in training)
|
||||||
|
for window in own:
|
||||||
|
predicted = fit["gain_n_per_raw"] * window["raw_mean"] + fit["offset_n"]
|
||||||
|
error = predicted - window["reference_n"]
|
||||||
|
fold["windows"].append({
|
||||||
|
"step_index": window["step_index"], "branch": window["branch"],
|
||||||
|
"reference_n": window["reference_n"], "predicted_n": predicted,
|
||||||
|
"error_n": error, "outside_training_raw_range": not low <= window["raw_mean"] <= high,
|
||||||
|
})
|
||||||
|
errors.append(error)
|
||||||
|
fold["status"] = "available"
|
||||||
|
folds.append(fold)
|
||||||
|
return {
|
||||||
|
"basis": "confirmed_window_means", "status": "exploratory",
|
||||||
|
"reference_alignment_possible": bool(confirmed) and all(w["reference_alignment_possible"] for w in confirmed),
|
||||||
|
"repeatability": repeatability, "hysteresis": hysteresis, "zero_return": zero_return,
|
||||||
|
"validation": {
|
||||||
|
"method": "leave_one_trial_out", "folds": folds,
|
||||||
|
"status": "available" if errors else "insufficient_data",
|
||||||
|
"evaluated_windows": len(errors),
|
||||||
|
"rmse_n": float(np.sqrt(np.mean(np.square(errors)))) if errors else None,
|
||||||
|
"mae_n": float(np.mean(np.abs(errors))) if errors else None,
|
||||||
|
"max_abs_error_n": float(np.max(np.abs(errors))) if errors else None,
|
||||||
|
"outside_training_raw_range": sum(w["outside_training_raw_range"] for f in folds for w in f["windows"]),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#: The multi-point campaign writes its own manifest and keeps each repeat's frame
|
||||||
|
#: source in a nested recorder session; both are reviewed through this module's one
|
||||||
|
#: window contract so the operator sees one review screen for either recording.
|
||||||
|
MULTIPOINT_FORMAT = "autocal-multipoint-1.0"
|
||||||
|
CAPTURE_DIRECTORY = "capture"
|
||||||
|
#: Live capture stamps frames against this host's monotonic clock, the same base the
|
||||||
|
#: campaign uses for its hold windows. A replayed or absent basis cannot be joined to
|
||||||
|
#: a reference window, so it is reported instead of being compared.
|
||||||
|
LIVE_TIME_BASIS = "host_camera"
|
||||||
|
#: The campaign calls the initial zero-load hold ``baseline``; the review contract names
|
||||||
|
#: that branch ``zero``, so one fitting and hysteresis rule serves both recordings.
|
||||||
|
BRANCH_ALIASES = {"baseline": "zero"}
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_source(
|
||||||
|
directory: Path, root: Path, inputs: dict, *, video_expected: bool = True
|
||||||
|
) -> dict:
|
||||||
|
"""Read the frame source recorded beside one repeat, or report why it is unusable.
|
||||||
|
|
||||||
|
Image-derived force samples only exist where the shared recorder ran: simulation and
|
||||||
|
a detached camera leave no frames, and the gap has to stay visible as a reason rather
|
||||||
|
than become an empty window that reads like a measurement.
|
||||||
|
|
||||||
|
``video_expected=False`` is a session recorded without video on purpose: the frames
|
||||||
|
were still acquired (a row carries its host time), only none was written to a video.
|
||||||
|
Frames are then accepted regardless of video membership and no video file is read.
|
||||||
|
A session with no recording block keeps the default, which requires the video.
|
||||||
|
"""
|
||||||
|
source = {
|
||||||
|
"recorded": False, "reason": "no_capture_directory", "video_expected": video_expected,
|
||||||
|
"directory": str(directory.relative_to(root)) if directory.is_relative_to(root) else str(directory),
|
||||||
|
"capture_session_id": None, "frames": 0, "in_video": 0, "time_bases": [],
|
||||||
|
"segments": [], "samples": [],
|
||||||
|
}
|
||||||
|
if not directory.is_dir():
|
||||||
|
return source
|
||||||
|
for name in ("session.json", "frames.csv", "forces.csv"):
|
||||||
|
if not (directory / name).is_file():
|
||||||
|
source["reason"] = f"missing {name}"
|
||||||
|
return source
|
||||||
|
for name in ("session.json", "frames.csv", "forces.csv"):
|
||||||
|
inputs[str((directory / name).relative_to(root))] = _sha256(directory / name)
|
||||||
|
metadata = json.loads((directory / "session.json").read_text(encoding="utf-8"))
|
||||||
|
frames: dict[int, int] = {}
|
||||||
|
bases: set[str] = set()
|
||||||
|
in_video = 0
|
||||||
|
segments: set[int] = set()
|
||||||
|
frame_rows = read_frame_index(directory)
|
||||||
|
unique, duplicated = unique_frames(frame_rows)
|
||||||
|
if duplicated:
|
||||||
|
# A repeated frame number has no single acquisition time. Its samples are
|
||||||
|
# left out instead of being paired with an order the files cannot prove.
|
||||||
|
source["duplicate_frame_ids"] = len(duplicated)
|
||||||
|
for frame in frame_rows:
|
||||||
|
if frame.dropped or frame.t_host_mono_ns is None:
|
||||||
|
continue
|
||||||
|
if frame.frame_id in duplicated:
|
||||||
|
continue
|
||||||
|
if video_expected:
|
||||||
|
if frame.in_video is False:
|
||||||
|
continue
|
||||||
|
segment = frame.segment
|
||||||
|
video = directory / f"raw_{segment:04d}.mp4"
|
||||||
|
if segment < 1 or not video.is_file() or video.stat().st_size == 0:
|
||||||
|
source["reason"] = "missing_video_segment"
|
||||||
|
return source
|
||||||
|
segments.add(segment)
|
||||||
|
in_video += 1
|
||||||
|
frames[frame.frame_id] = frame.t_host_mono_ns
|
||||||
|
bases.add(frame.time_basis)
|
||||||
|
if not frames:
|
||||||
|
source["reason"] = (
|
||||||
|
"no_recorded_video_frames" if video_expected else "no_recorded_frames"
|
||||||
|
)
|
||||||
|
return source
|
||||||
|
for segment in segments:
|
||||||
|
video = directory / f"raw_{segment:04d}.mp4"
|
||||||
|
inputs[str(video.relative_to(root))] = _sha256(video)
|
||||||
|
session_id = metadata.get("session_id")
|
||||||
|
samples = []
|
||||||
|
for row in _rows(directory / "forces.csv"):
|
||||||
|
if row.get("valid") != "1" or not row.get("FN_raw"):
|
||||||
|
continue
|
||||||
|
if session_id is not None and int(row["session_id"]) != int(session_id):
|
||||||
|
raise ValueError("frame source identity changed inside one repeat")
|
||||||
|
stamp = frames.get(int(row["frame_id"]))
|
||||||
|
raw = float(row["FN_raw"])
|
||||||
|
if stamp is not None and math.isfinite(raw):
|
||||||
|
samples.append((stamp, int(row["frame_id"]), raw))
|
||||||
|
samples.sort()
|
||||||
|
source.update({
|
||||||
|
"recorded": True, "reason": "", "capture_session_id": session_id,
|
||||||
|
"frames": len(frames), "in_video": in_video, "time_bases": sorted(bases),
|
||||||
|
"segments": sorted(segments), "samples": samples,
|
||||||
|
})
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
def _multipoint_site(record: dict, windows: list[dict]) -> dict:
|
||||||
|
"""Keep the campaign's own record and add the review contract's aliases to it."""
|
||||||
|
position = record.get("position") or {}
|
||||||
|
position_sensor_mm = None
|
||||||
|
if record.get("position_frame") == "sensor":
|
||||||
|
try:
|
||||||
|
values = [float(position[axis]) for axis in "xyz"]
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
values = []
|
||||||
|
if len(values) == 3 and all(math.isfinite(value) for value in values):
|
||||||
|
position_sensor_mm = values
|
||||||
|
uv = record.get("uv_px")
|
||||||
|
if isinstance(uv, dict):
|
||||||
|
uv = [uv["u"], uv["v"]] if "u" in uv and "v" in uv else None
|
||||||
|
training = [w for w in windows if w["branch"] in {"zero", "load"} and w["status"] == "candidate"]
|
||||||
|
confirmed = [w for w in training if w["stability_verified"]]
|
||||||
|
# The declared steps in window order are the loading and unloading curve the
|
||||||
|
# operator asked for; measured means are added where the frame source provided them.
|
||||||
|
curve = [{
|
||||||
|
"trial_id": w["trial_id"], "step_index": w["step_index"], "branch": w["branch"],
|
||||||
|
"recorded_branch": w["recorded_branch"],
|
||||||
|
"target_load_n": w["target_load_n"], "reference_n": w["reference_n"],
|
||||||
|
"samples": w["samples"], "raw_mean": w["raw_mean"], "raw_std": w["raw_std"],
|
||||||
|
} for w in windows]
|
||||||
|
return {
|
||||||
|
**record,
|
||||||
|
"uv_xy_px": [int(uv[0]), int(uv[1])] if isinstance(uv, (list, tuple)) and len(uv) == 2 else None,
|
||||||
|
"position_sensor_mm": position_sensor_mm,
|
||||||
|
"windows": windows, "curve": curve,
|
||||||
|
"fit": _fit(training), "confirmed_fit": _fit(confirmed), "quality": _quality(windows),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def review_multipoint(root: Path, *, manifest: dict, selections: dict | None = None) -> dict:
|
||||||
|
"""Review a multi-point session through the same hold-window contract.
|
||||||
|
|
||||||
|
The campaign declares every reference hold and records whether the repeat finished;
|
||||||
|
the raw normal signal is whatever the shared recorder beside it captured. Nothing is
|
||||||
|
filled in for a missing frame source, an unfinished repeat or an unverified window.
|
||||||
|
"""
|
||||||
|
selections = selections or {}
|
||||||
|
used: set[str] = set()
|
||||||
|
issues: list[dict] = []
|
||||||
|
windows: list[dict] = []
|
||||||
|
coverage: list[dict] = []
|
||||||
|
inputs = {"session.json": _sha256(root / "session.json")}
|
||||||
|
maximum = float(manifest["max_force_n"])
|
||||||
|
if not math.isfinite(maximum) or maximum <= 0:
|
||||||
|
raise ValueError("invalid session force range")
|
||||||
|
simulated = manifest.get("simulate") is True
|
||||||
|
# The recording block decides whether a missing video is a failure. A session
|
||||||
|
# recorded before the block existed keeps the default, which expects video.
|
||||||
|
recording = RecordingOptions.from_block(manifest.get("recording"))
|
||||||
|
video_expected = recording.video
|
||||||
|
for trial in manifest["trials"]:
|
||||||
|
trial_id = trial["trial_id"]
|
||||||
|
path = (root / trial["directory"]).resolve()
|
||||||
|
if not path.is_relative_to(root):
|
||||||
|
raise ValueError("trial directory is outside the multi-point session")
|
||||||
|
record = json.loads((path / "session.json").read_text(encoding="utf-8"))
|
||||||
|
for name in ("session.json", "reference.csv"):
|
||||||
|
file = path / name
|
||||||
|
if file.is_file():
|
||||||
|
inputs[str(file.relative_to(root))] = _sha256(file)
|
||||||
|
complete = trial.get("status") == record.get("status") == "complete"
|
||||||
|
if not complete:
|
||||||
|
issues.append({"trial_id": trial_id, "reason": "incomplete_trial",
|
||||||
|
"detail": str(trial.get("reason") or record.get("status") or "")})
|
||||||
|
source = _capture_source(
|
||||||
|
path / CAPTURE_DIRECTORY, root, inputs, video_expected=video_expected
|
||||||
|
)
|
||||||
|
if source.get("duplicate_frame_ids"):
|
||||||
|
issues.append({
|
||||||
|
"trial_id": trial_id, "reason": "duplicate_frame_identity",
|
||||||
|
"detail": f"{source['duplicate_frame_ids']} frame id(s) repeated",
|
||||||
|
})
|
||||||
|
samples = source["samples"]
|
||||||
|
unusable = [basis for basis in source["time_bases"] if basis != LIVE_TIME_BASIS]
|
||||||
|
if not source["recorded"]:
|
||||||
|
images = record.get("images") or {}
|
||||||
|
issues.append({"trial_id": trial_id, "reason": "no_frame_source",
|
||||||
|
"detail": str(images.get("reason") or source["reason"])})
|
||||||
|
basis_reason = "no_frame_source"
|
||||||
|
elif not source["time_bases"] or unusable:
|
||||||
|
issues.append({"trial_id": trial_id, "reason": "time_basis_not_comparable",
|
||||||
|
"detail": ", ".join(source["time_bases"]) or "time_basis absent"})
|
||||||
|
samples = []
|
||||||
|
basis_reason = "time_basis_not_comparable"
|
||||||
|
else:
|
||||||
|
basis_reason = ""
|
||||||
|
blocked = basis_reason or ("simulation" if simulated else "") or (
|
||||||
|
"incomplete_trial" if not complete else "")
|
||||||
|
if simulated and not basis_reason:
|
||||||
|
issues.append({"trial_id": trial_id, "reason": "simulation", "detail": "debug acquisition"})
|
||||||
|
reference_rows = _rows(path / "reference.csv")
|
||||||
|
curve_reference = [r for r in reference_rows if r.get("hold_complete") == "1"]
|
||||||
|
coverage.append({
|
||||||
|
"trial_id": trial_id, "site_id": trial["site_id"],
|
||||||
|
"repeat_index": trial.get("repeat_index"), "status": trial.get("status"),
|
||||||
|
"reason": trial.get("reason") or "", "windows": len(curve_reference),
|
||||||
|
"frame_source": source["directory"] if source["recorded"] else None,
|
||||||
|
"frames": source["frames"], "in_video": source["in_video"],
|
||||||
|
"time_bases": source["time_bases"], "segments": source["segments"],
|
||||||
|
"usable": bool(samples) and not blocked,
|
||||||
|
"blocked_by": blocked,
|
||||||
|
})
|
||||||
|
for reference in reference_rows:
|
||||||
|
if reference.get("hold_complete") != "1":
|
||||||
|
continue
|
||||||
|
start = int(reference["t_start_host_mono_ns"])
|
||||||
|
end = int(reference["t_end_host_mono_ns"])
|
||||||
|
load = float(reference["F_ref_normal_N"])
|
||||||
|
if end <= start or not math.isfinite(load) or not 0 <= load <= maximum:
|
||||||
|
raise ValueError(f"invalid reference window in {trial_id}")
|
||||||
|
key = f"{trial_id}:{int(reference['step_index'])}"
|
||||||
|
choice = selections.get(key)
|
||||||
|
selected_start, selected_end = start, end
|
||||||
|
verified = False
|
||||||
|
if choice is not None:
|
||||||
|
used.add(key)
|
||||||
|
selected_start = int(choice["t_start_host_mono_ns"])
|
||||||
|
selected_end = int(choice["t_end_host_mono_ns"])
|
||||||
|
verified = choice["stability_verified"] is True
|
||||||
|
if not start <= selected_start < selected_end <= end:
|
||||||
|
raise ValueError(f"selection outside recorded hold: {key}")
|
||||||
|
trace = [(stamp, frame, value) for stamp, frame, value in samples if start <= stamp < end]
|
||||||
|
raw = np.array([value for stamp, _, value in trace if selected_start <= stamp < selected_end], dtype=float)
|
||||||
|
if verified and raw.size < 2:
|
||||||
|
raise ValueError(f"not enough valid samples to confirm: {key}")
|
||||||
|
if blocked:
|
||||||
|
status = blocked
|
||||||
|
elif raw.size >= 2:
|
||||||
|
status = "candidate"
|
||||||
|
elif basis_reason:
|
||||||
|
status = basis_reason
|
||||||
|
else:
|
||||||
|
status = "insufficient_samples"
|
||||||
|
windows.append({
|
||||||
|
"trial_id": trial_id, "site_id": trial["site_id"],
|
||||||
|
"step_index": int(reference["step_index"]),
|
||||||
|
"branch": BRANCH_ALIASES.get(reference["branch"], reference["branch"]),
|
||||||
|
"recorded_branch": reference["branch"], "reference_n": load,
|
||||||
|
"target_load_n": float(reference["target_load_n"]),
|
||||||
|
# Declared references only: a hand-entered force is not an alignment.
|
||||||
|
"reference_source": reference.get("reference_source", ""),
|
||||||
|
"hold_start_host_mono_ns": start, "hold_end_host_mono_ns": end,
|
||||||
|
"t_start_host_mono_ns": selected_start, "t_end_host_mono_ns": selected_end,
|
||||||
|
"duration_s": (selected_end - selected_start) / 1e9, "samples": int(raw.size),
|
||||||
|
"trace": trace,
|
||||||
|
"raw_mean": float(raw.mean()) if raw.size else None,
|
||||||
|
"raw_std": float(raw.std()) if raw.size else None,
|
||||||
|
"raw_drift_per_s": float(np.polyfit(
|
||||||
|
np.array([(stamp - selected_start) / 1e9 for stamp, _, _ in trace
|
||||||
|
if selected_start <= stamp < selected_end]), raw, 1)[0])
|
||||||
|
if raw.size >= 2 and len({stamp for stamp, _, _ in trace
|
||||||
|
if selected_start <= stamp < selected_end}) >= 2 else None,
|
||||||
|
"stability_verified": verified,
|
||||||
|
"reference_alignment_possible": False,
|
||||||
|
"frame_source": source["directory"] if source["recorded"] else None,
|
||||||
|
"status": status,
|
||||||
|
})
|
||||||
|
if set(selections) != used:
|
||||||
|
raise ValueError("selection refers to an unavailable hold window")
|
||||||
|
sites = [_multipoint_site(record, [w for w in windows if w["site_id"] == record["site_id"]])
|
||||||
|
for record in manifest["sites"]]
|
||||||
|
completion = {entry["site_id"]: entry for entry in manifest.get("completion") or []}
|
||||||
|
for site in sites:
|
||||||
|
entry = completion.get(site["site_id"], {})
|
||||||
|
site["coverage"] = {
|
||||||
|
"repeats_planned": entry.get("repeats_planned"), "repeats_complete": entry.get("repeats_complete"),
|
||||||
|
"trials": sum(1 for t in coverage if t["site_id"] == site["site_id"]),
|
||||||
|
"usable_trials": sum(1 for t in coverage
|
||||||
|
if t["site_id"] == site["site_id"] and t["usable"]),
|
||||||
|
}
|
||||||
|
site["formal_conclusion"] = False
|
||||||
|
site["blockers"] = sorted({
|
||||||
|
*[t["blocked_by"] for t in coverage
|
||||||
|
if t["site_id"] == site["site_id"] and t["blocked_by"]],
|
||||||
|
*["incomplete_trial" for t in coverage
|
||||||
|
if t["site_id"] == site["site_id"] and t["status"] != "complete"],
|
||||||
|
*[f"unverified_window:{w['step_index']}" for w in site["windows"]
|
||||||
|
if not w["stability_verified"]],
|
||||||
|
*(["no_fit"] if site["fit"] is None else []),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"format_version": "autocal-review-1.0", "source_format": MULTIPOINT_FORMAT,
|
||||||
|
"session_id": manifest["session_id"], "source_directory": str(root),
|
||||||
|
"session_status": manifest["status"], "status": "exploratory",
|
||||||
|
"formal_conclusion": False, "sites": sites, "issues": issues,
|
||||||
|
"selections": selections, "input_sha256": inputs, "analysis_inputs": {},
|
||||||
|
"trials": manifest["trials"], "coverage": coverage,
|
||||||
|
"recording": recording.as_block(),
|
||||||
|
"uv_definition": manifest.get("uv_definition"),
|
||||||
|
"sensor_model": manifest.get("sensor_model"), "sensor_sn": manifest.get("sensor_sn"),
|
||||||
|
"started_at": manifest.get("started_at"), "uv_outline": manifest.get("uv_outline"),
|
||||||
|
"completion": manifest.get("completion") or [],
|
||||||
|
"session_reason": manifest.get("reason") or "",
|
||||||
|
"sync": manifest.get("sync"), "platform_motion": bool(manifest.get("platform_motion")),
|
||||||
|
"automatic_force_control": bool(manifest.get("automatic_force_control")),
|
||||||
|
"force_range_n": [manifest.get("min_force_n"), manifest.get("max_force_n")],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def review_session(directory: Path, *, selections: dict | None = None) -> dict:
|
||||||
|
"""Join frame identity to host timestamps and inspect only complete windows.
|
||||||
|
|
||||||
|
Manual reference values are declarations. Fits remain exploratory regardless
|
||||||
|
of sample count, and replay timestamps cannot establish physical alignment.
|
||||||
|
"""
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
manifest = json.loads((root / "session.json").read_text(encoding="utf-8"))
|
||||||
|
if manifest.get("format_version") == MULTIPOINT_FORMAT:
|
||||||
|
return review_multipoint(root, manifest=manifest, selections=selections)
|
||||||
|
if manifest.get("format_version") != "autocal-session-1.0":
|
||||||
|
raise ValueError("select a calibration session root, not an individual trial")
|
||||||
|
windows = []
|
||||||
|
issues = []
|
||||||
|
selections = selections or {}
|
||||||
|
used = set()
|
||||||
|
inputs = {"session.json": hashlib.sha256((root / "session.json").read_bytes()).hexdigest()}
|
||||||
|
analysis_inputs = {}
|
||||||
|
for trial in manifest["trials"]:
|
||||||
|
trial_id = trial["trial_id"]
|
||||||
|
if trial["status"] != "complete":
|
||||||
|
issues.append({"trial_id": trial_id, "reason": "incomplete_trial"})
|
||||||
|
continue
|
||||||
|
path = (root / trial["directory"]).resolve()
|
||||||
|
if not path.is_relative_to(root):
|
||||||
|
raise ValueError("trial directory is outside the calibration session")
|
||||||
|
metadata = json.loads((path / "session.json").read_text(encoding="utf-8"))
|
||||||
|
analysis_inputs[trial_id] = {
|
||||||
|
"config_snapshot": metadata.get("config_snapshot"),
|
||||||
|
"config_sha256": metadata.get("config_sha256"),
|
||||||
|
"uv_definition": metadata.get("uv_definition"),
|
||||||
|
}
|
||||||
|
for name in ("session.json", "frames.csv", "forces.csv", "reference.csv"):
|
||||||
|
inputs[str((path / name).relative_to(root))] = hashlib.sha256((path / name).read_bytes()).hexdigest()
|
||||||
|
if (metadata.get("trial_status") != "complete"
|
||||||
|
or metadata.get("trial_id") != trial_id
|
||||||
|
or metadata.get("site_id") != trial["site_id"]
|
||||||
|
or metadata.get("calibration_session_id") != manifest["session_id"]
|
||||||
|
or metadata.get("uv_definition") != manifest["uv_definition"]):
|
||||||
|
raise ValueError(f"trial identity or completion mismatch: {trial_id}")
|
||||||
|
frames = {}
|
||||||
|
unique, duplicated = unique_frames(read_frame_index(path))
|
||||||
|
if duplicated:
|
||||||
|
issues.append({
|
||||||
|
"trial_id": trial_id, "reason": "duplicate_frame_identity",
|
||||||
|
"detail": f"{len(duplicated)} frame id(s) repeated",
|
||||||
|
})
|
||||||
|
for frame_id, frame in unique.items():
|
||||||
|
if frame.dropped or frame.t_host_mono_ns is None:
|
||||||
|
continue
|
||||||
|
frames[frame_id] = frame.t_host_mono_ns
|
||||||
|
samples = []
|
||||||
|
for row in _rows(path / "forces.csv"):
|
||||||
|
if row["valid"] != "1" or not row["FN_raw"]:
|
||||||
|
continue
|
||||||
|
if int(row["session_id"]) != metadata["session_id"]:
|
||||||
|
raise ValueError(f"acquisition session mismatch in {trial_id}")
|
||||||
|
stamp = frames.get(int(row["frame_id"]))
|
||||||
|
raw = float(row["FN_raw"])
|
||||||
|
if stamp is not None and math.isfinite(raw):
|
||||||
|
samples.append((stamp, int(row["frame_id"]), raw))
|
||||||
|
samples.sort()
|
||||||
|
for reference in _rows(path / "reference.csv"):
|
||||||
|
if reference["hold_complete"] != "1":
|
||||||
|
continue
|
||||||
|
start = int(reference["t_start_host_mono_ns"])
|
||||||
|
end = int(reference["t_end_host_mono_ns"])
|
||||||
|
load = float(reference["F_ref_normal_N"])
|
||||||
|
if end <= start or not math.isfinite(load) or not 0 <= load <= 30:
|
||||||
|
raise ValueError(f"invalid reference window in {trial_id}")
|
||||||
|
key = f"{trial_id}:{int(reference['step_index'])}"
|
||||||
|
choice = selections.get(key)
|
||||||
|
selected_start, selected_end = start, end
|
||||||
|
verified = False
|
||||||
|
if choice is not None:
|
||||||
|
used.add(key)
|
||||||
|
selected_start = int(choice["t_start_host_mono_ns"])
|
||||||
|
selected_end = int(choice["t_end_host_mono_ns"])
|
||||||
|
verified = choice["stability_verified"] is True
|
||||||
|
if not start <= selected_start < selected_end <= end:
|
||||||
|
raise ValueError(f"selection outside recorded hold: {key}")
|
||||||
|
trace = [(stamp, frame, value) for stamp, frame, value in samples if start <= stamp < end]
|
||||||
|
raw = np.array([v for stamp, _, v in trace if selected_start <= stamp < selected_end], dtype=float)
|
||||||
|
if verified and raw.size < 2:
|
||||||
|
raise ValueError(f"not enough valid samples to confirm: {key}")
|
||||||
|
windows.append({
|
||||||
|
"trial_id": trial_id, "site_id": trial["site_id"],
|
||||||
|
"step_index": int(reference["step_index"]),
|
||||||
|
"branch": reference["branch"], "reference_n": load,
|
||||||
|
"hold_start_host_mono_ns": start, "hold_end_host_mono_ns": end,
|
||||||
|
"t_start_host_mono_ns": selected_start, "t_end_host_mono_ns": selected_end,
|
||||||
|
"duration_s": (selected_end - selected_start) / 1e9, "samples": int(raw.size),
|
||||||
|
"trace": trace,
|
||||||
|
"raw_mean": float(raw.mean()) if raw.size else None,
|
||||||
|
"raw_std": float(raw.std()) if raw.size else None,
|
||||||
|
"raw_drift_per_s": float(np.polyfit(
|
||||||
|
np.array([(stamp - selected_start) / 1e9 for stamp, _, _ in trace
|
||||||
|
if selected_start <= stamp < selected_end]), raw, 1)[0])
|
||||||
|
if raw.size >= 2 and len({stamp for stamp, _, _ in trace if selected_start <= stamp < selected_end}) >= 2 else None,
|
||||||
|
"stability_verified": verified,
|
||||||
|
"reference_alignment_possible": bool(metadata["reference_alignment_possible"]),
|
||||||
|
"frame_source": trial["directory"],
|
||||||
|
"status": "candidate" if raw.size >= 2 else "insufficient_samples",
|
||||||
|
})
|
||||||
|
if set(selections) != used:
|
||||||
|
raise ValueError("selection refers to an unavailable hold window")
|
||||||
|
sites = []
|
||||||
|
for site in manifest["sites"]:
|
||||||
|
selected = [w for w in windows if w["site_id"] == site["site_id"]]
|
||||||
|
# Loading and unloading stay separate: a shared runtime piecewise curve
|
||||||
|
# merges repeated loads and would hide the hysteresis under inspection.
|
||||||
|
training = [w for w in selected if w["branch"] in {"zero", "load"}
|
||||||
|
and w["status"] == "candidate"]
|
||||||
|
fit = _fit(training)
|
||||||
|
confirmed = [w for w in training if w["stability_verified"]]
|
||||||
|
confirmed_fit = _fit(confirmed)
|
||||||
|
sites.append({**site, "windows": selected, "fit": fit,
|
||||||
|
"confirmed_fit": confirmed_fit, "quality": _quality(selected)})
|
||||||
|
return {
|
||||||
|
"format_version": "autocal-review-1.0", "session_id": manifest["session_id"],
|
||||||
|
"source_directory": str(root), "session_status": manifest["status"],
|
||||||
|
"status": "exploratory", "sites": sites, "issues": issues,
|
||||||
|
"selections": selections, "input_sha256": inputs, "analysis_inputs": analysis_inputs,
|
||||||
|
"trials": manifest["trials"],
|
||||||
|
"recording": RecordingOptions.from_block(manifest.get("recording")).as_block(),
|
||||||
|
"uv_definition": manifest["uv_definition"],
|
||||||
|
"sensor_model": manifest.get("sensor_model"), "sensor_sn": manifest.get("sensor_sn"),
|
||||||
|
"started_at": manifest.get("started_at"), "uv_outline": manifest.get("uv_outline"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def reselect_review(result: dict, selections: dict) -> dict:
|
||||||
|
"""Adjust loaded hold traces; saving still verifies all acquired files afresh."""
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
updated = dict(result)
|
||||||
|
updated["sites"] = [{**site, "windows": [dict(window) for window in site["windows"]]}
|
||||||
|
for site in result["sites"]]
|
||||||
|
used = set()
|
||||||
|
for site in updated["sites"]:
|
||||||
|
for window in site["windows"]:
|
||||||
|
key = f"{window['trial_id']}:{window['step_index']}"
|
||||||
|
choice = selections.get(key)
|
||||||
|
start, end = window["hold_start_host_mono_ns"], window["hold_end_host_mono_ns"]
|
||||||
|
verified = False
|
||||||
|
if choice is not None:
|
||||||
|
used.add(key)
|
||||||
|
start, end = int(choice["t_start_host_mono_ns"]), int(choice["t_end_host_mono_ns"])
|
||||||
|
verified = choice["stability_verified"] is True
|
||||||
|
if not window["hold_start_host_mono_ns"] <= start < end <= window["hold_end_host_mono_ns"]:
|
||||||
|
raise ValueError(f"selection outside recorded hold: {key}")
|
||||||
|
trace = [(stamp, value) for stamp, _, value in window["trace"] if start <= stamp < end]
|
||||||
|
raw = np.array([value for _, value in trace], dtype=float)
|
||||||
|
if verified and raw.size < 2:
|
||||||
|
raise ValueError(f"not enough valid samples to confirm: {key}")
|
||||||
|
status = window["status"]
|
||||||
|
if status in {"candidate", "insufficient_samples"}:
|
||||||
|
status = "candidate" if raw.size >= 2 else "insufficient_samples"
|
||||||
|
window.update(
|
||||||
|
t_start_host_mono_ns=start, t_end_host_mono_ns=end,
|
||||||
|
duration_s=(end - start) / 1e9, samples=int(raw.size),
|
||||||
|
raw_mean=float(raw.mean()) if raw.size else None,
|
||||||
|
raw_std=float(raw.std()) if raw.size else None,
|
||||||
|
raw_drift_per_s=float(np.polyfit(
|
||||||
|
np.array([(stamp - start) / 1e9 for stamp, _ in trace]), raw, 1)[0])
|
||||||
|
if raw.size >= 2 and len({stamp for stamp, _ in trace}) >= 2 else None,
|
||||||
|
stability_verified=verified, status=status,
|
||||||
|
)
|
||||||
|
training = [w for w in site["windows"] if w["branch"] in {"zero", "load"}
|
||||||
|
and w["status"] == "candidate"]
|
||||||
|
site.update(fit=_fit(training),
|
||||||
|
confirmed_fit=_fit([w for w in training if w["stability_verified"]]),
|
||||||
|
quality=_quality(site["windows"]))
|
||||||
|
if updated.get("source_format") == MULTIPOINT_FORMAT:
|
||||||
|
site.update(_multipoint_site(site, site["windows"]))
|
||||||
|
site["blockers"] = sorted({
|
||||||
|
*[b for b in site["blockers"] if not b.startswith("unverified_window:") and b != "no_fit"],
|
||||||
|
*[f"unverified_window:{w['step_index']}" for w in site["windows"] if not w["stability_verified"]],
|
||||||
|
*(["no_fit"] if site["fit"] is None else []),
|
||||||
|
})
|
||||||
|
if used != set(selections):
|
||||||
|
raise ValueError("selection refers to an unavailable hold window")
|
||||||
|
updated["selections"] = deepcopy(selections)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def load_review(directory: Path, review_path: Path) -> dict:
|
||||||
|
"""Recompute saved selections only when the acquired inputs still match."""
|
||||||
|
saved = json.loads(review_path.read_text(encoding="utf-8"))
|
||||||
|
result = review_session(directory, selections=saved["selections"])
|
||||||
|
if saved["session_id"] != result["session_id"] or saved["input_sha256"] != result["input_sha256"]:
|
||||||
|
raise ValueError("saved review does not match the recorded inputs")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def save_review(result: dict) -> Path:
|
||||||
|
"""Save a new derived artifact; never replace acquired measurements."""
|
||||||
|
root = Path(result["source_directory"])
|
||||||
|
fresh = review_session(root, selections=result["selections"])
|
||||||
|
if fresh["input_sha256"] != result["input_sha256"]:
|
||||||
|
raise ValueError("recorded inputs changed since this review was opened")
|
||||||
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
directory = root / "derived" / f"review_{stamp}_{uuid4().hex[:8]}"
|
||||||
|
directory.mkdir(parents=True, exist_ok=False)
|
||||||
|
path = directory / "review.json"
|
||||||
|
saved = dict(fresh)
|
||||||
|
versions = {}
|
||||||
|
for package in ("orisys", "orisys-autocal", "orisys-app-shared", "numpy"):
|
||||||
|
try:
|
||||||
|
versions[package] = version(package)
|
||||||
|
except PackageNotFoundError:
|
||||||
|
versions[package] = "unavailable"
|
||||||
|
saved["analysis_versions"] = versions
|
||||||
|
saved["baselines"] = _save_baselines(fresh, directory)
|
||||||
|
write_json(path, saved)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _save_baselines(result: dict, destination: Path) -> list[dict]:
|
||||||
|
"""Snapshot a reviewed zero-load image for inspection, not tracker state."""
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
root = Path(result["source_directory"])
|
||||||
|
manifest = json.loads((root / "session.json").read_text(encoding="utf-8"))
|
||||||
|
trials = {t["trial_id"]: t for t in manifest["trials"]}
|
||||||
|
baselines = []
|
||||||
|
for site in result["sites"]:
|
||||||
|
# Each repeat has its own initial zero and its own image baseline.
|
||||||
|
for window in site["windows"]:
|
||||||
|
if window["branch"] != "zero" or not window["stability_verified"]:
|
||||||
|
continue
|
||||||
|
selected = [r for r in window["trace"] if window["t_start_host_mono_ns"] <= r[0] < window["t_end_host_mono_ns"]]
|
||||||
|
frame_id = selected[0][1]
|
||||||
|
trial_id = window["trial_id"]
|
||||||
|
# A multi-point repeat keeps its frames in the nested capture session, so the
|
||||||
|
# window, not the format, says where the video index belongs.
|
||||||
|
trial_path = (root / (window.get("frame_source") or trials[trial_id]["directory"])).resolve()
|
||||||
|
row = next(r for r in _rows(trial_path / "frames.csv") if int(r["frame_id"]) == frame_id)
|
||||||
|
item = {"site_id": site["site_id"], "trial_id": trial_id,
|
||||||
|
"frame_id": frame_id, "role": "reviewed_zero_load_inspection",
|
||||||
|
"tracker_reference": False, "status": "unavailable"}
|
||||||
|
if row.get("in_video") == "1" and row.get("segment") and row.get("segment_frame"):
|
||||||
|
media = (trial_path / f"raw_{int(row['segment']):04d}.mp4")
|
||||||
|
capture = cv2.VideoCapture(str(media))
|
||||||
|
try:
|
||||||
|
# Recorder indices are one-based; OpenCV seeks from zero.
|
||||||
|
position = int(row["segment_frame"]) - 1
|
||||||
|
capture.set(cv2.CAP_PROP_POS_FRAMES, position)
|
||||||
|
ok, image = capture.read()
|
||||||
|
ok = ok and round(capture.get(cv2.CAP_PROP_POS_FRAMES)) == position + 1
|
||||||
|
finally:
|
||||||
|
capture.release()
|
||||||
|
if ok:
|
||||||
|
name = f"baseline_{trial_id}.png"
|
||||||
|
ok, encoded = cv2.imencode(".png", image)
|
||||||
|
if not ok:
|
||||||
|
raise OSError("cannot encode baseline image")
|
||||||
|
(destination / name).write_bytes(encoded.tobytes())
|
||||||
|
item.update(status="saved", image=name,
|
||||||
|
source_video=str(media.relative_to(root)),
|
||||||
|
segment_frame=int(row["segment_frame"]),
|
||||||
|
image_sha256=hashlib.sha256(encoded.tobytes()).hexdigest())
|
||||||
|
baselines.append(item)
|
||||||
|
return baselines
|
||||||
@@ -0,0 +1,664 @@
|
|||||||
|
"""Operator summary: two traceable tables built from recorded evidence.
|
||||||
|
|
||||||
|
The summary answers the operator's question — what did each measured point do
|
||||||
|
under each declared load — without becoming a second calibration. It is built
|
||||||
|
from the review's hold windows (one definition of "an attempt") plus the raw
|
||||||
|
components in the frame source, joined by frame identity.
|
||||||
|
|
||||||
|
Two tables, both versioned:
|
||||||
|
|
||||||
|
``baseline_load_data.csv``
|
||||||
|
one row per actual hold attempt, with its identity, window method, sample
|
||||||
|
counts and the three raw component means. A rejected attempt stays visible.
|
||||||
|
|
||||||
|
``sensitivity_values.csv``
|
||||||
|
one row per point, trial, axis and review set, with the forward regression
|
||||||
|
``raw = a * reference + b``. ``a`` is not ``1 / gain`` of the review's inverse
|
||||||
|
fit; the two answer different questions and are kept apart by name.
|
||||||
|
|
||||||
|
Nothing here writes files (see :mod:`summary_export`) and nothing calls the SDK.
|
||||||
|
|
||||||
|
Method and unit rules, fixed here so a reader can recompute every number:
|
||||||
|
|
||||||
|
* window: ``hold_second_half_v1`` for an unverified candidate, the reviewer's
|
||||||
|
exact interval (``review_selection_v1``) once confirmed. The latter half is the
|
||||||
|
settling-time rule the legacy ``stable_means.csv`` already used; no auto
|
||||||
|
"stable" flag is ever written.
|
||||||
|
* samples: raw components only. A missing cell stays empty; no zero is invented
|
||||||
|
and no Newton value is mixed in. Standard deviation is the sample deviation
|
||||||
|
(``ddof=1``); fewer than two samples leave it empty.
|
||||||
|
* fit: ordinary least squares of ``raw`` on the *actual* reference N, within one
|
||||||
|
trial, over zero-load and load windows at equal weight. Unload windows are
|
||||||
|
reported (they show hysteresis) but never enter this fit. At least two
|
||||||
|
distinct reference loads are required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .frames_index import (
|
||||||
|
AXES,
|
||||||
|
joined_samples,
|
||||||
|
reference_holds,
|
||||||
|
resolve_capture_directory,
|
||||||
|
)
|
||||||
|
from .recording_options import RecordingOptions
|
||||||
|
from .session_review import review_session
|
||||||
|
|
||||||
|
SCHEMA_VERSION = "autocal-summary-1.0"
|
||||||
|
HOLD_WINDOW_METHOD = "hold_second_half_v1"
|
||||||
|
REVIEW_WINDOW_METHOD = "review_selection_v1"
|
||||||
|
LEGACY_WINDOW_METHOD = "legacy_stable_means_v1"
|
||||||
|
FIT_METHOD = "raw_on_reference_ols_v1"
|
||||||
|
UNIT = "raw"
|
||||||
|
FORMAL_CONCLUSION = False
|
||||||
|
|
||||||
|
#: Reasons a row can be listed but not used. Tokens, localised by the report.
|
||||||
|
REASON_INCOMPLETE_TRIAL = "incomplete_trial"
|
||||||
|
REASON_NO_FRAME_SOURCE = "no_frame_source"
|
||||||
|
REASON_CAPTURE_INCOMPLETE = "capture_incomplete"
|
||||||
|
REASON_NOT_RECORDED = "not_recorded"
|
||||||
|
REASON_TIME_BASIS = "time_basis_not_comparable"
|
||||||
|
REASON_SIMULATION = "simulation"
|
||||||
|
REASON_REMEASURED = "remeasured"
|
||||||
|
REASON_INCOMPLETE_HOLD = "incomplete_hold"
|
||||||
|
REASON_INSUFFICIENT_SAMPLES = "insufficient_samples"
|
||||||
|
REASON_LEGACY = "legacy_no_window"
|
||||||
|
REASON_NO_DATA = "no_data"
|
||||||
|
REASON_FEWER_LEVELS = "insufficient_reference_levels"
|
||||||
|
REASON_FLAT = "flat_signal"
|
||||||
|
REASON_NO_ZERO = "missing_zero_load"
|
||||||
|
REASON_NO_HIGH = "missing_highest_load"
|
||||||
|
|
||||||
|
#: Review window statuses that mean "the evidence itself is missing".
|
||||||
|
_BLOCKING_STATUSES = {
|
||||||
|
"no_frame_source", "time_basis_not_comparable", "simulation",
|
||||||
|
"incomplete_trial", "insufficient_samples",
|
||||||
|
}
|
||||||
|
#: Capture lifecycles that mean "there is no usable video for this repeat".
|
||||||
|
_BLOCKING_CAPTURE = {"incomplete", "missing", "not_recorded"}
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value) -> float | None:
|
||||||
|
try:
|
||||||
|
number = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return number if math.isfinite(number) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_text(text: str) -> str:
|
||||||
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _selections_hash(selections: dict) -> str:
|
||||||
|
return _sha256_text(json.dumps(selections, sort_keys=True, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
def _versions() -> dict:
|
||||||
|
found = {}
|
||||||
|
for package in ("orisys", "orisys-autocal", "orisys-app-shared", "numpy"):
|
||||||
|
try:
|
||||||
|
found[package] = version(package)
|
||||||
|
except PackageNotFoundError:
|
||||||
|
found[package] = "unavailable"
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_directory(root: Path, trial: dict, window: dict) -> Path | None:
|
||||||
|
return resolve_capture_directory(
|
||||||
|
root,
|
||||||
|
trial_directory=str(trial.get("directory") or ""),
|
||||||
|
frame_source=str(window.get("frame_source") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _trial_by_id(review: dict) -> dict[str, dict]:
|
||||||
|
return {trial["trial_id"]: trial for trial in review["trials"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_status(root: Path, trial: dict) -> tuple[str, str]:
|
||||||
|
"""``(status, reason)`` from the repeat record, defaulting to unknown.
|
||||||
|
|
||||||
|
The capture lifecycle is written into the repeat's own ``session.json`` (the
|
||||||
|
root manifest entry carries only what the campaign knew at the time), so the
|
||||||
|
repeat record is read when the entry has nothing newer. A legacy record with
|
||||||
|
no lifecycle field at all is *unknown*: ``images.captured`` says a recorder
|
||||||
|
was attached once, not that the video is usable.
|
||||||
|
"""
|
||||||
|
status = str(trial.get("capture_status") or "").strip()
|
||||||
|
if status:
|
||||||
|
return status, str(trial.get("capture_reason") or "")
|
||||||
|
record: dict = {}
|
||||||
|
path = root / str(trial.get("directory") or "") / "session.json"
|
||||||
|
try:
|
||||||
|
record = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
record = {}
|
||||||
|
status = str(record.get("capture_status") or "").strip()
|
||||||
|
if status:
|
||||||
|
return status, str(record.get("capture_reason") or "")
|
||||||
|
images = trial.get("images") or record.get("images") or {}
|
||||||
|
if images.get("captured") is True:
|
||||||
|
return "unknown", ""
|
||||||
|
return "missing", str(images.get("reason") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _axis_stats(values_by_axis: dict[str, list[float]]) -> dict:
|
||||||
|
result = {}
|
||||||
|
for axis in AXES:
|
||||||
|
values = [value for value in values_by_axis[axis] if value is not None]
|
||||||
|
result[axis] = {
|
||||||
|
"n": len(values),
|
||||||
|
"mean": float(np.mean(values)) if values else None,
|
||||||
|
"std": float(np.std(values, ddof=1)) if len(values) >= 2 else None,
|
||||||
|
"min": float(np.min(values)) if values else None,
|
||||||
|
"max": float(np.max(values)) if values else None,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _window_interval(window: dict, hold: dict | None) -> tuple[int, int, str]:
|
||||||
|
"""The interval the summary averages, and the method that chose it."""
|
||||||
|
if window.get("stability_verified"):
|
||||||
|
return (
|
||||||
|
int(window["t_start_host_mono_ns"]),
|
||||||
|
int(window["t_end_host_mono_ns"]),
|
||||||
|
REVIEW_WINDOW_METHOD,
|
||||||
|
)
|
||||||
|
start = int(window["hold_start_host_mono_ns"])
|
||||||
|
end = int(window["hold_end_host_mono_ns"])
|
||||||
|
if hold is not None:
|
||||||
|
start, end = hold["t_start_host_mono_ns"], hold["t_end_host_mono_ns"]
|
||||||
|
return start + (end - start) // 2, end, HOLD_WINDOW_METHOD
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_samples(
|
||||||
|
cache: dict[Path, object], directory: Path, *, require_video: bool
|
||||||
|
) -> list:
|
||||||
|
key = (directory, require_video)
|
||||||
|
if key not in cache:
|
||||||
|
cache[key] = joined_samples(directory, require_video=require_video)[0]
|
||||||
|
return cache[key]
|
||||||
|
|
||||||
|
|
||||||
|
def _window_samples(
|
||||||
|
cache: dict, directory: Path, *, require_video: bool, start_ns: int, end_ns: int
|
||||||
|
) -> tuple[dict[str, list[float]], int, int]:
|
||||||
|
values: dict[str, list[float]] = {axis: [] for axis in AXES}
|
||||||
|
total = 0
|
||||||
|
excluded = 0
|
||||||
|
for sample in _frame_samples(cache, directory, require_video=require_video):
|
||||||
|
if not start_ns <= sample.t_host_mono_ns < end_ns:
|
||||||
|
continue
|
||||||
|
total += 1
|
||||||
|
if not sample.valid:
|
||||||
|
excluded += 1
|
||||||
|
continue
|
||||||
|
for axis in AXES:
|
||||||
|
values[axis].append(sample.value(axis))
|
||||||
|
return values, total, excluded
|
||||||
|
|
||||||
|
|
||||||
|
def _excluded_reason(window: dict, trial_status: str, capture: tuple[str, str]) -> str:
|
||||||
|
status = str(window.get("status") or "")
|
||||||
|
if trial_status != "complete":
|
||||||
|
return REASON_INCOMPLETE_TRIAL
|
||||||
|
# The review's own status is more specific than the capture lifecycle: a
|
||||||
|
# simulated campaign reports "no frame source", not "capture incomplete".
|
||||||
|
if status in _BLOCKING_STATUSES:
|
||||||
|
return status
|
||||||
|
if capture[0] in _BLOCKING_CAPTURE:
|
||||||
|
if capture[0] == "not_recorded":
|
||||||
|
return REASON_NOT_RECORDED
|
||||||
|
if capture[0] == "missing":
|
||||||
|
return REASON_NO_FRAME_SOURCE
|
||||||
|
return REASON_CAPTURE_INCOMPLETE
|
||||||
|
if status != "candidate":
|
||||||
|
return str(status or REASON_NO_DATA)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def build_windows(root: Path, review: dict) -> list[dict]:
|
||||||
|
"""One row per actual hold attempt, including the ones that were replaced."""
|
||||||
|
root = Path(root).resolve()
|
||||||
|
trials = _trial_by_id(review)
|
||||||
|
by_trial_holds = {
|
||||||
|
trial_id: reference_holds(root / trial["directory"])
|
||||||
|
for trial_id, trial in trials.items()
|
||||||
|
}
|
||||||
|
windows_by_trial: dict[str, list[dict]] = {}
|
||||||
|
for site in review["sites"]:
|
||||||
|
for window in site["windows"]:
|
||||||
|
windows_by_trial.setdefault(window["trial_id"], []).append(window)
|
||||||
|
cache: dict = {}
|
||||||
|
rows: list[dict] = []
|
||||||
|
# A multi-point recording has to join a hold to a frame that reached the
|
||||||
|
# video. A session recorded without video (the root manifest's ``recording``
|
||||||
|
# block) has no video membership to require; every recorded frame carries
|
||||||
|
# its own host time. A session with no block is treated as video-expected.
|
||||||
|
require_video = (
|
||||||
|
bool(review.get("source_format"))
|
||||||
|
and RecordingOptions.from_block(review.get("recording")).video
|
||||||
|
)
|
||||||
|
for trial in review["trials"]:
|
||||||
|
trial_id = trial["trial_id"]
|
||||||
|
trial_status = str(trial.get("status") or "")
|
||||||
|
capture = _capture_status(root, trial)
|
||||||
|
holds = by_trial_holds.get(trial_id, [])
|
||||||
|
windows = windows_by_trial.get(trial_id, [])
|
||||||
|
matched: set[tuple[int, int]] = set()
|
||||||
|
for hold in holds:
|
||||||
|
window = next(
|
||||||
|
(
|
||||||
|
item for item in windows
|
||||||
|
if item["step_index"] == hold["step_index"]
|
||||||
|
and int(item["hold_start_host_mono_ns"]) == hold["t_start_host_mono_ns"]
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if window is not None:
|
||||||
|
matched.add((hold["step_index"], hold["t_start_host_mono_ns"]))
|
||||||
|
directory = _capture_directory(root, trial, window or {})
|
||||||
|
if window is not None and hold["complete"]:
|
||||||
|
start, end, method = _window_interval(window, hold)
|
||||||
|
if directory is None:
|
||||||
|
values = {axis: [] for axis in AXES}
|
||||||
|
total = excluded = 0
|
||||||
|
else:
|
||||||
|
values, total, excluded = _window_samples(
|
||||||
|
cache, directory, require_video=require_video,
|
||||||
|
start_ns=start, end_ns=end,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
values = {axis: [] for axis in AXES}
|
||||||
|
total = excluded = 0
|
||||||
|
start, end = hold["t_start_host_mono_ns"], hold["t_end_host_mono_ns"]
|
||||||
|
method = HOLD_WINDOW_METHOD
|
||||||
|
stats = _axis_stats(values)
|
||||||
|
window_status = str(window.get("status", "") if window is not None else "")
|
||||||
|
verified = bool(window.get("stability_verified")) if window is not None else False
|
||||||
|
if not hold["complete"]:
|
||||||
|
reason = REASON_REMEASURED if any(
|
||||||
|
other["step_index"] == hold["step_index"] and other["complete"]
|
||||||
|
for other in holds
|
||||||
|
) else REASON_INCOMPLETE_HOLD
|
||||||
|
elif window is None:
|
||||||
|
reason = REASON_INCOMPLETE_TRIAL if trial_status != "complete" else REASON_NO_DATA
|
||||||
|
elif not verified and window_status == "candidate" and stats["FN"]["n"] < 2:
|
||||||
|
reason = REASON_INSUFFICIENT_SAMPLES
|
||||||
|
else:
|
||||||
|
reason = _excluded_reason(window, trial_status, capture)
|
||||||
|
rows.append({
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"summary_id": "", # filled by build_summary
|
||||||
|
"session_id": review["session_id"],
|
||||||
|
"trial_id": trial_id,
|
||||||
|
"hold_id": hold["hold_id"],
|
||||||
|
"site_id": str(trial.get("site_id") or ""),
|
||||||
|
"point": "",
|
||||||
|
"repeat_index": trial.get("repeat_index"),
|
||||||
|
"step_index": hold["step_index"],
|
||||||
|
"branch": hold["branch"],
|
||||||
|
"source_file": f"{trial['directory']}/reference.csv",
|
||||||
|
"U": None,
|
||||||
|
"V": None,
|
||||||
|
"mapping_sha256": (review.get("uv_definition") or {}).get("mapping_sha256"),
|
||||||
|
"reference_source": hold["reference_source"],
|
||||||
|
"target_load_n": hold["target_load_n"],
|
||||||
|
"reference_load_n": hold["reference_load_n"],
|
||||||
|
"t_start_host_mono_ns": start,
|
||||||
|
"t_end_host_mono_ns": end,
|
||||||
|
"time_basis": _time_basis(window),
|
||||||
|
"window_method": method,
|
||||||
|
"stability_verified": verified,
|
||||||
|
"samples_total": total,
|
||||||
|
"samples_valid": total - excluded,
|
||||||
|
"samples_excluded": excluded,
|
||||||
|
"axis_samples": {axis: stats[axis]["n"] for axis in AXES},
|
||||||
|
"FN_mean": stats["FN"]["mean"],
|
||||||
|
"FX_mean": stats["FX"]["mean"],
|
||||||
|
"FY_mean": stats["FY"]["mean"],
|
||||||
|
"FN_std": stats["FN"]["std"],
|
||||||
|
"FX_std": stats["FX"]["std"],
|
||||||
|
"FY_std": stats["FY"]["std"],
|
||||||
|
"unit": UNIT,
|
||||||
|
"trial_status": trial_status,
|
||||||
|
"capture_status": capture[0],
|
||||||
|
"capture_reason": capture[1],
|
||||||
|
"review_status": (
|
||||||
|
"confirmed" if verified else window_status or "unavailable"
|
||||||
|
),
|
||||||
|
"eligible": not reason,
|
||||||
|
"excluded_reason": reason,
|
||||||
|
})
|
||||||
|
for window in windows:
|
||||||
|
if (int(window["step_index"]), int(window["hold_start_host_mono_ns"])) in matched:
|
||||||
|
continue
|
||||||
|
rows.append(_orphan_window_row(root, review, trial, window, cache))
|
||||||
|
# UV coordinates belong to the site record, not to the attempt.
|
||||||
|
sites = {site["site_id"]: site for site in review["sites"]}
|
||||||
|
for row in rows:
|
||||||
|
site = sites.get(row["site_id"])
|
||||||
|
uv = site.get("uv_xy_px") if site else None
|
||||||
|
if isinstance(uv, (list, tuple)) and len(uv) == 2:
|
||||||
|
row["U"], row["V"] = float(uv[0]), float(uv[1])
|
||||||
|
row["point"] = f"U {row['U']:g}, V {row['V']:g}" if row["U"] is not None else ""
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _time_basis(window: dict | None) -> str:
|
||||||
|
if not window:
|
||||||
|
return ""
|
||||||
|
bases = window.get("time_bases") or []
|
||||||
|
return bases[0] if len(bases) == 1 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _orphan_window_row(
|
||||||
|
root: Path, review: dict, trial: dict, window: dict, cache: dict
|
||||||
|
) -> dict:
|
||||||
|
"""A reviewed window whose reference row no longer matches: still listed."""
|
||||||
|
capture = _capture_status(root, trial)
|
||||||
|
return {
|
||||||
|
"schema_version": SCHEMA_VERSION, "summary_id": "",
|
||||||
|
"session_id": review["session_id"], "trial_id": trial["trial_id"],
|
||||||
|
"hold_id": -1, "site_id": str(trial.get("site_id") or ""), "point": "",
|
||||||
|
"repeat_index": trial.get("repeat_index"), "step_index": int(window["step_index"]),
|
||||||
|
"branch": str(window.get("branch") or ""), "source_file": f"{trial['directory']}/reference.csv",
|
||||||
|
"U": None, "V": None,
|
||||||
|
"mapping_sha256": (review.get("uv_definition") or {}).get("mapping_sha256"),
|
||||||
|
"reference_source": str(window.get("reference_source") or ""),
|
||||||
|
"target_load_n": window.get("target_load_n"),
|
||||||
|
"reference_load_n": window.get("reference_n"),
|
||||||
|
"t_start_host_mono_ns": int(window["t_start_host_mono_ns"]),
|
||||||
|
"t_end_host_mono_ns": int(window["t_end_host_mono_ns"]),
|
||||||
|
"time_basis": _time_basis(window), "window_method": HOLD_WINDOW_METHOD,
|
||||||
|
"stability_verified": False, "samples_total": 0, "samples_valid": 0,
|
||||||
|
"samples_excluded": 0, "axis_samples": {axis: 0 for axis in AXES},
|
||||||
|
"FN_mean": None, "FX_mean": None, "FY_mean": None,
|
||||||
|
"FN_std": None, "FX_std": None, "FY_std": None, "unit": UNIT,
|
||||||
|
"trial_status": str(trial.get("status") or ""),
|
||||||
|
"capture_status": capture[0], "capture_reason": capture[1],
|
||||||
|
"review_status": str(window.get("status") or ""),
|
||||||
|
"eligible": False, "excluded_reason": REASON_NO_DATA,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fit_axis(
|
||||||
|
points: list[tuple[float, float]],
|
||||||
|
) -> dict:
|
||||||
|
"""Forward OLS ``raw = slope * reference + intercept`` for one axis."""
|
||||||
|
finite = [(x, y) for x, y in points if math.isfinite(x) and math.isfinite(y)]
|
||||||
|
loads = sorted({x for x, _ in finite})
|
||||||
|
if not finite:
|
||||||
|
return {"status": REASON_NO_DATA}
|
||||||
|
if len(loads) < 2:
|
||||||
|
return {"status": REASON_FEWER_LEVELS}
|
||||||
|
x = np.array([value for value, _ in finite], dtype=float)
|
||||||
|
y = np.array([value for _, value in finite], dtype=float)
|
||||||
|
x_mean, y_mean = float(x.mean()), float(y.mean())
|
||||||
|
sxx = float(((x - x_mean) ** 2).sum())
|
||||||
|
if sxx <= 0:
|
||||||
|
return {"status": REASON_FEWER_LEVELS}
|
||||||
|
slope = float(((x - x_mean) * (y - y_mean)).sum() / sxx)
|
||||||
|
intercept = y_mean - slope * x_mean
|
||||||
|
predicted = slope * x + intercept
|
||||||
|
residuals = y - predicted
|
||||||
|
sse = float((residuals ** 2).sum())
|
||||||
|
sst = float(((y - y_mean) ** 2).sum())
|
||||||
|
r2 = 1.0 - sse / sst if sst > 0 else None
|
||||||
|
return {
|
||||||
|
"status": "available" if r2 is not None else REASON_FLAT,
|
||||||
|
"slope_raw_per_N": slope,
|
||||||
|
"intercept_raw": intercept,
|
||||||
|
"R2": r2,
|
||||||
|
"rmse_raw": float(np.sqrt(sse / len(y))),
|
||||||
|
"reference_min_N": min(loads),
|
||||||
|
"reference_max_N": max(loads),
|
||||||
|
"window_count": len(finite),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_means(rows: list[dict], axis: str) -> dict[float, float]:
|
||||||
|
"""Mean raw value per declared reference load, over the eligible rows."""
|
||||||
|
groups: dict[float, list[float]] = {}
|
||||||
|
for row in rows:
|
||||||
|
load = row["reference_load_n"]
|
||||||
|
value = row[f"{axis}_mean"]
|
||||||
|
if load is None or value is None or not row["eligible"]:
|
||||||
|
continue
|
||||||
|
groups.setdefault(float(load), []).append(float(value))
|
||||||
|
return {load: float(np.mean(values)) for load, values in groups.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint(rows: list[dict], axis: str) -> float | None:
|
||||||
|
values = _load_means(rows, axis)
|
||||||
|
zero = values.get(0.0)
|
||||||
|
positive = [load for load in values if load > 0]
|
||||||
|
if zero is None or not positive:
|
||||||
|
return None
|
||||||
|
high = max(positive)
|
||||||
|
return (values[high] - zero) / high
|
||||||
|
|
||||||
|
|
||||||
|
def _initial_step(rows: list[dict], axis: str) -> float | None:
|
||||||
|
values = _load_means(rows, axis)
|
||||||
|
zero = values.get(0.0)
|
||||||
|
positive = sorted(load for load in values if load > 0)
|
||||||
|
if zero is None or not positive:
|
||||||
|
return None
|
||||||
|
return values[positive[0]] - zero
|
||||||
|
|
||||||
|
|
||||||
|
def build_sensitivity(windows: list[dict]) -> list[dict]:
|
||||||
|
"""Per point, trial, axis and review set: a fit that can be recomputed."""
|
||||||
|
rows: list[dict] = []
|
||||||
|
groups: dict[tuple, list[dict]] = {}
|
||||||
|
for window in windows:
|
||||||
|
if not window["eligible"]:
|
||||||
|
continue
|
||||||
|
if window["branch"] not in {"zero", "baseline", "load"}:
|
||||||
|
continue # unloading is shown, never fitted
|
||||||
|
groups.setdefault(
|
||||||
|
(window["site_id"], window["trial_id"], bool(window["stability_verified"])),
|
||||||
|
[],
|
||||||
|
).append(window)
|
||||||
|
for (site_id, trial_id, verified), group in sorted(groups.items()):
|
||||||
|
for axis in AXES:
|
||||||
|
key = f"{axis}_mean"
|
||||||
|
points = [
|
||||||
|
(float(row["reference_load_n"]), float(row[key]))
|
||||||
|
for row in group
|
||||||
|
if row["reference_load_n"] is not None and row[key] is not None
|
||||||
|
]
|
||||||
|
fit = fit_axis(points)
|
||||||
|
rows.append({
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"summary_id": "",
|
||||||
|
"session_id": group[0]["session_id"],
|
||||||
|
"site_id": site_id,
|
||||||
|
"trial_id": trial_id,
|
||||||
|
"repeat_index": group[0]["repeat_index"],
|
||||||
|
"step_index": "",
|
||||||
|
"component": axis,
|
||||||
|
"review_status": "confirmed" if verified else "candidate",
|
||||||
|
"method": FIT_METHOD,
|
||||||
|
"slope_raw_per_N": fit.get("slope_raw_per_N"),
|
||||||
|
"intercept_raw": fit.get("intercept_raw"),
|
||||||
|
"R2": fit.get("R2"),
|
||||||
|
"rmse_raw": fit.get("rmse_raw"),
|
||||||
|
"endpoint_raw_per_N": _endpoint(group, axis),
|
||||||
|
"initial_step_raw": _initial_step(group, axis),
|
||||||
|
"reference_min_N": fit.get("reference_min_N"),
|
||||||
|
"reference_max_N": fit.get("reference_max_N"),
|
||||||
|
"window_count": fit.get("window_count", 0),
|
||||||
|
"status": fit["status"],
|
||||||
|
"reason": "" if fit["status"] == "available" else fit["status"],
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_sensitivity(rows: list[dict]) -> list[dict]:
|
||||||
|
"""Mean and sample deviation of the per-trial slopes. Not a joint fit."""
|
||||||
|
groups: dict[tuple[str, str, str], list[dict]] = {}
|
||||||
|
for row in rows:
|
||||||
|
if row["status"] != "available" or row["slope_raw_per_N"] is None:
|
||||||
|
continue
|
||||||
|
groups.setdefault((row["site_id"], row["component"], row["review_status"]), []).append(row)
|
||||||
|
result = []
|
||||||
|
for (site_id, component, review_status), group in sorted(groups.items()):
|
||||||
|
slopes = [float(row["slope_raw_per_N"]) for row in group]
|
||||||
|
result.append({
|
||||||
|
"site_id": site_id,
|
||||||
|
"component": component,
|
||||||
|
"review_status": review_status,
|
||||||
|
"trials": len({row["trial_id"] for row in group}),
|
||||||
|
"slope_mean_raw_per_N": float(np.mean(slopes)),
|
||||||
|
"slope_std_raw_per_N": float(np.std(slopes, ddof=1)) if len(slopes) >= 2 else None,
|
||||||
|
"window_count": sum(int(row["window_count"] or 0) for row in group),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_summary(
|
||||||
|
root: Path,
|
||||||
|
*,
|
||||||
|
review: dict | None = None,
|
||||||
|
selections: dict | None = None,
|
||||||
|
review_path: Path | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Build the summary object. Pure: no file is written and no input changes."""
|
||||||
|
root = Path(root).resolve()
|
||||||
|
if review is None:
|
||||||
|
review = review_session(root, selections=selections)
|
||||||
|
windows = build_windows(root, review)
|
||||||
|
sensitivity = build_sensitivity(windows)
|
||||||
|
summary_id = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}_{uuid4().hex[:8]}"
|
||||||
|
for row in [*windows, *sensitivity]:
|
||||||
|
row["summary_id"] = summary_id
|
||||||
|
trials = review["trials"]
|
||||||
|
capture = []
|
||||||
|
for trial in trials:
|
||||||
|
status, reason = _capture_status(root, trial)
|
||||||
|
capture.append({
|
||||||
|
"trial_id": trial["trial_id"],
|
||||||
|
"site_id": trial.get("site_id"),
|
||||||
|
"trial_status": str(trial.get("status") or ""),
|
||||||
|
"capture_status": status,
|
||||||
|
"capture_reason": reason,
|
||||||
|
"capture_report": trial.get("capture_report"),
|
||||||
|
})
|
||||||
|
missing = sorted({
|
||||||
|
row["excluded_reason"] for row in windows if row["excluded_reason"]
|
||||||
|
})
|
||||||
|
review_ref = ""
|
||||||
|
if review_path is not None:
|
||||||
|
try:
|
||||||
|
review_ref = str(Path(review_path).resolve().relative_to(root)).replace("\\", "/")
|
||||||
|
except ValueError:
|
||||||
|
review_ref = ""
|
||||||
|
return {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"summary_id": summary_id,
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||||
|
"software": _versions(),
|
||||||
|
"source_directory": str(root),
|
||||||
|
"session_id": review["session_id"],
|
||||||
|
"source_format": review.get("source_format") or "autocal-session-1.0",
|
||||||
|
"sensor_model": review.get("sensor_model"),
|
||||||
|
"sensor_sn": review.get("sensor_sn"),
|
||||||
|
"units": {
|
||||||
|
"raw": UNIT,
|
||||||
|
"reference": "N",
|
||||||
|
"note": "raw components are not newtons and are not mixed with them",
|
||||||
|
},
|
||||||
|
"methods": {
|
||||||
|
"window_unverified": HOLD_WINDOW_METHOD,
|
||||||
|
"window_confirmed": REVIEW_WINDOW_METHOD,
|
||||||
|
"fit": FIT_METHOD,
|
||||||
|
"endpoint": "highest_load_minus_zero_over_its_reference",
|
||||||
|
"initial_step": "lowest_positive_mean_minus_zero_mean",
|
||||||
|
},
|
||||||
|
"formal_conclusion": FORMAL_CONCLUSION,
|
||||||
|
"input_sha256": review.get("input_sha256") or {},
|
||||||
|
"review_relative_path": review_ref,
|
||||||
|
"selections_sha256": _selections_hash(review.get("selections") or {}),
|
||||||
|
"capture": capture,
|
||||||
|
"completion": review.get("completion") or [],
|
||||||
|
"issues": review.get("issues") or [],
|
||||||
|
"missing_reasons": missing,
|
||||||
|
"quality": {
|
||||||
|
str(site.get("site_id")): site.get("quality")
|
||||||
|
for site in review.get("sites") or []
|
||||||
|
},
|
||||||
|
"windows": windows,
|
||||||
|
"sensitivity": sensitivity,
|
||||||
|
"sensitivity_summary": aggregate_sensitivity(sensitivity),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_window_rows(path: Path, *, summary_id: str = "") -> list[dict]:
|
||||||
|
"""Rows of an old ``stable_means.csv``, marked as having no window evidence.
|
||||||
|
|
||||||
|
The historical table has no host times, no standard deviation and no hold
|
||||||
|
identity, and its per-axis outlier rule is not the new table's. Its numbers
|
||||||
|
are kept, labelled, and excluded from every fit rather than reinterpreted.
|
||||||
|
"""
|
||||||
|
path = Path(path)
|
||||||
|
if not path.is_file():
|
||||||
|
return []
|
||||||
|
rows = []
|
||||||
|
with path.open(encoding="utf-8-sig", newline="") as handle:
|
||||||
|
for row in csv.DictReader(handle):
|
||||||
|
rows.append({
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"summary_id": summary_id,
|
||||||
|
"session_id": "",
|
||||||
|
"trial_id": "",
|
||||||
|
"hold_id": -1,
|
||||||
|
"site_id": str(row.get("site_id") or ""),
|
||||||
|
"point": "",
|
||||||
|
"repeat_index": _number(row.get("repeat_index")),
|
||||||
|
"step_index": _number(row.get("step_index")),
|
||||||
|
"branch": str(row.get("branch") or ""),
|
||||||
|
"source_file": str(path.name),
|
||||||
|
"U": _number(row.get("U")),
|
||||||
|
"V": _number(row.get("V")),
|
||||||
|
"mapping_sha256": None,
|
||||||
|
"reference_source": str(row.get("负载类型") or ""),
|
||||||
|
"target_load_n": _number(row.get("target_load_n")),
|
||||||
|
"reference_load_n": _number(row.get("target_load_n")),
|
||||||
|
"t_start_host_mono_ns": None,
|
||||||
|
"t_end_host_mono_ns": None,
|
||||||
|
"time_basis": "",
|
||||||
|
"window_method": LEGACY_WINDOW_METHOD,
|
||||||
|
"stability_verified": False,
|
||||||
|
"samples_total": _number(row.get("samples")) or 0,
|
||||||
|
"samples_valid": _number(row.get("samples")) or 0,
|
||||||
|
"samples_excluded": 0,
|
||||||
|
"axis_samples": {
|
||||||
|
axis: int(_number(row.get(f"{axis}_mean")) is not None) for axis in AXES
|
||||||
|
},
|
||||||
|
"FN_mean": _number(row.get("FN_mean")),
|
||||||
|
"FX_mean": _number(row.get("FX_mean")),
|
||||||
|
"FY_mean": _number(row.get("FY_mean")),
|
||||||
|
"FN_std": None, "FX_std": None, "FY_std": None,
|
||||||
|
"unit": str(row.get("unit") or UNIT),
|
||||||
|
"trial_status": "legacy",
|
||||||
|
"capture_status": "unknown",
|
||||||
|
"capture_reason": "",
|
||||||
|
"review_status": "legacy",
|
||||||
|
"eligible": False,
|
||||||
|
"excluded_reason": REASON_LEGACY,
|
||||||
|
})
|
||||||
|
return rows
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
"""Publish one summary: four files, one atomic pointer, no partial result.
|
||||||
|
|
||||||
|
The export writes the whole set into a unique temporary directory, verifies what
|
||||||
|
it wrote, renames that directory into place, and only then replaces
|
||||||
|
``summary-latest.json`` through an atomic temp-file replace. A failure anywhere
|
||||||
|
before the rename leaves the previous pointer untouched; the acquisition state is
|
||||||
|
never rolled back because an export failed.
|
||||||
|
|
||||||
|
After the pointer moves, the same four files are mirrored into ``summary/`` at
|
||||||
|
the session root so the operator can open one obvious folder. The mirror is a
|
||||||
|
convenience copy: the pointer remains the only authority, and a failed mirror is
|
||||||
|
reported without moving the pointer back or changing the recording.
|
||||||
|
|
||||||
|
Nothing is published when the recorded inputs changed since the summary was
|
||||||
|
built: the numbers would no longer be reproducible from the files on disk.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import hashlib
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from .calibration_session import write_json
|
||||||
|
from .session_review import review_session
|
||||||
|
from .summary import SCHEMA_VERSION, build_summary
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
POINTER_NAME = "summary-latest.json"
|
||||||
|
POINTER_FORMAT = "autocal-summary-pointer-1.0"
|
||||||
|
EXPORT_FORMAT = "autocal-summary-export-1.0"
|
||||||
|
#: Where the readable copy of the last published summary lives, under the session root.
|
||||||
|
SUMMARY_DIR_NAME = "summary"
|
||||||
|
SUMMARY_FILES = (
|
||||||
|
"summary.json",
|
||||||
|
"summary.html",
|
||||||
|
"baseline_load_data.csv",
|
||||||
|
"sensitivity_values.csv",
|
||||||
|
)
|
||||||
|
|
||||||
|
BASELINE_HEADER = (
|
||||||
|
"schema_version", "summary_id", "session_id", "trial_id", "hold_id", "site_id",
|
||||||
|
"point", "repeat_index", "step_index", "branch", "source_file",
|
||||||
|
"U", "V", "mapping_sha256",
|
||||||
|
"reference_source", "target_load_n", "reference_load_n",
|
||||||
|
"t_start_host_mono_ns", "t_end_host_mono_ns", "time_basis",
|
||||||
|
"window_method", "stability_verified",
|
||||||
|
"samples_total", "samples_valid", "samples_excluded",
|
||||||
|
"samples_FN", "samples_FX", "samples_FY",
|
||||||
|
"FN_mean", "FX_mean", "FY_mean", "FN_std", "FX_std", "FY_std", "unit",
|
||||||
|
"trial_status", "capture_status", "review_status", "eligible", "excluded_reason",
|
||||||
|
)
|
||||||
|
|
||||||
|
SENSITIVITY_HEADER = (
|
||||||
|
"schema_version", "summary_id", "session_id", "site_id", "trial_id",
|
||||||
|
"repeat_index", "component", "review_status", "method",
|
||||||
|
"slope_raw_per_N", "intercept_raw", "R2", "rmse_raw",
|
||||||
|
"endpoint_raw_per_N", "initial_step_raw",
|
||||||
|
"reference_min_N", "reference_max_N", "window_count", "status", "reason",
|
||||||
|
)
|
||||||
|
|
||||||
|
#: One publish per session root at a time.
|
||||||
|
_LOCKS: dict[str, threading.Lock] = {}
|
||||||
|
_LOCKS_GUARD = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryExportError(RuntimeError):
|
||||||
|
"""The summary was not published; the previous pointer still stands."""
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_for(root: Path) -> threading.Lock:
|
||||||
|
key = str(Path(root).resolve())
|
||||||
|
with _LOCKS_GUARD:
|
||||||
|
return _LOCKS.setdefault(key, threading.Lock())
|
||||||
|
|
||||||
|
|
||||||
|
def _cell(value) -> str:
|
||||||
|
"""Full-precision cell. A missing value is empty, never zero."""
|
||||||
|
if value is None or value == "":
|
||||||
|
return ""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "1" if value else "0"
|
||||||
|
if isinstance(value, float):
|
||||||
|
return repr(value)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_cells(row: dict, header: tuple[str, ...]) -> list[str]:
|
||||||
|
cells = []
|
||||||
|
for name in header:
|
||||||
|
if name.startswith("samples_") and name[8:] in {"FN", "FX", "FY"}:
|
||||||
|
cells.append(_cell((row.get("axis_samples") or {}).get(name[8:])))
|
||||||
|
else:
|
||||||
|
cells.append(_cell(row.get(name)))
|
||||||
|
return cells
|
||||||
|
|
||||||
|
|
||||||
|
def _write_csv(path: Path, header: tuple[str, ...], rows: list[dict]) -> None:
|
||||||
|
with path.open("w", newline="", encoding="utf-8-sig") as handle:
|
||||||
|
writer = csv.writer(handle)
|
||||||
|
writer.writerow(header)
|
||||||
|
for row in rows:
|
||||||
|
writer.writerow(_row_to_cells(row, header))
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_file(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _quality_section(summary: dict) -> str:
|
||||||
|
rows = []
|
||||||
|
for site_id, quality in sorted((summary.get("quality") or {}).items()):
|
||||||
|
if not quality:
|
||||||
|
rows.append(
|
||||||
|
"<tr><td>%s</td><td colspan=\"4\">%s</td></tr>"
|
||||||
|
% (html.escape(site_id), html.escape("not available"))
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
validation = quality.get("validation") or {}
|
||||||
|
rows.append(
|
||||||
|
"<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>" % (
|
||||||
|
html.escape(site_id),
|
||||||
|
html.escape(str(len(quality.get("repeatability") or []))),
|
||||||
|
html.escape(str(len(quality.get("hysteresis") or []))),
|
||||||
|
html.escape(str(validation.get("evaluated_windows", 0))),
|
||||||
|
html.escape(str(validation.get("rmse_n") if validation.get("rmse_n") is not None else "—")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"<h2>Repeatability, hysteresis and held-out validation (existing review)</h2>"
|
||||||
|
"<table><thead><tr><th>Point</th><th>Repeat groups</th><th>Hysteresis rows</th>"
|
||||||
|
"<th>Held-out windows</th><th>Held-out RMSE (N)</th></tr></thead><tbody>%s</tbody></table>"
|
||||||
|
% "".join(rows)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sensitivity_table(summary: dict) -> str:
|
||||||
|
rows = []
|
||||||
|
for row in summary.get("sensitivity") or []:
|
||||||
|
cells = [
|
||||||
|
html.escape(str(row.get("site_id") or "")),
|
||||||
|
html.escape(str(row.get("component") or "")),
|
||||||
|
html.escape(str(row.get("review_status") or "")),
|
||||||
|
html.escape(_cell(row.get("slope_raw_per_N"))),
|
||||||
|
html.escape(_cell(row.get("intercept_raw"))),
|
||||||
|
html.escape(_cell(row.get("R2"))),
|
||||||
|
html.escape(_cell(row.get("endpoint_raw_per_N"))),
|
||||||
|
html.escape(_cell(row.get("initial_step_raw"))),
|
||||||
|
html.escape(str(row.get("window_count") or 0)),
|
||||||
|
html.escape(str(row.get("status") or "")),
|
||||||
|
]
|
||||||
|
rows.append("<tr>%s</tr>" % "".join(f"<td>{cell}</td>" for cell in cells))
|
||||||
|
return (
|
||||||
|
"<h2>Sensitivity per point, trial and component (raw per N)</h2>"
|
||||||
|
"<p>Method <code>%s</code>: ordinary least squares of the raw window mean on the "
|
||||||
|
"actual reference load. Unloading is not fitted. This is not a joint three-axis "
|
||||||
|
"fit and not a physical accuracy claim.</p>"
|
||||||
|
"<table><thead><tr><th>Point</th><th>Component</th><th>Review set</th><th>Slope</th>"
|
||||||
|
"<th>Intercept</th><th>R2</th><th>Endpoint</th><th>Initial step</th>"
|
||||||
|
"<th>Windows</th><th>Status</th></tr></thead><tbody>%s</tbody></table>"
|
||||||
|
% (html.escape(str((summary.get("methods") or {}).get("fit", ""))), "".join(rows))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _window_table(summary: dict) -> str:
|
||||||
|
rows = []
|
||||||
|
for row in summary.get("windows") or []:
|
||||||
|
cells = [
|
||||||
|
html.escape(str(row.get("site_id") or "")),
|
||||||
|
html.escape(str(row.get("trial_id") or "")),
|
||||||
|
html.escape(str(row.get("hold_id") or "")),
|
||||||
|
html.escape(str(row.get("branch") or "")),
|
||||||
|
html.escape(_cell(row.get("reference_load_n"))),
|
||||||
|
html.escape(_cell(row.get("FN_mean"))),
|
||||||
|
html.escape(_cell(row.get("FX_mean"))),
|
||||||
|
html.escape(_cell(row.get("FY_mean"))),
|
||||||
|
html.escape(str(row.get("samples_valid") or 0)),
|
||||||
|
html.escape(str(row.get("window_method") or "")),
|
||||||
|
html.escape("yes" if row.get("eligible") else "no"),
|
||||||
|
html.escape(str(row.get("excluded_reason") or "")),
|
||||||
|
]
|
||||||
|
rows.append("<tr>%s</tr>" % "".join(f"<td>{cell}</td>" for cell in cells))
|
||||||
|
return (
|
||||||
|
"<h2>Every hold attempt (raw components)</h2>"
|
||||||
|
"<p>An attempt is listed even when it is excluded, so nothing disappears quietly. "
|
||||||
|
"A repeated attempt is excluded with <code>remeasured</code>.</p>"
|
||||||
|
"<table><thead><tr><th>Point</th><th>Trial</th><th>Hold</th><th>Branch</th>"
|
||||||
|
"<th>Reference N</th><th>FN</th><th>FX</th><th>FY</th><th>Samples</th>"
|
||||||
|
"<th>Window</th><th>Eligible</th><th>Excluded reason</th></tr></thead>"
|
||||||
|
"<tbody>%s</tbody></table>" % "".join(rows)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _evidence_links(summary: dict) -> str:
|
||||||
|
links = []
|
||||||
|
for trial in summary.get("capture") or []:
|
||||||
|
trial_id = str(trial.get("trial_id") or "")
|
||||||
|
report = trial.get("capture_report") or {}
|
||||||
|
directory = str(report.get("directory") or "")
|
||||||
|
if directory:
|
||||||
|
links.append(
|
||||||
|
"<li>%s: %s — <code>%s</code></li>"
|
||||||
|
% (
|
||||||
|
html.escape(trial_id),
|
||||||
|
html.escape(str(trial.get("capture_status") or "")),
|
||||||
|
html.escape(directory),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
links.append(
|
||||||
|
"<li>%s: %s</li>"
|
||||||
|
% (html.escape(trial_id), html.escape(str(trial.get("capture_status") or "")))
|
||||||
|
)
|
||||||
|
return "<h2>Evidence</h2><ul>%s</ul>" % "".join(links)
|
||||||
|
|
||||||
|
|
||||||
|
def render_html(summary: dict) -> str:
|
||||||
|
"""One self-contained page; it loads nothing and escapes every value."""
|
||||||
|
completion = summary.get("completion") or []
|
||||||
|
done = sum(1 for row in completion if row.get("complete"))
|
||||||
|
capture = summary.get("capture") or []
|
||||||
|
incomplete_capture = [row["trial_id"] for row in capture
|
||||||
|
if row.get("capture_status") in {"incomplete", "missing"}]
|
||||||
|
pending = sum(1 for row in summary.get("windows") or []
|
||||||
|
if row.get("review_status") == "candidate" and row.get("eligible"))
|
||||||
|
missing = ", ".join(summary.get("missing_reasons") or []) or "none"
|
||||||
|
body = [
|
||||||
|
"<!doctype html><html lang=\"zh\"><head><meta charset=\"utf-8\">",
|
||||||
|
"<title>Autocal summary %s</title>" % html.escape(str(summary.get("summary_id") or "")),
|
||||||
|
"<style>body{font-family:sans-serif;margin:24px;color:#1b1b1b}"
|
||||||
|
"table{border-collapse:collapse;margin:8px 0 20px}"
|
||||||
|
"th,td{border:1px solid #c9c9c9;padding:3px 8px;font-size:13px;text-align:left}"
|
||||||
|
"th{background:#eee}code{background:#f4f4f4;padding:0 3px}"
|
||||||
|
".warn{color:#a33}</style></head><body>",
|
||||||
|
"<h1>Autocal recording summary</h1>",
|
||||||
|
"<p><b>Model</b> %s · <b>SN</b> %s · <b>Session</b> %s</p>" % (
|
||||||
|
html.escape(str(summary.get("sensor_model") or "—")),
|
||||||
|
html.escape(str(summary.get("sensor_sn") or "—")),
|
||||||
|
html.escape(str(summary.get("session_id") or "—")),
|
||||||
|
),
|
||||||
|
"<p><b>Points complete</b> %d / %d · <b>Capture incomplete</b> %d · "
|
||||||
|
"<b>Windows awaiting review</b> %d · <b>Missing data</b> %s</p>" % (
|
||||||
|
done, len(completion), len(incomplete_capture), pending, html.escape(missing),
|
||||||
|
),
|
||||||
|
"<p class=\"warn\">This page states measurements and their gaps. It is not a "
|
||||||
|
"pass/fail certificate, and it does not validate physical accuracy.</p>",
|
||||||
|
_sensitivity_table(summary),
|
||||||
|
_window_table(summary),
|
||||||
|
_quality_section(summary),
|
||||||
|
_evidence_links(summary),
|
||||||
|
"<p>Schema %s · generated %s</p>" % (
|
||||||
|
html.escape(str(summary.get("schema_version") or SCHEMA_VERSION)),
|
||||||
|
html.escape(str(summary.get("generated_at") or "")),
|
||||||
|
),
|
||||||
|
"</body></html>",
|
||||||
|
]
|
||||||
|
return "\n".join(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_export(directory: Path, summary: dict) -> dict[str, str]:
|
||||||
|
directory.mkdir(parents=True, exist_ok=False)
|
||||||
|
(directory / "summary.json").write_text(
|
||||||
|
json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(directory / "summary.html").write_text(render_html(summary), encoding="utf-8")
|
||||||
|
_write_csv(directory / "baseline_load_data.csv", BASELINE_HEADER, summary.get("windows") or [])
|
||||||
|
_write_csv(directory / "sensitivity_values.csv", SENSITIVITY_HEADER, summary.get("sensitivity") or [])
|
||||||
|
hashes = {}
|
||||||
|
for name in SUMMARY_FILES:
|
||||||
|
path = directory / name
|
||||||
|
if not path.is_file():
|
||||||
|
raise SummaryExportError(f"the export is missing {name}")
|
||||||
|
hashes[name] = _sha256_file(path)
|
||||||
|
# Read the JSON back: a summary that cannot be parsed is not a published result.
|
||||||
|
json.loads((directory / "summary.json").read_text(encoding="utf-8"))
|
||||||
|
return hashes
|
||||||
|
|
||||||
|
|
||||||
|
def _pointer_for(root: Path, summary: dict, directory: Path, hashes: dict[str, str]) -> dict:
|
||||||
|
return {
|
||||||
|
"format_version": POINTER_FORMAT,
|
||||||
|
"export_format": EXPORT_FORMAT,
|
||||||
|
"summary_id": summary["summary_id"],
|
||||||
|
"generated_at": summary.get("generated_at"),
|
||||||
|
"path": str(directory.relative_to(root)).replace("\\", "/"),
|
||||||
|
"input_sha256": summary.get("input_sha256") or {},
|
||||||
|
"selections_sha256": summary.get("selections_sha256"),
|
||||||
|
"files": hashes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_pointer(root: Path) -> dict:
|
||||||
|
path = root / POINTER_NAME
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
pointer = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
return pointer if isinstance(pointer, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _recorded_summary_id(summary_json: Path) -> str:
|
||||||
|
"""The ``summary_id`` inside an exported summary, or ``""`` when unreadable."""
|
||||||
|
try:
|
||||||
|
payload = json.loads(summary_json.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return ""
|
||||||
|
return str(payload.get("summary_id") or "") if isinstance(payload, dict) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def published_summary_directory(root: Path) -> Path | None:
|
||||||
|
"""The folder an operator should open for the last published summary.
|
||||||
|
|
||||||
|
The readable ``summary/`` mirror, but only while it is the version the pointer
|
||||||
|
names: a failed mirror leaves the previous copy in place, and opening that
|
||||||
|
would show numbers the pointer no longer claims. Otherwise the directory the
|
||||||
|
pointer itself names, which is the authority. ``None`` means nothing has been
|
||||||
|
published yet; a caller should disable the action rather than invent a path.
|
||||||
|
"""
|
||||||
|
root = Path(root).resolve()
|
||||||
|
pointer = _read_pointer(root)
|
||||||
|
pointer_id = str(pointer.get("summary_id") or "") if pointer else ""
|
||||||
|
flat = root / SUMMARY_DIR_NAME
|
||||||
|
flat_summary = flat / "summary.json"
|
||||||
|
if flat.is_dir() and flat_summary.is_file():
|
||||||
|
if not pointer_id or _recorded_summary_id(flat_summary) == pointer_id:
|
||||||
|
return flat
|
||||||
|
if not pointer:
|
||||||
|
return None
|
||||||
|
path = root / str(pointer.get("path") or "")
|
||||||
|
return path if path.is_dir() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_flat_summary(temporary: Path, source: Path, hashes: dict[str, str]) -> None:
|
||||||
|
"""Copy the published four files into a fresh directory and verify them.
|
||||||
|
|
||||||
|
The copy is written into ``temporary`` first, so a failure here never touches
|
||||||
|
the existing ``summary/``; it becomes the new readable copy only after every
|
||||||
|
byte matched the pointer's hashes.
|
||||||
|
"""
|
||||||
|
temporary.mkdir(parents=True, exist_ok=False)
|
||||||
|
for name in SUMMARY_FILES:
|
||||||
|
destination = temporary / name
|
||||||
|
shutil.copy2(source / name, destination)
|
||||||
|
if hashes.get(name) and _sha256_file(destination) != hashes[name]:
|
||||||
|
raise SummaryExportError(f"the summary copy of {name} does not match the export")
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_directory(target: Path, temporary: Path) -> None:
|
||||||
|
"""Swap ``temporary`` into ``target``, restoring the old directory on failure."""
|
||||||
|
backup = target.parent / f".tmp-summary-old-{uuid4().hex[:8]}"
|
||||||
|
moved_old = False
|
||||||
|
if target.exists():
|
||||||
|
target.rename(backup)
|
||||||
|
moved_old = True
|
||||||
|
try:
|
||||||
|
temporary.rename(target)
|
||||||
|
except OSError:
|
||||||
|
if moved_old:
|
||||||
|
backup.rename(target)
|
||||||
|
raise
|
||||||
|
if moved_old:
|
||||||
|
try:
|
||||||
|
shutil.rmtree(backup)
|
||||||
|
except OSError:
|
||||||
|
# The new copy is in place; a leftover backup is untidy, not a failure.
|
||||||
|
logger.warning("Could not remove the previous summary copy: %s", backup)
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_flat_copy(
|
||||||
|
root: Path,
|
||||||
|
source: Path,
|
||||||
|
hashes: dict[str, str],
|
||||||
|
on_warning: Callable[[str], None] | None,
|
||||||
|
) -> None:
|
||||||
|
"""Mirror the published summary under ``summary/``; report, never fail the export.
|
||||||
|
|
||||||
|
The pointer has already moved when this runs. The readable copy is a
|
||||||
|
convenience, so a failure here leaves the previous ``summary/`` and the
|
||||||
|
pointer exactly as they were and only tells the operator.
|
||||||
|
"""
|
||||||
|
temporary = root / f".tmp-summary-flat-{uuid4().hex[:8]}"
|
||||||
|
try:
|
||||||
|
_write_flat_summary(temporary, source, hashes)
|
||||||
|
_replace_directory(root / SUMMARY_DIR_NAME, temporary)
|
||||||
|
except Exception as exc: # noqa: BLE001 - a copy failure is reported, not fatal
|
||||||
|
if temporary.exists():
|
||||||
|
shutil.rmtree(temporary, ignore_errors=True)
|
||||||
|
message = (
|
||||||
|
f"the readable copy under {SUMMARY_DIR_NAME}/ was not written: {exc}. "
|
||||||
|
"The pointer still names the published summary; the recording is unchanged."
|
||||||
|
)
|
||||||
|
logger.warning("%s", message)
|
||||||
|
if on_warning is not None:
|
||||||
|
try:
|
||||||
|
on_warning(message)
|
||||||
|
except Exception: # noqa: BLE001 - a reporter must not break the export
|
||||||
|
logger.exception("The summary copy warning handler failed")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_inputs(root: Path, summary: dict) -> None:
|
||||||
|
"""Refuse to publish numbers the files on disk can no longer reproduce."""
|
||||||
|
fresh = review_session(root)
|
||||||
|
if fresh.get("input_sha256") != summary.get("input_sha256"):
|
||||||
|
raise SummaryExportError(
|
||||||
|
"recorded inputs changed since this summary was built; nothing was published"
|
||||||
|
)
|
||||||
|
if fresh.get("session_id") != summary.get("session_id"):
|
||||||
|
raise SummaryExportError("the summary belongs to a different session")
|
||||||
|
|
||||||
|
|
||||||
|
def publish_summary(
|
||||||
|
root: Path,
|
||||||
|
summary: dict,
|
||||||
|
*,
|
||||||
|
verify: bool = True,
|
||||||
|
cancelled=None,
|
||||||
|
on_warning: Callable[[str], None] | None = None,
|
||||||
|
) -> Path:
|
||||||
|
"""Write, verify, rename, move the pointer, then mirror ``summary/``.
|
||||||
|
|
||||||
|
Returns the published directory under ``derived/``. A failure of the readable
|
||||||
|
mirror is reported through ``on_warning`` (and the log) after the pointer has
|
||||||
|
moved; it never turns a published summary into a failed export.
|
||||||
|
"""
|
||||||
|
root = Path(root).resolve()
|
||||||
|
if not (root / "session.json").is_file():
|
||||||
|
raise SummaryExportError(f"not a session directory: {root}")
|
||||||
|
if not summary.get("summary_id"):
|
||||||
|
raise SummaryExportError("the summary has no id")
|
||||||
|
with _lock_for(root):
|
||||||
|
if cancelled is not None and cancelled():
|
||||||
|
raise SummaryExportError("the export was cancelled before it started")
|
||||||
|
if verify:
|
||||||
|
verify_inputs(root, summary)
|
||||||
|
previous = _read_pointer(root)
|
||||||
|
generated = str(summary.get("generated_at") or "")
|
||||||
|
if previous.get("generated_at") and generated and generated < str(previous["generated_at"]):
|
||||||
|
raise SummaryExportError(
|
||||||
|
"a newer summary is already published; this one would move the pointer back"
|
||||||
|
)
|
||||||
|
derived = root / "derived"
|
||||||
|
derived.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = derived / f".tmp-summary-{uuid4().hex[:8]}"
|
||||||
|
target = derived / f"summary_{summary['summary_id']}"
|
||||||
|
if target.exists():
|
||||||
|
raise SummaryExportError(f"this summary is already published: {target.name}")
|
||||||
|
try:
|
||||||
|
hashes = _write_export(temporary, summary)
|
||||||
|
if cancelled is not None and cancelled():
|
||||||
|
raise SummaryExportError("the export was cancelled before publishing")
|
||||||
|
temporary.rename(target)
|
||||||
|
except Exception as exc:
|
||||||
|
if temporary.exists():
|
||||||
|
# Best effort: the failure is already being reported, and a
|
||||||
|
# leftover temp directory must not replace that reason.
|
||||||
|
for leftover in sorted(temporary.rglob("*"), reverse=True):
|
||||||
|
try:
|
||||||
|
leftover.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
temporary.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if isinstance(exc, SummaryExportError):
|
||||||
|
raise
|
||||||
|
raise SummaryExportError(f"could not publish the summary: {exc}") from exc
|
||||||
|
pointer = _pointer_for(root, summary, target, hashes)
|
||||||
|
write_json(root / POINTER_NAME, pointer)
|
||||||
|
# The pointer is the authority from here on; the readable copy is optional.
|
||||||
|
_publish_flat_copy(root, target, hashes, on_warning)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def publish_reviewed_summary(
|
||||||
|
root: Path,
|
||||||
|
review: dict,
|
||||||
|
*,
|
||||||
|
review_path: Path | None = None,
|
||||||
|
) -> Path:
|
||||||
|
"""Publish a summary of an existing (already saved) review snapshot."""
|
||||||
|
summary = build_summary(Path(root), review=review, review_path=review_path)
|
||||||
|
return publish_summary(root, summary)
|
||||||
|
|
||||||
|
|
||||||
|
def load_summary(root: Path) -> tuple[dict | None, dict]:
|
||||||
|
"""``(summary, pointer)`` for the last published result, or ``(None, {})``."""
|
||||||
|
root = Path(root).resolve()
|
||||||
|
pointer = _read_pointer(root)
|
||||||
|
if not pointer:
|
||||||
|
return None, {}
|
||||||
|
if pointer.get("format_version") != POINTER_FORMAT:
|
||||||
|
raise SummaryExportError("unsupported pointer format")
|
||||||
|
path = (root / str(pointer.get("path") or "")) / "summary.json"
|
||||||
|
if not path.is_file():
|
||||||
|
raise SummaryExportError("the pointer names a summary that is not there")
|
||||||
|
for name, digest in (pointer.get("files") or {}).items():
|
||||||
|
candidate = path.parent / name
|
||||||
|
if not candidate.is_file() or _sha256_file(candidate) != digest:
|
||||||
|
raise SummaryExportError(f"the published summary was modified: {name}")
|
||||||
|
return json.loads(path.read_text(encoding="utf-8")), pointer
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
"""One manually confirmed finger load/unload trial.
|
||||||
|
|
||||||
|
The acquisition worker owns frames; the shared recorder owns disk I/O. This
|
||||||
|
module owns only the site, planned steps and operator-confirmed event order.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from orisys_app_shared.record import ExperimentRecorder
|
||||||
|
|
||||||
|
from .calibration_session import CalibrationSession, write_json
|
||||||
|
from .capture_guard import CaptureGuard, capture_verdict, PENDING
|
||||||
|
from .recording_options import (
|
||||||
|
RecordingOptions,
|
||||||
|
make_record_config,
|
||||||
|
require_codec_available,
|
||||||
|
)
|
||||||
|
from .sensor.uv_flat import Site
|
||||||
|
|
||||||
|
|
||||||
|
REFERENCE_HEADER = (
|
||||||
|
"step_index",
|
||||||
|
"branch",
|
||||||
|
"target_load_n",
|
||||||
|
"t_start_host_mono_ns",
|
||||||
|
"t_end_host_mono_ns",
|
||||||
|
"F_ref_normal_N",
|
||||||
|
"F_ref_x_N",
|
||||||
|
"F_ref_y_N",
|
||||||
|
"F_ref_z_N",
|
||||||
|
"force_frame",
|
||||||
|
"source",
|
||||||
|
"stability_verified",
|
||||||
|
"hold_complete",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_path(levels: list[float]) -> list[tuple[str, float]]:
|
||||||
|
"""One continuous ascent and descent, ending at zero."""
|
||||||
|
values = [float(value) for value in levels]
|
||||||
|
if not values or any(not 0.0 < value <= 30.0 for value in values):
|
||||||
|
raise ValueError("load levels must be in (0, 30] N")
|
||||||
|
if values != sorted(set(values)):
|
||||||
|
raise ValueError("load levels must be strictly increasing")
|
||||||
|
return [("zero", 0.0)] + [("load", value) for value in values] + [
|
||||||
|
("unload", value) for value in [*reversed(values[:-1]), 0.0]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ManualTrial:
|
||||||
|
"""Connect a running finger source to one independent recording directory."""
|
||||||
|
|
||||||
|
def __init__(self, worker) -> None:
|
||||||
|
self.worker = worker
|
||||||
|
self.session: CalibrationSession | None = None
|
||||||
|
self.entry: dict | None = None
|
||||||
|
#: The recording settings this session runs with (plan P2). The session
|
||||||
|
#: manifest locks them at establishment; a later trial reuses them.
|
||||||
|
self.recording = RecordingOptions()
|
||||||
|
self.hold_s = 20.0
|
||||||
|
self.completed_steps: list[dict] = []
|
||||||
|
self.reason = ""
|
||||||
|
self.recorder: ExperimentRecorder | None = None
|
||||||
|
self.guard: CaptureGuard | None = None
|
||||||
|
self.capture_state = "idle"
|
||||||
|
self.capture_reason = ""
|
||||||
|
self.capture_report: dict | None = None
|
||||||
|
self.directory: Path | None = None
|
||||||
|
self.steps: list[tuple[str, float]] = []
|
||||||
|
self.step_index = 0
|
||||||
|
self.status = "idle"
|
||||||
|
self.aborted = False
|
||||||
|
self.reference_alignment_possible = False
|
||||||
|
self._last_confirm_ns: int | None = None
|
||||||
|
self._closing_since_ns: int | None = None
|
||||||
|
self._reference_file = None
|
||||||
|
self._reference_writer = None
|
||||||
|
self._active_reference: tuple[int, str, float, float, int] | None = None
|
||||||
|
self._reference_error = ""
|
||||||
|
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
root: Path,
|
||||||
|
site: Site,
|
||||||
|
levels: list[float],
|
||||||
|
hold_s: float,
|
||||||
|
sensor_model: str,
|
||||||
|
sensor_sn: str,
|
||||||
|
bundle_path: str,
|
||||||
|
source: dict,
|
||||||
|
config_ref: str,
|
||||||
|
fps: float,
|
||||||
|
uv_outline: dict | None = None,
|
||||||
|
recording: RecordingOptions | None = None,
|
||||||
|
codec_availability: Mapping[str, bool] | None = None,
|
||||||
|
) -> Path:
|
||||||
|
if self.status in {"recording", "closing"}:
|
||||||
|
raise RuntimeError("a trial is already active")
|
||||||
|
if not self.worker.is_running or self.worker.latest() is None:
|
||||||
|
raise RuntimeError("start the finger input and wait for its first frame")
|
||||||
|
if not sensor_sn.strip():
|
||||||
|
raise ValueError("sensor SN is required")
|
||||||
|
if not sensor_model.strip():
|
||||||
|
raise ValueError("sensor model is required")
|
||||||
|
if not math.isfinite(float(fps)) or not float(fps) > 0.0:
|
||||||
|
raise ValueError("input frame rate must be positive")
|
||||||
|
steps = load_path(levels)
|
||||||
|
if not 10.0 <= float(hold_s) <= 30.0:
|
||||||
|
raise ValueError("hold time must be between 10 and 30 seconds")
|
||||||
|
self.hold_s = float(hold_s)
|
||||||
|
# The session is the owner of the video choice: it is written into the
|
||||||
|
# root manifest once and every later trial reuses it. The codec is
|
||||||
|
# checked before anything is written, so an encoder this machine cannot
|
||||||
|
# open is refused instead of silently replaced.
|
||||||
|
options = RecordingOptions() if recording is None else recording
|
||||||
|
if not isinstance(options, RecordingOptions):
|
||||||
|
raise TypeError("recording must be a RecordingOptions instance")
|
||||||
|
if self.session is None:
|
||||||
|
require_codec_available(options, codec_availability)
|
||||||
|
else:
|
||||||
|
locked = RecordingOptions.from_block(self.session.document.get("recording"))
|
||||||
|
if recording is not None and options != locked:
|
||||||
|
raise ValueError(
|
||||||
|
"recording settings are locked once the session has started; "
|
||||||
|
"finish this session before changing them"
|
||||||
|
)
|
||||||
|
options = locked
|
||||||
|
self.recording = options
|
||||||
|
packet = self.worker.latest()
|
||||||
|
if packet.capture_mono_ns is None:
|
||||||
|
raise RuntimeError("the input has no frame-bound host timestamp")
|
||||||
|
mesh_path = Path(bundle_path) / "mesh.npz"
|
||||||
|
config_path = Path(config_ref)
|
||||||
|
if not mesh_path.is_file() or not config_path.is_file():
|
||||||
|
raise ValueError("sensor mesh and configuration files are required")
|
||||||
|
mesh_sha256 = hashlib.sha256(mesh_path.read_bytes()).hexdigest()
|
||||||
|
config_sha256 = hashlib.sha256(config_path.read_bytes()).hexdigest()
|
||||||
|
self.reference_alignment_possible = packet.time_basis == "host_camera"
|
||||||
|
metadata = {
|
||||||
|
"purpose": "autocal_finger_manual_trial",
|
||||||
|
"sensor_sn": sensor_sn.strip(),
|
||||||
|
"sensor_model": sensor_model.strip(),
|
||||||
|
"site": {
|
||||||
|
"uv_xy_px": [site.u_px, site.v_px],
|
||||||
|
"position_sensor_mm": list(site.position_sensor_mm),
|
||||||
|
"face_index": site.face_index,
|
||||||
|
"raster_xy_px": list(site.raster_xy_px),
|
||||||
|
"uv_origin_raster_xy_px": list(site.uv_origin_raster_xy_px),
|
||||||
|
"bundle_path": bundle_path,
|
||||||
|
"mesh_sha256": mesh_sha256,
|
||||||
|
"raster_size_hw": list(site.raster_size_hw),
|
||||||
|
},
|
||||||
|
"uv_definition": {
|
||||||
|
"unit": "px", "u_positive": "right", "v_positive": "up",
|
||||||
|
"origin_raster_xy_px": list(site.uv_origin_raster_xy_px),
|
||||||
|
"raster_size_hw": list(site.raster_size_hw),
|
||||||
|
"mapping_sha256": site.mapping_sha256,
|
||||||
|
},
|
||||||
|
"plan": {"levels_n": levels, "hold_s": float(hold_s), "steps": [
|
||||||
|
{"step_index": i, "branch": branch, "target_load_n": target}
|
||||||
|
for i, (branch, target) in enumerate(steps)
|
||||||
|
]},
|
||||||
|
"input_source": source,
|
||||||
|
"config_ref": config_ref,
|
||||||
|
"config_sha256": config_sha256,
|
||||||
|
"config_snapshot": json.loads(config_path.read_text(encoding="utf-8")),
|
||||||
|
"time_basis": packet.time_basis,
|
||||||
|
"reference_alignment_possible": self.reference_alignment_possible,
|
||||||
|
"reference_source": "manual_operator_confirmation",
|
||||||
|
"reference_file": "reference.csv",
|
||||||
|
"uv_outline": uv_outline,
|
||||||
|
# The root manifest keeps this block once; the trial's own metadata
|
||||||
|
# repeats it so a single trial folder states what it recorded.
|
||||||
|
"recording": self.recording.as_block(),
|
||||||
|
}
|
||||||
|
if uv_outline is not None and any(uv_outline[key] != metadata["uv_definition"][key]
|
||||||
|
for key in ("mapping_sha256", "origin_raster_xy_px", "raster_size_hw")):
|
||||||
|
raise ValueError("UV outline does not match the selected site mapping")
|
||||||
|
recorder = ExperimentRecorder(make_record_config(self.recording, fps))
|
||||||
|
if self.session is None:
|
||||||
|
self.session = CalibrationSession(Path(root).resolve(), metadata)
|
||||||
|
elif Path(root).resolve() != self.session.directory.parent:
|
||||||
|
raise ValueError("finish this session before changing the output root")
|
||||||
|
entry = self.session.add_trial(metadata)
|
||||||
|
metadata.update({
|
||||||
|
"calibration_session_id": self.session.document["session_id"],
|
||||||
|
"trial_id": entry["trial_id"], "site_id": entry["site_id"],
|
||||||
|
"repeat_index": entry["repeat_index"], "trial_status": "incomplete",
|
||||||
|
})
|
||||||
|
directory = recorder.start(
|
||||||
|
root=self.session.directory / "trials",
|
||||||
|
session_id=packet.session_id,
|
||||||
|
metadata=metadata,
|
||||||
|
directory_name=entry["trial_id"],
|
||||||
|
)
|
||||||
|
reference_file = None
|
||||||
|
try:
|
||||||
|
reference_file = (directory / "reference.csv").open(
|
||||||
|
"w", newline="", encoding="utf-8"
|
||||||
|
)
|
||||||
|
reference_writer = csv.writer(reference_file)
|
||||||
|
reference_writer.writerow(REFERENCE_HEADER)
|
||||||
|
reference_file.flush()
|
||||||
|
except OSError:
|
||||||
|
if reference_file is not None:
|
||||||
|
reference_file.close()
|
||||||
|
recorder.stop(wait=True)
|
||||||
|
raise
|
||||||
|
self.entry = entry
|
||||||
|
self.steps = steps
|
||||||
|
self.completed_steps = []
|
||||||
|
self.reason = ""
|
||||||
|
self.recorder = recorder
|
||||||
|
self.directory = directory
|
||||||
|
self._reference_file = reference_file
|
||||||
|
self._reference_writer = reference_writer
|
||||||
|
self._active_reference = None
|
||||||
|
self._reference_error = ""
|
||||||
|
self.step_index = 0
|
||||||
|
self.aborted = False
|
||||||
|
self._last_confirm_ns = None
|
||||||
|
self._closing_since_ns = None
|
||||||
|
self.status = "recording"
|
||||||
|
self.capture_state = "recording"
|
||||||
|
self.capture_reason = ""
|
||||||
|
self.capture_report = None
|
||||||
|
guard = CaptureGuard(expected_session_id=int(packet.session_id), recorder=recorder)
|
||||||
|
self.guard = guard
|
||||||
|
self.worker.add_sink(guard)
|
||||||
|
self._event("trial_started", {"site_uv_xy_px": [site.u_px, site.v_px]})
|
||||||
|
return directory
|
||||||
|
|
||||||
|
@property
|
||||||
|
def next_step(self) -> tuple[str, float] | None:
|
||||||
|
return (
|
||||||
|
self.steps[self.step_index] if self.step_index < len(self.steps) else None
|
||||||
|
)
|
||||||
|
|
||||||
|
def _event(self, kind: str, detail: dict, *, now_ns: int | None = None) -> None:
|
||||||
|
recorder = self.recorder
|
||||||
|
if recorder is None:
|
||||||
|
return
|
||||||
|
now_mono = now_ns if now_ns is not None else time.perf_counter_ns()
|
||||||
|
packet = self.worker.latest()
|
||||||
|
if packet is not None and packet.capture_mono_ns is not None:
|
||||||
|
event_mono_t = (
|
||||||
|
float(packet.mono_t or 0.0) + (now_mono - packet.capture_mono_ns) / 1e9
|
||||||
|
)
|
||||||
|
frame_id = packet.frame_id
|
||||||
|
else:
|
||||||
|
event_mono_t = None
|
||||||
|
frame_id = -1
|
||||||
|
recorder.log_event(
|
||||||
|
kind,
|
||||||
|
json.dumps(
|
||||||
|
{**detail, "t_host_mono_ns": now_mono,
|
||||||
|
"t_host_utc_ns": time.time_ns() + now_mono - time.perf_counter_ns()},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
mono_t=event_mono_t,
|
||||||
|
frame_id=frame_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _end_reference(self, end_ns: int, *, complete: bool) -> None:
|
||||||
|
if self._active_reference is None:
|
||||||
|
return
|
||||||
|
step_index, branch, target, reference, start_ns = self._active_reference
|
||||||
|
try:
|
||||||
|
self._reference_writer.writerow(
|
||||||
|
(
|
||||||
|
step_index,
|
||||||
|
branch,
|
||||||
|
target,
|
||||||
|
start_ns,
|
||||||
|
end_ns,
|
||||||
|
reference,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"manual_operator_confirmation",
|
||||||
|
0,
|
||||||
|
int(complete),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._reference_file.flush()
|
||||||
|
except OSError as exc:
|
||||||
|
self._reference_error = str(exc)
|
||||||
|
finally:
|
||||||
|
self._active_reference = None
|
||||||
|
|
||||||
|
def confirm(self, reference_n: float) -> tuple[str, float]:
|
||||||
|
if self.status != "recording" or self.next_step is None:
|
||||||
|
raise RuntimeError("no load step is waiting for confirmation")
|
||||||
|
branch, target = self.next_step
|
||||||
|
reference = float(reference_n)
|
||||||
|
if not 0.0 <= reference <= 30.0:
|
||||||
|
raise ValueError("reference load must be between 0 and 30 N")
|
||||||
|
now_ns = time.perf_counter_ns()
|
||||||
|
if self._active_reference is not None:
|
||||||
|
raise RuntimeError("wait for the current hold to finish")
|
||||||
|
self._event(
|
||||||
|
"step_reached",
|
||||||
|
{
|
||||||
|
"step_index": self.step_index,
|
||||||
|
"branch": branch,
|
||||||
|
"target_load_n": target,
|
||||||
|
"reference_load_n": reference,
|
||||||
|
"reference_kind": "manual",
|
||||||
|
},
|
||||||
|
now_ns=now_ns,
|
||||||
|
)
|
||||||
|
self._active_reference = (self.step_index, branch, target, reference, now_ns)
|
||||||
|
self._last_confirm_ns = now_ns
|
||||||
|
self._event("hold_started", {"step_index": self.step_index}, now_ns=now_ns)
|
||||||
|
return branch, target
|
||||||
|
|
||||||
|
@property
|
||||||
|
def holding(self) -> bool:
|
||||||
|
return self._active_reference is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remaining_s(self) -> float:
|
||||||
|
if not self.holding:
|
||||||
|
return 0.0
|
||||||
|
return max(0.0, self.hold_s - (time.perf_counter_ns() - self._last_confirm_ns) / 1e9)
|
||||||
|
|
||||||
|
def _complete_hold(self, end_ns: int, *, complete: bool) -> None:
|
||||||
|
index, branch, target, reference, start_ns = self._active_reference
|
||||||
|
self._end_reference(end_ns, complete=complete)
|
||||||
|
step = {
|
||||||
|
"step_index": index, "branch": branch, "target_load_n": target,
|
||||||
|
"reference_load_n": reference,
|
||||||
|
"t_start_host_mono_ns": start_ns, "t_end_host_mono_ns": end_ns,
|
||||||
|
"status": "complete" if complete and not self._reference_error else "incomplete",
|
||||||
|
"stability_verified": False,
|
||||||
|
}
|
||||||
|
self.completed_steps.append(step)
|
||||||
|
self._event("hold_ended", step, now_ns=end_ns)
|
||||||
|
self._last_confirm_ns = None
|
||||||
|
if complete and not self._reference_error:
|
||||||
|
self.step_index += 1
|
||||||
|
if self.session is not None and self.entry is not None:
|
||||||
|
self.entry["steps"] = list(self.completed_steps)
|
||||||
|
try:
|
||||||
|
self.session.save()
|
||||||
|
except OSError as exc:
|
||||||
|
self._reference_error = f"session write failed: {exc}"
|
||||||
|
|
||||||
|
def finish_session(self) -> tuple[Path, str]:
|
||||||
|
if self.status in {"recording", "closing"}:
|
||||||
|
raise RuntimeError("finish the current trial first")
|
||||||
|
if self.session is None:
|
||||||
|
raise RuntimeError("no calibration session")
|
||||||
|
directory = self.session.directory
|
||||||
|
status = self.session.close()
|
||||||
|
self.session = None
|
||||||
|
return directory, status
|
||||||
|
|
||||||
|
def finish(self, *, aborted: bool = False, reason: str = "") -> None:
|
||||||
|
if self.status != "recording" or self.recorder is None:
|
||||||
|
raise RuntimeError("no active trial")
|
||||||
|
if not aborted and self.next_step is not None:
|
||||||
|
raise RuntimeError("complete the load/unload path before finishing")
|
||||||
|
now_ns = time.perf_counter_ns()
|
||||||
|
if self.holding:
|
||||||
|
self._complete_hold(now_ns, complete=False)
|
||||||
|
try:
|
||||||
|
self._reference_file.close()
|
||||||
|
except OSError as exc:
|
||||||
|
self._reference_error = str(exc)
|
||||||
|
if self._reference_error:
|
||||||
|
aborted = True
|
||||||
|
reason = f"reference write failed: {self._reference_error}"
|
||||||
|
self._event(
|
||||||
|
"trial_aborted" if aborted else "trial_finished",
|
||||||
|
{"reason": reason},
|
||||||
|
now_ns=now_ns,
|
||||||
|
)
|
||||||
|
self.reason = reason
|
||||||
|
self.aborted = aborted
|
||||||
|
if self.guard is not None:
|
||||||
|
self.worker.remove_sink(self.guard)
|
||||||
|
else:
|
||||||
|
self.worker.remove_sink(self.recorder.on_packet)
|
||||||
|
self.recorder.stop(wait=False)
|
||||||
|
self._closing_since_ns = time.perf_counter_ns()
|
||||||
|
self.status = "closing"
|
||||||
|
self.capture_state = "closing"
|
||||||
|
|
||||||
|
def poll(self) -> str:
|
||||||
|
if self.status == "recording" and self.recorder is not None:
|
||||||
|
if self.guard is not None and self.guard.failed:
|
||||||
|
self.finish(aborted=True, reason="the image source restarted")
|
||||||
|
elif not self.worker.is_running or not self.recorder.is_recording:
|
||||||
|
self.finish(aborted=True, reason="input or writer stopped")
|
||||||
|
elif self.holding and self.remaining_s <= 0:
|
||||||
|
deadline = self._last_confirm_ns + round(self.hold_s * 1e9)
|
||||||
|
self._complete_hold(deadline, complete=True)
|
||||||
|
if self._reference_error:
|
||||||
|
self.finish(aborted=True, reason=self._reference_error)
|
||||||
|
if self.status == "closing" and self.recorder is not None:
|
||||||
|
complete = not self.aborted and self.next_step is None
|
||||||
|
elapsed = (
|
||||||
|
None if self._closing_since_ns is None
|
||||||
|
else (time.perf_counter_ns() - self._closing_since_ns) / 1e9
|
||||||
|
)
|
||||||
|
verdict = capture_verdict(
|
||||||
|
self.recorder,
|
||||||
|
complete=complete,
|
||||||
|
reason=self.reason,
|
||||||
|
guard=self.guard,
|
||||||
|
elapsed_s=elapsed,
|
||||||
|
video_expected=self.recording.video,
|
||||||
|
)
|
||||||
|
if verdict.status == PENDING:
|
||||||
|
return self.status
|
||||||
|
self.status = verdict.status
|
||||||
|
self.capture_state = verdict.status
|
||||||
|
self.capture_reason = verdict.reason
|
||||||
|
self.capture_report = verdict.report
|
||||||
|
self.entry.update({
|
||||||
|
"status": self.status,
|
||||||
|
"reason": self.reason or ("" if self.status == "complete" else verdict.reason),
|
||||||
|
"steps": list(self.completed_steps),
|
||||||
|
"capture_status": verdict.status,
|
||||||
|
"capture_reason": verdict.reason,
|
||||||
|
"capture_report": verdict.report,
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
trial_path = self.directory / "session.json"
|
||||||
|
document = json.loads(trial_path.read_text(encoding="utf-8"))
|
||||||
|
document.update({
|
||||||
|
"trial_status": self.status,
|
||||||
|
"steps": self.completed_steps,
|
||||||
|
"trial_reason": self.entry["reason"],
|
||||||
|
"capture_status": verdict.status,
|
||||||
|
"capture_reason": verdict.reason,
|
||||||
|
"capture_report": verdict.report,
|
||||||
|
})
|
||||||
|
write_json(trial_path, document)
|
||||||
|
self.session.save()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
self.status = "incomplete"
|
||||||
|
self.entry["status"] = "incomplete"
|
||||||
|
raise
|
||||||
|
return self.status
|
||||||
|
|
||||||
|
def capture_summary(self) -> dict:
|
||||||
|
"""Live recording state for the operator label; no disk read."""
|
||||||
|
recorder = self.recorder
|
||||||
|
stats = recorder.stats() if recorder is not None else None
|
||||||
|
return {
|
||||||
|
"state": self.capture_state,
|
||||||
|
"reason": self.capture_reason,
|
||||||
|
"frames": int(getattr(stats, "frames", 0) or 0),
|
||||||
|
"video_frames": int(getattr(stats, "video_frames", 0) or 0),
|
||||||
|
"dropped_data": int(getattr(stats, "dropped_data", 0) or 0),
|
||||||
|
"dropped_video": int(getattr(stats, "dropped_video", 0) or 0),
|
||||||
|
"duration_s": float(getattr(stats, "duration_s", 0.0) or 0.0),
|
||||||
|
"directory": str(self.directory or ""),
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"""The array tool of the sequence page: propose points on the map, see them, add them.
|
||||||
|
|
||||||
|
It is a small form and nothing more. The tool turns its fields into a list of U/V
|
||||||
|
candidates (``array_plan``) and announces it; the window checks each one against the
|
||||||
|
loaded surface, draws them on the 2D map, and hands the verdict back through
|
||||||
|
:meth:`ArrayTool.set_checked`. Only candidates the surface supports can be added, so
|
||||||
|
a sequence never receives a point nothing has checked — the rule the point table's
|
||||||
|
hint states.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from PySide6.QtCore import Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
|
QGridLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QSpinBox,
|
||||||
|
QStackedWidget,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..array_plan import (
|
||||||
|
NEAR_EDGE,
|
||||||
|
OFF_SURFACE,
|
||||||
|
PRESET_KEYS,
|
||||||
|
Candidate,
|
||||||
|
grid_uv,
|
||||||
|
preset_uv,
|
||||||
|
)
|
||||||
|
|
||||||
|
MODES: tuple[str, ...] = ("grid", "preset")
|
||||||
|
|
||||||
|
#: Defaults for a first look: a 3 x 3 centred on the middle of the patch.
|
||||||
|
_DEFAULTS = {
|
||||||
|
"rows": 3, "cols": 3, "u_pitch": 130, "v_pitch": 150,
|
||||||
|
"centre_u": 0, "centre_v": 470, "margin": 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ArrayTool(QWidget):
|
||||||
|
"""Grid or staged-preset candidates, previewed on the map before they are added."""
|
||||||
|
|
||||||
|
#: ``(points, margin_px)``: raw candidates, to be checked against the surface.
|
||||||
|
#: Always sent, whether or not they are drawn: the count and the add button need
|
||||||
|
#: the verdict either way.
|
||||||
|
previewChanged = Signal(object, int)
|
||||||
|
#: The usable candidates, as :class:`~orisys_autocal.array_plan.Candidate`.
|
||||||
|
addRequested = Signal(object)
|
||||||
|
#: Take the point picked on the map as the grid's centre.
|
||||||
|
centreFromPickRequested = Signal()
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._checked: list[Candidate] = []
|
||||||
|
self._spins: dict[str, QSpinBox] = {}
|
||||||
|
self._labels: dict[str, QLabel] = {}
|
||||||
|
|
||||||
|
column = QVBoxLayout(self)
|
||||||
|
column.setContentsMargins(4, 6, 4, 4)
|
||||||
|
column.setSpacing(6)
|
||||||
|
|
||||||
|
top = QHBoxLayout()
|
||||||
|
top.setSpacing(8)
|
||||||
|
self.mode_combo = self._combo()
|
||||||
|
for mode in MODES:
|
||||||
|
self.mode_combo.addItem("", mode)
|
||||||
|
self._labels["mode"] = QLabel(self)
|
||||||
|
self._labels["margin"] = QLabel(self)
|
||||||
|
top.addWidget(self._labels["mode"])
|
||||||
|
top.addWidget(self.mode_combo, 1)
|
||||||
|
top.addWidget(self._labels["margin"])
|
||||||
|
top.addWidget(self._spin("margin", 0, 200), 0)
|
||||||
|
column.addLayout(top)
|
||||||
|
|
||||||
|
self.pages = QStackedWidget(self)
|
||||||
|
self.pages.addWidget(self._build_grid_page())
|
||||||
|
self.pages.addWidget(self._build_preset_page())
|
||||||
|
column.addWidget(self.pages)
|
||||||
|
|
||||||
|
self.info_label = QLabel(self)
|
||||||
|
self.info_label.setObjectName("statusValue")
|
||||||
|
self.info_label.setWordWrap(True)
|
||||||
|
column.addWidget(self.info_label)
|
||||||
|
column.addStretch(1)
|
||||||
|
|
||||||
|
buttons = QHBoxLayout()
|
||||||
|
buttons.setSpacing(8)
|
||||||
|
self.show_check = QCheckBox(self)
|
||||||
|
self.show_check.setChecked(True)
|
||||||
|
self.show_check.toggled.connect(self._emit_preview)
|
||||||
|
self.add_button = QPushButton(self)
|
||||||
|
self.add_button.setObjectName("primaryButton")
|
||||||
|
self.add_button.clicked.connect(self._on_add)
|
||||||
|
buttons.addWidget(self.show_check)
|
||||||
|
buttons.addStretch(1)
|
||||||
|
buttons.addWidget(self.add_button)
|
||||||
|
column.addLayout(buttons)
|
||||||
|
|
||||||
|
self.mode_combo.currentIndexChanged.connect(self._on_mode)
|
||||||
|
self.preset_combo.currentIndexChanged.connect(self._emit_preview)
|
||||||
|
self.serpentine_check.toggled.connect(self._emit_preview)
|
||||||
|
for spin in self._spins.values():
|
||||||
|
spin.valueChanged.connect(self._emit_preview)
|
||||||
|
self.retranslate()
|
||||||
|
self._sync_add()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ widgets
|
||||||
|
|
||||||
|
def _combo(self) -> QComboBox:
|
||||||
|
combo = QComboBox(self)
|
||||||
|
combo.setObjectName("compactField")
|
||||||
|
combo.setFixedHeight(26)
|
||||||
|
return combo
|
||||||
|
|
||||||
|
def _spin(self, key: str, low: int, high: int) -> QSpinBox:
|
||||||
|
spin = QSpinBox(self)
|
||||||
|
spin.setObjectName("compactField")
|
||||||
|
spin.setRange(low, high)
|
||||||
|
spin.setValue(_DEFAULTS.get(key, 0))
|
||||||
|
spin.setFixedHeight(26)
|
||||||
|
spin.setMinimumWidth(64)
|
||||||
|
self._spins[key] = spin
|
||||||
|
return spin
|
||||||
|
|
||||||
|
def _build_grid_page(self) -> QWidget:
|
||||||
|
page = QWidget(self)
|
||||||
|
grid = QGridLayout(page)
|
||||||
|
grid.setContentsMargins(0, 0, 0, 0)
|
||||||
|
grid.setHorizontalSpacing(8)
|
||||||
|
grid.setVerticalSpacing(6)
|
||||||
|
specs = (
|
||||||
|
("rows", 1, 20), ("cols", 1, 20),
|
||||||
|
("u_pitch", 1, 1500), ("v_pitch", 1, 1500),
|
||||||
|
("centre_u", -1500, 1500), ("centre_v", -1500, 1500),
|
||||||
|
)
|
||||||
|
for index, (key, low, high) in enumerate(specs):
|
||||||
|
row, side = divmod(index, 2)
|
||||||
|
self._labels[key] = QLabel(page)
|
||||||
|
grid.addWidget(self._labels[key], row, side * 2)
|
||||||
|
grid.addWidget(self._spin(key, low, high), row, side * 2 + 1)
|
||||||
|
self.serpentine_check = QCheckBox(page)
|
||||||
|
self.serpentine_check.setChecked(True)
|
||||||
|
self.centre_button = QPushButton(page)
|
||||||
|
self.centre_button.setProperty("rail", True)
|
||||||
|
self.centre_button.clicked.connect(self.centreFromPickRequested)
|
||||||
|
grid.addWidget(self.serpentine_check, 3, 0, 1, 2)
|
||||||
|
grid.addWidget(self.centre_button, 3, 2, 1, 2)
|
||||||
|
grid.setColumnStretch(1, 1)
|
||||||
|
grid.setColumnStretch(3, 1)
|
||||||
|
return page
|
||||||
|
|
||||||
|
def _build_preset_page(self) -> QWidget:
|
||||||
|
page = QWidget(self)
|
||||||
|
column = QVBoxLayout(page)
|
||||||
|
column.setContentsMargins(0, 0, 0, 0)
|
||||||
|
column.setSpacing(6)
|
||||||
|
row = QHBoxLayout()
|
||||||
|
self._labels["preset"] = QLabel(page)
|
||||||
|
self.preset_combo = self._combo()
|
||||||
|
for key in PRESET_KEYS:
|
||||||
|
self.preset_combo.addItem("", key)
|
||||||
|
row.addWidget(self._labels["preset"])
|
||||||
|
row.addWidget(self.preset_combo, 1)
|
||||||
|
column.addLayout(row)
|
||||||
|
self.preset_hint = QLabel(page)
|
||||||
|
self.preset_hint.setObjectName("hintLabel")
|
||||||
|
self.preset_hint.setWordWrap(True)
|
||||||
|
column.addWidget(self.preset_hint)
|
||||||
|
column.addStretch(1)
|
||||||
|
return page
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mode(self) -> str:
|
||||||
|
return str(self.mode_combo.currentData() or MODES[0])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def margin_px(self) -> int:
|
||||||
|
return int(self._spins["margin"].value())
|
||||||
|
|
||||||
|
def spin(self, key: str) -> QSpinBox:
|
||||||
|
return self._spins[key]
|
||||||
|
|
||||||
|
def set_centre(self, u: int, v: int) -> None:
|
||||||
|
for key, value in (("centre_u", u), ("centre_v", v)):
|
||||||
|
self._spins[key].setValue(int(round(value)))
|
||||||
|
|
||||||
|
def candidates(self) -> list[tuple]:
|
||||||
|
"""The raw proposed points for the current fields, ``(u, v)`` or ``(label, u, v)``."""
|
||||||
|
if self.mode == "preset":
|
||||||
|
return preset_uv(str(self.preset_combo.currentData()))
|
||||||
|
return grid_uv(
|
||||||
|
self._spins["rows"].value(),
|
||||||
|
self._spins["cols"].value(),
|
||||||
|
self._spins["u_pitch"].value(),
|
||||||
|
self._spins["v_pitch"].value(),
|
||||||
|
(self._spins["centre_u"].value(), self._spins["centre_v"].value()),
|
||||||
|
serpentine=self.serpentine_check.isChecked(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def previewing(self) -> bool:
|
||||||
|
return self.show_check.isChecked()
|
||||||
|
|
||||||
|
def usable(self) -> list[Candidate]:
|
||||||
|
return [c for c in self._checked if c.valid]
|
||||||
|
|
||||||
|
def set_checked(self, checked: list[Candidate]) -> None:
|
||||||
|
"""The surface's verdict on the current candidates; updates the count and the button."""
|
||||||
|
self._checked = list(checked)
|
||||||
|
usable = len(self.usable())
|
||||||
|
off = sum(1 for c in checked if c.reason == OFF_SURFACE)
|
||||||
|
edge = sum(1 for c in checked if c.reason == NEAR_EDGE)
|
||||||
|
if not checked:
|
||||||
|
self.info_label.setText(t("array.none"))
|
||||||
|
elif not (off or edge):
|
||||||
|
self.info_label.setText(t("array.all_ok", total=len(checked)))
|
||||||
|
else:
|
||||||
|
self.info_label.setText(
|
||||||
|
t("array.some_bad", total=len(checked), usable=usable, off=off, edge=edge)
|
||||||
|
)
|
||||||
|
self._sync_add()
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- behaviour
|
||||||
|
|
||||||
|
def _on_mode(self, *_args) -> None:
|
||||||
|
self.pages.setCurrentIndex(MODES.index(self.mode))
|
||||||
|
self._emit_preview()
|
||||||
|
|
||||||
|
def _emit_preview(self, *_args) -> None:
|
||||||
|
try:
|
||||||
|
points = self.candidates()
|
||||||
|
except ValueError:
|
||||||
|
points = []
|
||||||
|
self.previewChanged.emit(points, self.margin_px)
|
||||||
|
self._sync_add()
|
||||||
|
|
||||||
|
def _on_add(self) -> None:
|
||||||
|
usable = self.usable()
|
||||||
|
if usable:
|
||||||
|
self.addRequested.emit(usable)
|
||||||
|
|
||||||
|
def _sync_add(self) -> None:
|
||||||
|
count = len(self.usable())
|
||||||
|
self.add_button.setEnabled(count > 0)
|
||||||
|
self.add_button.setText(t("array.btn_add", count=count))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- translations
|
||||||
|
|
||||||
|
def retranslate(self) -> None:
|
||||||
|
for key, label in self._labels.items():
|
||||||
|
label.setText(t(f"array.{key}"))
|
||||||
|
for index, mode in enumerate(MODES):
|
||||||
|
self.mode_combo.setItemText(index, t(f"array.mode_{mode}"))
|
||||||
|
for index, key in enumerate(PRESET_KEYS):
|
||||||
|
self.preset_combo.setItemText(index, t(f"array.preset_{key}"))
|
||||||
|
self.preset_hint.setText(t("array.preset_hint"))
|
||||||
|
self.serpentine_check.setText(t("array.serpentine"))
|
||||||
|
self.centre_button.setText(t("array.centre_from_pick"))
|
||||||
|
self.centre_button.setToolTip(t("array.centre_from_pick_tip"))
|
||||||
|
self.show_check.setText(t("array.show_preview"))
|
||||||
|
self._sync_add()
|
||||||
@@ -15,6 +15,9 @@ five-axis platform, acting on the wrong axis is the expensive mistake.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
import time
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
from PySide6.QtCore import Qt, Signal
|
from PySide6.QtCore import Qt, Signal
|
||||||
@@ -25,6 +28,8 @@ from PySide6.QtWidgets import (
|
|||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
|
QListWidget,
|
||||||
|
QListWidgetItem,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QTableWidget,
|
QTableWidget,
|
||||||
QTableWidgetItem,
|
QTableWidgetItem,
|
||||||
@@ -42,6 +47,8 @@ from ..motion import (
|
|||||||
safe_float,
|
safe_float,
|
||||||
user_units_to_mm,
|
user_units_to_mm,
|
||||||
)
|
)
|
||||||
|
from ..motion.events import UNITS_MISMATCH, AxisEvent, AxisEventTracker
|
||||||
|
from ..motion.unit_report import UNREAD, UnitRow, summarise
|
||||||
|
|
||||||
#: Data columns in the state table, in display order.
|
#: Data columns in the state table, in display order.
|
||||||
#:
|
#:
|
||||||
@@ -113,6 +120,8 @@ class AxisPanel(QGroupBox):
|
|||||||
moveRelRequested = Signal(int, float)
|
moveRelRequested = Signal(int, float)
|
||||||
homeRequested = Signal(int)
|
homeRequested = Signal(int)
|
||||||
clearAlarmRequested = Signal(int)
|
clearAlarmRequested = Signal(int)
|
||||||
|
#: The operator wants the unit settings (shown and changed in the device setup).
|
||||||
|
unitsRequested = Signal()
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -133,6 +142,8 @@ class AxisPanel(QGroupBox):
|
|||||||
self._selected_axis = 0
|
self._selected_axis = 0
|
||||||
self._snapshot = Snapshot()
|
self._snapshot = Snapshot()
|
||||||
self._rows: list[dict] = []
|
self._rows: list[dict] = []
|
||||||
|
self._tracker = AxisEventTracker()
|
||||||
|
self._unit_rows: list[UnitRow] = []
|
||||||
|
|
||||||
outer = QVBoxLayout(self)
|
outer = QVBoxLayout(self)
|
||||||
outer.setContentsMargins(10, 8, 10, 10)
|
outer.setContentsMargins(10, 8, 10, 10)
|
||||||
@@ -140,11 +151,12 @@ class AxisPanel(QGroupBox):
|
|||||||
|
|
||||||
outer.addWidget(self._build_params())
|
outer.addWidget(self._build_params())
|
||||||
outer.addWidget(self._build_target_row())
|
outer.addWidget(self._build_target_row())
|
||||||
# Stretch factor 1: the table is the only part of this panel that may
|
# The table is exactly as tall as its axis rows: the spare height belongs to
|
||||||
# grow, so every spare pixel goes to a further axis row rather than to
|
# the event log below, which is where an operator looks after something went
|
||||||
# padding around the parameter form.
|
# wrong, and not to blank rows nobody can use.
|
||||||
outer.addWidget(self._build_table(), 1)
|
outer.addWidget(self._build_table())
|
||||||
outer.addWidget(self._build_actions())
|
outer.addWidget(self._build_actions())
|
||||||
|
outer.addWidget(self._build_events(), 1)
|
||||||
|
|
||||||
self.retranslate()
|
self.retranslate()
|
||||||
self._reread_params()
|
self._reread_params()
|
||||||
@@ -181,6 +193,10 @@ class AxisPanel(QGroupBox):
|
|||||||
|
|
||||||
self.units_label = label("axes.units")
|
self.units_label = label("axes.units")
|
||||||
self.units_edit = edit(80)
|
self.units_edit = edit(80)
|
||||||
|
# Units are shown here and changed in the device setup, where they are locked
|
||||||
|
# until the operator asks to modify them: a pulse equivalent typed into a form
|
||||||
|
# that is always live rescales every position the machine is told about.
|
||||||
|
self.units_edit.setReadOnly(True)
|
||||||
self.speed_label = label("axes.speed")
|
self.speed_label = label("axes.speed")
|
||||||
self.speed_edit = edit(80)
|
self.speed_edit = edit(80)
|
||||||
self.accel_label = label("axes.accel")
|
self.accel_label = label("axes.accel")
|
||||||
@@ -211,6 +227,12 @@ class AxisPanel(QGroupBox):
|
|||||||
lambda: self.clearAlarmRequested.emit(self.selected_axis)
|
lambda: self.clearAlarmRequested.emit(self.selected_axis)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.units_button = QPushButton(box)
|
||||||
|
self.units_button.setObjectName("axisUnitsOpen")
|
||||||
|
self.units_button.setProperty("rail", True)
|
||||||
|
self.units_button.setFixedHeight(FIELD_HEIGHT)
|
||||||
|
self.units_button.clicked.connect(self.unitsRequested)
|
||||||
|
|
||||||
grid.addWidget(self.axis_label, 0, 0)
|
grid.addWidget(self.axis_label, 0, 0)
|
||||||
grid.addWidget(self.axis_index_label, 0, 1)
|
grid.addWidget(self.axis_index_label, 0, 1)
|
||||||
grid.addWidget(self.units_label, 0, 2)
|
grid.addWidget(self.units_label, 0, 2)
|
||||||
@@ -232,6 +254,7 @@ class AxisPanel(QGroupBox):
|
|||||||
grid.addWidget(self.apply_button, 2, 0, 1, 2)
|
grid.addWidget(self.apply_button, 2, 0, 1, 2)
|
||||||
grid.addWidget(self.home_button, 2, 2, 1, 2)
|
grid.addWidget(self.home_button, 2, 2, 1, 2)
|
||||||
grid.addWidget(self.clear_alarm_button, 2, 4, 1, 2)
|
grid.addWidget(self.clear_alarm_button, 2, 4, 1, 2)
|
||||||
|
grid.addWidget(self.units_button, 2, 6, 1, 2)
|
||||||
return box
|
return box
|
||||||
|
|
||||||
def _build_target_row(self) -> QWidget:
|
def _build_target_row(self) -> QWidget:
|
||||||
@@ -275,6 +298,7 @@ class AxisPanel(QGroupBox):
|
|||||||
# and the splitter then clips the last drive — which reads as a missing
|
# and the splitter then clips the last drive — which reads as a missing
|
||||||
# axis. The table scrolls if it is ever squeezed below this.
|
# axis. The table scrolls if it is ever squeezed below this.
|
||||||
self.table.setMinimumHeight(40 + ROW_HEIGHT * MIN_VISIBLE_ROWS)
|
self.table.setMinimumHeight(40 + ROW_HEIGHT * MIN_VISIBLE_ROWS)
|
||||||
|
self.table.setMaximumHeight(40 + ROW_HEIGHT * max(self.axis_count, MIN_VISIBLE_ROWS) + 4)
|
||||||
self.table.cellClicked.connect(self._on_cell_clicked)
|
self.table.cellClicked.connect(self._on_cell_clicked)
|
||||||
|
|
||||||
for row in range(self.axis_count):
|
for row in range(self.axis_count):
|
||||||
@@ -347,6 +371,97 @@ class AxisPanel(QGroupBox):
|
|||||||
column.addLayout(row)
|
column.addLayout(row)
|
||||||
return box
|
return box
|
||||||
|
|
||||||
|
def _build_events(self) -> QWidget:
|
||||||
|
box = QWidget(self)
|
||||||
|
column = QVBoxLayout(box)
|
||||||
|
column.setContentsMargins(0, 0, 0, 0)
|
||||||
|
column.setSpacing(4)
|
||||||
|
header = QHBoxLayout()
|
||||||
|
header.setSpacing(8)
|
||||||
|
self.events_title = QLabel(box)
|
||||||
|
self.events_title.setObjectName("statusValue")
|
||||||
|
self.units_chip = QLabel(box)
|
||||||
|
self.units_chip.setObjectName("hintLabel")
|
||||||
|
self.units_chip.setWordWrap(True)
|
||||||
|
self.clear_events_button = QPushButton(box)
|
||||||
|
self.clear_events_button.setObjectName("axisEventsClear")
|
||||||
|
self.clear_events_button.setProperty("rail", True)
|
||||||
|
self.clear_events_button.setFixedHeight(FIELD_HEIGHT)
|
||||||
|
self.clear_events_button.clicked.connect(self._on_clear_events)
|
||||||
|
header.addWidget(self.events_title)
|
||||||
|
header.addWidget(self.units_chip, 1)
|
||||||
|
header.addWidget(self.clear_events_button)
|
||||||
|
column.addLayout(header)
|
||||||
|
self.event_log = QListWidget(box)
|
||||||
|
self.event_log.setObjectName("axisEventLog")
|
||||||
|
self.event_log.setSelectionMode(QListWidget.SelectionMode.NoSelection)
|
||||||
|
self.event_log.setAlternatingRowColors(True)
|
||||||
|
self.event_log.setMinimumHeight(90)
|
||||||
|
column.addWidget(self.event_log, 1)
|
||||||
|
self.events_empty = QLabel(box)
|
||||||
|
self.events_empty.setObjectName("hintLabel")
|
||||||
|
self.events_empty.setWordWrap(True)
|
||||||
|
column.addWidget(self.events_empty)
|
||||||
|
return box
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ events
|
||||||
|
|
||||||
|
def _append_event(self, event: AxisEvent) -> None:
|
||||||
|
stamp = time.strftime("%H:%M:%S", time.localtime(event.time))
|
||||||
|
item = QListWidgetItem(f"{stamp} {event_text(event)}")
|
||||||
|
colour = _EVENT_COLOURS.get(event.severity)
|
||||||
|
if colour:
|
||||||
|
item.setForeground(QBrush(QColor(colour)))
|
||||||
|
self.event_log.insertItem(0, item)
|
||||||
|
while self.event_log.count() > self._tracker.limit:
|
||||||
|
self.event_log.takeItem(self.event_log.count() - 1)
|
||||||
|
|
||||||
|
def _render_events(self) -> None:
|
||||||
|
"""Rebuild the list from the tracker (language change, clear)."""
|
||||||
|
self.event_log.clear()
|
||||||
|
for event in self._tracker.events:
|
||||||
|
self._append_event(event)
|
||||||
|
self.events_empty.setVisible(self.event_log.count() == 0)
|
||||||
|
|
||||||
|
def _on_clear_events(self) -> None:
|
||||||
|
self._tracker.clear()
|
||||||
|
self._render_events()
|
||||||
|
|
||||||
|
def set_unit_rows(self, rows: Iterable[UnitRow], connected: bool) -> None:
|
||||||
|
"""One line about the unit check; the full table lives in the device setup."""
|
||||||
|
self._unit_rows = list(rows)
|
||||||
|
if not self._unit_rows:
|
||||||
|
self.units_chip.setText("")
|
||||||
|
return
|
||||||
|
_good, total, problems = summarise(self._unit_rows)
|
||||||
|
if not problems:
|
||||||
|
self.units_chip.setText(t("units.summary_ok", total=total))
|
||||||
|
elif all(row.status == UNREAD for row in problems):
|
||||||
|
self.units_chip.setText(t("units.summary_unread"))
|
||||||
|
else:
|
||||||
|
self.units_chip.setText(
|
||||||
|
t("units.summary_bad", bad=len(problems), total=total)
|
||||||
|
)
|
||||||
|
|
||||||
|
def apply_units(self, changes: Mapping[int, float]) -> None:
|
||||||
|
"""Send confirmed unit changes through the ordinary axis-parameter path."""
|
||||||
|
for axis, value in sorted(changes.items()):
|
||||||
|
if not 0 <= int(axis) < self.axis_count:
|
||||||
|
continue
|
||||||
|
params = replace(self._params.get(int(axis), AxisParams()), units=float(value))
|
||||||
|
self._params[int(axis)] = params
|
||||||
|
self.paramsApplied.emit(int(axis), params)
|
||||||
|
self._reread_params()
|
||||||
|
|
||||||
|
def expected_units(self, axis: int) -> float:
|
||||||
|
"""The pulse equivalent configured for a drive."""
|
||||||
|
return self._params.get(int(axis), AxisParams()).units
|
||||||
|
|
||||||
|
@property
|
||||||
|
def snapshot(self) -> Snapshot:
|
||||||
|
"""The last poll the panel rendered."""
|
||||||
|
return self._snapshot
|
||||||
|
|
||||||
# ------------------------------------------------------------------- state
|
# ------------------------------------------------------------------- state
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -372,7 +487,20 @@ class AxisPanel(QGroupBox):
|
|||||||
self._recolour_rows()
|
self._recolour_rows()
|
||||||
|
|
||||||
def _axis_present(self, row: int) -> bool:
|
def _axis_present(self, row: int) -> bool:
|
||||||
return not self._available_axes or row < self._available_axes
|
"""Rows the bus counted, plus any axis the controller has already mapped.
|
||||||
|
|
||||||
|
``Bus_TotalAxisnum`` is a global left by the last scan. It can stay
|
||||||
|
short of the real axis list after a drive is brought back without
|
||||||
|
another scan, which disabled axis 4 while that same controller axis
|
||||||
|
still answered and could be moved from the vendor software. A non-zero
|
||||||
|
axis type is that answer. Axes 0-3 already pass the count check; axis
|
||||||
|
4 passes this one once it is mapped. An unmapped axis (type 0) stays
|
||||||
|
disabled, and none of this writes parameters or starts motion.
|
||||||
|
"""
|
||||||
|
if not self._available_axes or row < self._available_axes:
|
||||||
|
return True
|
||||||
|
state = self._snapshot.axis(row)
|
||||||
|
return state is not None and state.read_ok and state.atype != 0
|
||||||
|
|
||||||
def _refresh_availability(self) -> None:
|
def _refresh_availability(self) -> None:
|
||||||
"""Recompute every control's enabled state from connection + bus."""
|
"""Recompute every control's enabled state from connection + bus."""
|
||||||
@@ -460,6 +588,10 @@ class AxisPanel(QGroupBox):
|
|||||||
)
|
)
|
||||||
self._refresh_availability()
|
self._refresh_availability()
|
||||||
self._recolour_rows()
|
self._recolour_rows()
|
||||||
|
expected = {axis: self.expected_units(axis) for axis in range(self.axis_count)}
|
||||||
|
for event in self._tracker.update(snapshot, expected):
|
||||||
|
self._append_event(event)
|
||||||
|
self.events_empty.setVisible(self.event_log.count() == 0)
|
||||||
|
|
||||||
def _detail_text(self) -> str:
|
def _detail_text(self) -> str:
|
||||||
"""Everything about the selected axis that the seven columns cannot hold.
|
"""Everything about the selected axis that the seven columns cannot hold.
|
||||||
@@ -558,6 +690,12 @@ class AxisPanel(QGroupBox):
|
|||||||
self.move_rel_button.setText(t("axes.move_rel"))
|
self.move_rel_button.setText(t("axes.move_rel"))
|
||||||
self.stop_all_button.setText(t("axes.stop_all"))
|
self.stop_all_button.setText(t("axes.stop_all"))
|
||||||
self.mm_hint.setText(t("axes.mm_derived_tip"))
|
self.mm_hint.setText(t("axes.mm_derived_tip"))
|
||||||
|
self.units_button.setText(t("axes.units_open"))
|
||||||
|
self.units_edit.setToolTip(t("axes.units_locked_tip"))
|
||||||
|
self.events_title.setText(t("axes.events_title"))
|
||||||
|
self.clear_events_button.setText(t("axes.events_clear"))
|
||||||
|
self.events_empty.setText(t("axes.events_empty"))
|
||||||
|
self._render_events()
|
||||||
if hasattr(self, "_detail_label"):
|
if hasattr(self, "_detail_label"):
|
||||||
self._detail_label.setText(self._detail_text())
|
self._detail_label.setText(self._detail_text())
|
||||||
self.table.setHorizontalHeaderLabels(
|
self.table.setHorizontalHeaderLabels(
|
||||||
@@ -571,6 +709,19 @@ class AxisPanel(QGroupBox):
|
|||||||
self._rows[row]["stop_button"].setText(t("axes.stop"))
|
self._rows[row]["stop_button"].setText(t("axes.stop"))
|
||||||
|
|
||||||
|
|
||||||
|
#: Line colours for the event log. Info lines keep the theme's own text colour.
|
||||||
|
_EVENT_COLOURS = {"error": "#c62828", "warning": "#b26a00"}
|
||||||
|
|
||||||
|
|
||||||
|
def event_text(event: AxisEvent) -> str:
|
||||||
|
"""One event as a sentence in the current language."""
|
||||||
|
fields: dict[str, object] = {"axis": event.axis, "detail": event.detail}
|
||||||
|
if event.kind == UNITS_MISMATCH:
|
||||||
|
expected, _, observed = event.detail.partition("|")
|
||||||
|
fields.update(expected=expected or "—", observed=observed or "—")
|
||||||
|
return t(f"axes.ev_{event.kind}", **fields)
|
||||||
|
|
||||||
|
|
||||||
def _number(value: float, places: int) -> str:
|
def _number(value: float, places: int) -> str:
|
||||||
try:
|
try:
|
||||||
return f"{float(value):.{places}f}"
|
return f"{float(value):.{places}f}"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,491 @@
|
|||||||
|
"""The capture page's settings dialog: input source, bundle, session and recording.
|
||||||
|
|
||||||
|
The capture page itself shows the workflow; everything that is decided once per
|
||||||
|
session lives here, so that starting a capture cannot switch pages or hide the
|
||||||
|
live image behind a configuration form.
|
||||||
|
|
||||||
|
Three things this dialog owns beyond plain fields:
|
||||||
|
|
||||||
|
* **The codec probe.** Recording candidates are written and read back once per
|
||||||
|
process before they are offered. A codec this machine cannot open is disabled
|
||||||
|
with its reason shown, never silently replaced — a recording made under a
|
||||||
|
different codec than the session manifest claims is not the evidence it says it
|
||||||
|
is.
|
||||||
|
* **The size estimate.** Bitrate times the planned duration is an estimate (the
|
||||||
|
module says so wherever it is displayed) and the free space beside it is real;
|
||||||
|
when less than twice the estimate is free the dialog warns before anything is
|
||||||
|
recorded.
|
||||||
|
* **The bundle.** Loading one rasterises the finger geometry; the page keeps that
|
||||||
|
work, the button here only asks for it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from orisys_app_shared.paths import outputs_dir
|
||||||
|
from PySide6.QtCore import Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QFileDialog,
|
||||||
|
QGridLayout,
|
||||||
|
QGroupBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPushButton,
|
||||||
|
QScrollArea,
|
||||||
|
QSizePolicy,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..recording_options import CODEC_CANDIDATES, RecordingOptions, probe_codecs
|
||||||
|
from ..sequence_io import format_duration
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: Product models a session can be recorded under. The stored value is this id.
|
||||||
|
PRODUCT_MODELS: tuple[str, ...] = ("finger",)
|
||||||
|
|
||||||
|
#: Measured video bitrates used only for the volume estimate. The plan's baseline
|
||||||
|
#: measured about 27 Mbit/s for ``mp4v`` on this view and about 5 Mbit/s for the
|
||||||
|
#: H.264 sample; the slider is not an encoder parameter, so the number is labelled
|
||||||
|
#: an estimate wherever it is shown.
|
||||||
|
CODEC_MBIT_PER_S: dict[str, float] = {"mp4v": 27.0, "avc1": 5.0, "H264": 5.0}
|
||||||
|
DEFAULT_MBIT_PER_S = 10.0
|
||||||
|
|
||||||
|
#: Planning allowance per hold: load, settle, confirm and unload around the hold
|
||||||
|
#: itself. It only shapes the size estimate, never a recorded window.
|
||||||
|
STEP_OVERHEAD_S = 5.0
|
||||||
|
|
||||||
|
#: Qt's "no limit" for a widget's height, and the tallest preview this dialog shows.
|
||||||
|
_UNBOUNDED = 16777215
|
||||||
|
_PREVIEW_MAX_HEIGHT = 200
|
||||||
|
|
||||||
|
#: One probe per process. The probe writes three real clips, and every window in
|
||||||
|
#: this app would otherwise repeat it; the answer cannot change while the process
|
||||||
|
#: runs because it is a property of this machine's OpenCV build.
|
||||||
|
_PROBE_CACHE: dict[str, dict[str, bool]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def probe_once(scratch: Path | None = None) -> dict[str, bool]:
|
||||||
|
"""The codec availability for this machine, probed at most once per process."""
|
||||||
|
directory = Path(scratch) if scratch is not None else Path(outputs_dir()) / "codec_probe"
|
||||||
|
key = str(directory.resolve())
|
||||||
|
if key not in _PROBE_CACHE:
|
||||||
|
try:
|
||||||
|
_PROBE_CACHE[key] = dict(probe_codecs(directory))
|
||||||
|
except Exception as exc: # noqa: BLE001 - a probe is external and must not crash start-up
|
||||||
|
logger.exception("Codec probe failed: %s", exc)
|
||||||
|
_PROBE_CACHE[key] = {}
|
||||||
|
return dict(_PROBE_CACHE[key])
|
||||||
|
|
||||||
|
|
||||||
|
def format_size(value: float) -> str:
|
||||||
|
"""A byte count in the unit an operator thinks in."""
|
||||||
|
number = float(value)
|
||||||
|
for unit, scale in (("GB", 1e9), ("MB", 1e6), ("kB", 1e3)):
|
||||||
|
if number >= scale:
|
||||||
|
return f"{number / scale:.2f} {unit}"
|
||||||
|
return f"{number:.0f} B"
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureSettingsDialog(QDialog):
|
||||||
|
"""Everything decided once per session, in one place."""
|
||||||
|
|
||||||
|
#: The operator asked for a bundle; the page (and the window) own the dialog.
|
||||||
|
bundleRequested = Signal()
|
||||||
|
#: A recorded option changed and should be remembered in the preferences.
|
||||||
|
recordingChanged = Signal()
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
source_panel: QWidget,
|
||||||
|
prefs: dict,
|
||||||
|
parent: QWidget | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle(t("cap.settings.title"))
|
||||||
|
self.resize(720, 700)
|
||||||
|
self.setModal(True)
|
||||||
|
self.prefs = prefs
|
||||||
|
self.source_panel = source_panel
|
||||||
|
self._availability: dict[str, bool] | None = None
|
||||||
|
self._probe_error = ""
|
||||||
|
self._planned_seconds = 0.0
|
||||||
|
self._locked = False
|
||||||
|
|
||||||
|
outer = QVBoxLayout(self)
|
||||||
|
scroll = QScrollArea(self)
|
||||||
|
scroll.setWidgetResizable(True)
|
||||||
|
page = QWidget(scroll)
|
||||||
|
layout = QVBoxLayout(page)
|
||||||
|
layout.setSpacing(10)
|
||||||
|
scroll.setWidget(page)
|
||||||
|
outer.addWidget(scroll, 1)
|
||||||
|
|
||||||
|
self.input_group = QGroupBox(page)
|
||||||
|
self.input_group.setObjectName("groupBox")
|
||||||
|
input_layout = QVBoxLayout(self.input_group)
|
||||||
|
# The input panel is the live image plus its controls. The preview is what
|
||||||
|
# is bounded here: the dialog has four sections, and a preview that expands
|
||||||
|
# would push the bundle, session and recording settings below the fold. The
|
||||||
|
# panel itself is never capped. Its field rows, buttons and hints need
|
||||||
|
# their natural height, and a cap below that squeezes them into each other.
|
||||||
|
source_panel.setMaximumHeight(_UNBOUNDED)
|
||||||
|
source_panel.preview.setMinimumSize(140, 90)
|
||||||
|
source_panel.preview.setMaximumHeight(_PREVIEW_MAX_HEIGHT)
|
||||||
|
input_layout.addWidget(source_panel)
|
||||||
|
self.input_group.setSizePolicy(
|
||||||
|
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum
|
||||||
|
)
|
||||||
|
layout.addWidget(self.input_group)
|
||||||
|
|
||||||
|
self.bundle_group = QGroupBox(page)
|
||||||
|
self.bundle_group.setObjectName("groupBox")
|
||||||
|
bundle_row = QHBoxLayout(self.bundle_group)
|
||||||
|
self.bundle_label = QLabel(t("cap.settings.bundle_none"), self.bundle_group)
|
||||||
|
self.bundle_label.setWordWrap(True)
|
||||||
|
self.bundle_label.setObjectName("hintText")
|
||||||
|
self.bundle_button = QPushButton(self.bundle_group)
|
||||||
|
self.bundle_button.setObjectName("captureLoadBundle")
|
||||||
|
self.bundle_button.clicked.connect(self.bundleRequested)
|
||||||
|
bundle_row.addWidget(self.bundle_label, 1)
|
||||||
|
bundle_row.addWidget(self.bundle_button)
|
||||||
|
layout.addWidget(self.bundle_group)
|
||||||
|
|
||||||
|
self.session_group = QGroupBox(page)
|
||||||
|
self.session_group.setObjectName("groupBox")
|
||||||
|
session_grid = QGridLayout(self.session_group)
|
||||||
|
session_grid.setHorizontalSpacing(12)
|
||||||
|
session_grid.setVerticalSpacing(8)
|
||||||
|
self.model_label = QLabel(self.session_group)
|
||||||
|
self.sensor_model = QComboBox(self.session_group)
|
||||||
|
self.sensor_model.setObjectName("captureSensorModel")
|
||||||
|
self.sensor_model.addItems(list(PRODUCT_MODELS))
|
||||||
|
self.sn_label = QLabel(self.session_group)
|
||||||
|
self.sensor_sn = QLineEdit(self.session_group)
|
||||||
|
self.sensor_sn.setObjectName("captureSensorSn")
|
||||||
|
self.output_label = QLabel(self.session_group)
|
||||||
|
self.output_path = QLineEdit(str(Path(outputs_dir()) / "calibrations"), self.session_group)
|
||||||
|
self.output_path.setObjectName("captureOutputPath")
|
||||||
|
self.output_browse = QPushButton(self.session_group)
|
||||||
|
self.frame_label = QLabel(self.session_group)
|
||||||
|
self.frame = QLineEdit("sensor", self.session_group)
|
||||||
|
self.frame.setObjectName("captureCoordinateFrame")
|
||||||
|
self.max_force_label = QLabel(self.session_group)
|
||||||
|
self.max_force = QLineEdit(self.session_group)
|
||||||
|
self.max_force.setObjectName("captureMaxForce")
|
||||||
|
self.min_force_label = QLabel(self.session_group)
|
||||||
|
self.min_force = QLineEdit(self.session_group)
|
||||||
|
self.min_force.setObjectName("captureMinForce")
|
||||||
|
for row, (label, field) in enumerate(
|
||||||
|
(
|
||||||
|
(self.model_label, self.sensor_model),
|
||||||
|
(self.sn_label, self.sensor_sn),
|
||||||
|
(self.output_label, self.output_path),
|
||||||
|
(self.frame_label, self.frame),
|
||||||
|
(self.max_force_label, self.max_force),
|
||||||
|
(self.min_force_label, self.min_force),
|
||||||
|
)
|
||||||
|
):
|
||||||
|
session_grid.addWidget(label, row, 0)
|
||||||
|
session_grid.addWidget(field, row, 1)
|
||||||
|
session_grid.addWidget(self.output_browse, 2, 2)
|
||||||
|
self.simulate = QCheckBox(self.session_group)
|
||||||
|
self.simulate.setObjectName("captureSimulate")
|
||||||
|
session_grid.addWidget(self.simulate, 6, 0, 1, 3)
|
||||||
|
layout.addWidget(self.session_group)
|
||||||
|
|
||||||
|
self.recording_group = QGroupBox(page)
|
||||||
|
self.recording_group.setObjectName("groupBox")
|
||||||
|
recording_layout = QGridLayout(self.recording_group)
|
||||||
|
recording_layout.setHorizontalSpacing(12)
|
||||||
|
recording_layout.setVerticalSpacing(6)
|
||||||
|
self.video_check = QCheckBox(self.recording_group)
|
||||||
|
self.video_check.setObjectName("captureRecordVideo")
|
||||||
|
self.codec_label = QLabel(self.recording_group)
|
||||||
|
self.codec = QComboBox(self.recording_group)
|
||||||
|
self.codec.setObjectName("captureCodec")
|
||||||
|
self.lossless_check = QCheckBox(self.recording_group)
|
||||||
|
self.lossless_check.setObjectName("captureLossless")
|
||||||
|
self.codec_reason = QLabel(self.recording_group)
|
||||||
|
self.codec_reason.setWordWrap(True)
|
||||||
|
self.codec_reason.setObjectName("hintText")
|
||||||
|
self.estimate = QLabel(self.recording_group)
|
||||||
|
self.estimate.setWordWrap(True)
|
||||||
|
self.estimate.setObjectName("hintText")
|
||||||
|
self.locked_label = QLabel(self.recording_group)
|
||||||
|
self.locked_label.setWordWrap(True)
|
||||||
|
self.locked_label.setObjectName("hintText")
|
||||||
|
recording_layout.addWidget(self.video_check, 0, 0, 1, 2)
|
||||||
|
recording_layout.addWidget(self.codec_label, 1, 0)
|
||||||
|
recording_layout.addWidget(self.codec, 1, 1)
|
||||||
|
recording_layout.addWidget(self.lossless_check, 2, 0, 1, 2)
|
||||||
|
recording_layout.addWidget(self.codec_reason, 3, 0, 1, 2)
|
||||||
|
recording_layout.addWidget(self.estimate, 4, 0, 1, 2)
|
||||||
|
recording_layout.addWidget(self.locked_label, 5, 0, 1, 2)
|
||||||
|
layout.addWidget(self.recording_group)
|
||||||
|
layout.addStretch(1)
|
||||||
|
|
||||||
|
self.buttons = QDialogButtonBox(self)
|
||||||
|
self.close_button = self.buttons.addButton(
|
||||||
|
t("cap.settings.close"), QDialogButtonBox.ButtonRole.AcceptRole
|
||||||
|
)
|
||||||
|
self.close_button.setObjectName("captureSettingsClose")
|
||||||
|
outer.addWidget(self.buttons)
|
||||||
|
|
||||||
|
self.close_button.clicked.connect(self.close)
|
||||||
|
self.output_browse.clicked.connect(self._choose_output)
|
||||||
|
self.bundle_button.clicked.connect(self._choose_bundle)
|
||||||
|
self.video_check.toggled.connect(self._on_recording_changed)
|
||||||
|
self.codec.currentIndexChanged.connect(self._on_recording_changed)
|
||||||
|
self.lossless_check.toggled.connect(self._on_recording_changed)
|
||||||
|
self.output_path.textChanged.connect(self._refresh_estimate)
|
||||||
|
self.sensor_sn.textChanged.connect(self._refresh_estimate)
|
||||||
|
self.simulate.toggled.connect(self._refresh_estimate)
|
||||||
|
self.set_availability(probe_once(), error="")
|
||||||
|
self._load_preferences()
|
||||||
|
self.retranslate()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- preferences
|
||||||
|
|
||||||
|
def _load_preferences(self) -> None:
|
||||||
|
block = self.prefs.get("recording") if isinstance(self.prefs, dict) else None
|
||||||
|
self._apply_options(RecordingOptions.from_block(block))
|
||||||
|
|
||||||
|
def _remember(self) -> None:
|
||||||
|
if not isinstance(self.prefs, dict):
|
||||||
|
return
|
||||||
|
# Only the recording block is a preference: the sensor SN, the frame name
|
||||||
|
# and the maximum force belong to the session being set up, not to the
|
||||||
|
# operator's stored UI state, so they are not carried across restarts.
|
||||||
|
self.prefs["recording"] = self.recording_options().as_block()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ values
|
||||||
|
|
||||||
|
@property
|
||||||
|
def availability(self) -> dict[str, bool] | None:
|
||||||
|
"""The probed codec result the start path must honour (``None``: unprobed)."""
|
||||||
|
return None if self._availability is None else dict(self._availability)
|
||||||
|
|
||||||
|
def recording_options(self) -> RecordingOptions:
|
||||||
|
codec = self.codec.currentData()
|
||||||
|
return RecordingOptions(
|
||||||
|
video=self.video_check.isChecked(),
|
||||||
|
codec=str(codec or CODEC_CANDIDATES[0]),
|
||||||
|
lossless_frames=self.lossless_check.isChecked(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _apply_options(self, options: RecordingOptions) -> None:
|
||||||
|
self.video_check.setChecked(bool(options.video))
|
||||||
|
index = self.codec.findData(options.codec)
|
||||||
|
# A stored codec this machine cannot write must not become the selection:
|
||||||
|
# ``set_availability`` already fell back to one that works, and re-selecting
|
||||||
|
# the disabled entry would leave the operator pointing at a codec the start
|
||||||
|
# path is about to refuse.
|
||||||
|
if index >= 0 and self._codec_usable(index):
|
||||||
|
self.codec.setCurrentIndex(index)
|
||||||
|
self.lossless_check.setChecked(bool(options.lossless_frames))
|
||||||
|
|
||||||
|
def _codec_usable(self, index: int) -> bool:
|
||||||
|
item = self.codec.model().item(index)
|
||||||
|
return item is None or item.isEnabled()
|
||||||
|
|
||||||
|
def session_fields(self) -> dict:
|
||||||
|
"""The session parameters as the page reads them when it starts a session."""
|
||||||
|
return {
|
||||||
|
"sensor_model": self.sensor_model.currentText(),
|
||||||
|
"sensor_sn": self.sensor_sn.text().strip(),
|
||||||
|
"root": self.output_path.text().strip(),
|
||||||
|
"coordinate_frame": self.frame.text().strip(),
|
||||||
|
"max_force_n": self.max_force.text().strip(),
|
||||||
|
"min_force_n": self.min_force.text().strip(),
|
||||||
|
"simulate": self.simulate.isChecked(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- probe
|
||||||
|
|
||||||
|
def set_availability(self, availability: dict[str, bool] | None, *, error: str = "") -> None:
|
||||||
|
"""Offer only the codecs this machine can actually encode with."""
|
||||||
|
self._availability = None if availability is None else dict(availability)
|
||||||
|
self._probe_error = str(error or "")
|
||||||
|
self.codec.blockSignals(True)
|
||||||
|
try:
|
||||||
|
self.codec.clear()
|
||||||
|
for codec in CODEC_CANDIDATES:
|
||||||
|
label = codec
|
||||||
|
available = True
|
||||||
|
if self._availability is not None:
|
||||||
|
available = self._availability.get(codec) is True
|
||||||
|
if not available:
|
||||||
|
label = t("cap.recording.unavailable", codec=codec)
|
||||||
|
self.codec.addItem(label, codec)
|
||||||
|
item = self.codec.model().item(self.codec.count() - 1)
|
||||||
|
if item is not None:
|
||||||
|
item.setEnabled(available)
|
||||||
|
wanted = str(self._stored_codec() or CODEC_CANDIDATES[0])
|
||||||
|
index = self.codec.findData(wanted)
|
||||||
|
if index < 0:
|
||||||
|
index = 0
|
||||||
|
self.codec.setCurrentIndex(index)
|
||||||
|
# The stored codec can be one this machine no longer opens. Offer the
|
||||||
|
# first available one instead, and keep the reason above visible: the
|
||||||
|
# operator must not be left with a disabled selection and no way out.
|
||||||
|
for candidate in range(self.codec.count()):
|
||||||
|
item = self.codec.model().item(candidate)
|
||||||
|
if item is not None and item.isEnabled():
|
||||||
|
self.codec.setCurrentIndex(candidate)
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
self.codec.blockSignals(False)
|
||||||
|
self.codec.setToolTip(
|
||||||
|
"\n".join(
|
||||||
|
t("cap.recording.unavailable", codec=codec)
|
||||||
|
for codec, ok in (self._availability or {}).items()
|
||||||
|
if not ok
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._refresh_estimate()
|
||||||
|
|
||||||
|
def _stored_codec(self) -> str:
|
||||||
|
block = self.prefs.get("recording") if isinstance(self.prefs, dict) else None
|
||||||
|
if isinstance(block, dict) and isinstance(block.get("codec"), str):
|
||||||
|
return block["codec"]
|
||||||
|
return CODEC_CANDIDATES[0]
|
||||||
|
|
||||||
|
def _on_recording_changed(self) -> None:
|
||||||
|
self._remember()
|
||||||
|
self._refresh_estimate()
|
||||||
|
self.recordingChanged.emit()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ estimate
|
||||||
|
|
||||||
|
def set_plan(self, seconds: float) -> None:
|
||||||
|
"""How long the queue is planned to record for, in seconds."""
|
||||||
|
self._planned_seconds = max(0.0, float(seconds))
|
||||||
|
self._refresh_estimate()
|
||||||
|
|
||||||
|
def _disk_root(self) -> Path:
|
||||||
|
text = self.output_path.text().strip()
|
||||||
|
candidate = Path(text) if text else Path.cwd()
|
||||||
|
while not candidate.exists() and candidate != candidate.parent:
|
||||||
|
candidate = candidate.parent
|
||||||
|
return candidate if candidate.exists() else Path.cwd()
|
||||||
|
|
||||||
|
def _refresh_estimate(self) -> None:
|
||||||
|
options = self.recording_options()
|
||||||
|
if not options.video:
|
||||||
|
text = t("cap.recording.off")
|
||||||
|
if options.lossless_frames:
|
||||||
|
text += "\n" + t("cap.recording.lossless")
|
||||||
|
self.estimate.setText(text)
|
||||||
|
return
|
||||||
|
if self._planned_seconds <= 0:
|
||||||
|
self.estimate.setText(t("cap.recording.no_plan"))
|
||||||
|
return
|
||||||
|
codec = str(options.codec)
|
||||||
|
mbit = CODEC_MBIT_PER_S.get(codec, DEFAULT_MBIT_PER_S)
|
||||||
|
size = mbit * 1e6 / 8.0 * self._planned_seconds
|
||||||
|
lines = []
|
||||||
|
try:
|
||||||
|
free = shutil.disk_usage(self._disk_root()).free
|
||||||
|
except OSError:
|
||||||
|
free = None
|
||||||
|
if free is None:
|
||||||
|
lines.append(
|
||||||
|
t("cap.recording.estimate", size=format_size(size),
|
||||||
|
seconds=format_duration(self._planned_seconds), free="—")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append(
|
||||||
|
t("cap.recording.estimate", size=format_size(size),
|
||||||
|
seconds=format_duration(self._planned_seconds), free=format_size(free))
|
||||||
|
)
|
||||||
|
if free < 2.0 * size:
|
||||||
|
lines.append(t("cap.recording.disk_short"))
|
||||||
|
self.estimate.setText("\n".join(lines))
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- state
|
||||||
|
|
||||||
|
def set_locked(self, locked: bool) -> None:
|
||||||
|
"""A started session locks its recording and session parameters."""
|
||||||
|
if bool(locked) == self._locked and self.locked_label.isVisible() == self._locked:
|
||||||
|
return
|
||||||
|
self._locked = bool(locked)
|
||||||
|
for widget in (
|
||||||
|
self.input_group, self.bundle_button, self.session_group,
|
||||||
|
self.sensor_model, self.sensor_sn, self.output_path, self.output_browse,
|
||||||
|
self.frame, self.max_force, self.min_force, self.simulate,
|
||||||
|
self.video_check, self.codec, self.lossless_check,
|
||||||
|
):
|
||||||
|
widget.setEnabled(not self._locked)
|
||||||
|
self.locked_label.setText(t("cap.recording.locked") if self._locked else "")
|
||||||
|
self.locked_label.setVisible(self._locked)
|
||||||
|
|
||||||
|
def set_bundle(self, path: str) -> None:
|
||||||
|
text = str(path or "").strip()
|
||||||
|
self.bundle_label.setText(text or t("cap.settings.bundle_none"))
|
||||||
|
self.bundle_label.setToolTip(text)
|
||||||
|
|
||||||
|
def refresh_sizes(self) -> None:
|
||||||
|
"""Re-read free space; opening the dialog again refreshes the estimate."""
|
||||||
|
self._refresh_estimate()
|
||||||
|
|
||||||
|
def _choose_output(self) -> None:
|
||||||
|
path = QFileDialog.getExistingDirectory(self, t("calibration.output"), self.output_path.text())
|
||||||
|
if path:
|
||||||
|
self.output_path.setText(path)
|
||||||
|
|
||||||
|
def _choose_bundle(self) -> None:
|
||||||
|
self.bundleRequested.emit()
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- translations
|
||||||
|
|
||||||
|
def retranslate(self) -> None:
|
||||||
|
self.setWindowTitle(t("cap.settings.title"))
|
||||||
|
self.input_group.setTitle(t("cap.settings.input"))
|
||||||
|
self.bundle_group.setTitle(t("cap.settings.bundle"))
|
||||||
|
self.bundle_button.setText(t("cap.settings.bundle_load"))
|
||||||
|
self.session_group.setTitle(t("cap.settings.session"))
|
||||||
|
self.model_label.setText(t("calibration.sensor_model"))
|
||||||
|
self.sn_label.setText(t("calibration.sensor_sn"))
|
||||||
|
self.output_label.setText(t("calibration.output"))
|
||||||
|
self.output_browse.setText(t("source.browse"))
|
||||||
|
self.frame_label.setText(t("multipoint.frame"))
|
||||||
|
self.max_force_label.setText(t("multipoint.max_force"))
|
||||||
|
self.max_force.setPlaceholderText(t("cap.settings.max_required"))
|
||||||
|
self.min_force_label.setText(t("multipoint.min_force"))
|
||||||
|
self.min_force.setPlaceholderText(t("multipoint.min_placeholder"))
|
||||||
|
self.simulate.setText(t("cap.settings.simulate"))
|
||||||
|
self.recording_group.setTitle(t("cap.recording.title"))
|
||||||
|
self.video_check.setText(t("cap.recording.video"))
|
||||||
|
self.codec_label.setText(t("cap.recording.codec"))
|
||||||
|
self.lossless_check.setText(t("cap.recording.lossless"))
|
||||||
|
if self._probe_error:
|
||||||
|
self.codec_reason.setText(t("cap.recording.probe_failed", detail=self._probe_error))
|
||||||
|
else:
|
||||||
|
self.codec_reason.setText(
|
||||||
|
"\n".join(
|
||||||
|
t("cap.recording.unavailable", codec=codec)
|
||||||
|
for codec, ok in (self._availability or {}).items()
|
||||||
|
if not ok
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.close_button.setText(t("cap.settings.close"))
|
||||||
|
self._refresh_estimate()
|
||||||
|
|
||||||
|
def showEvent(self, event) -> None: # noqa: N802 - Qt override
|
||||||
|
super().showEvent(event)
|
||||||
|
self._refresh_estimate()
|
||||||
|
|
||||||
|
def closeEvent(self, event) -> None: # noqa: N802 - Qt override
|
||||||
|
self._remember()
|
||||||
|
super().closeEvent(event)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Shared value axes for record and hold inspection charts."""
|
||||||
|
|
||||||
|
from PySide6.QtCharts import QValueAxis
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
|
||||||
|
def clear_chart(chart):
|
||||||
|
chart.removeAllSeries()
|
||||||
|
for axis in chart.axes():
|
||||||
|
axis.hide()
|
||||||
|
chart.removeAxis(axis)
|
||||||
|
axis.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def add_value_axes(chart):
|
||||||
|
points = [point for series in chart.series() for point in series.points()]
|
||||||
|
font = chart.font()
|
||||||
|
font.setPixelSize(14)
|
||||||
|
for horizontal in (True, False):
|
||||||
|
axis = QValueAxis()
|
||||||
|
axis.setLabelsFont(font)
|
||||||
|
axis.setTitleFont(font)
|
||||||
|
values = [p.x() if horizontal else p.y() for p in points]
|
||||||
|
if values:
|
||||||
|
low, high = min(values), max(values)
|
||||||
|
margin = max((high - low) * .1, .1)
|
||||||
|
axis.setRange(low - margin, high + margin)
|
||||||
|
chart.addAxis(axis, Qt.AlignmentFlag.AlignBottom if horizontal else Qt.AlignmentFlag.AlignLeft)
|
||||||
|
for series in chart.series():
|
||||||
|
series.attachAxis(axis)
|
||||||
@@ -26,9 +26,11 @@ from __future__ import annotations
|
|||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
from PySide6.QtCore import Signal
|
from PySide6.QtCore import Signal
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
|
QDoubleSpinBox,
|
||||||
QFormLayout,
|
QFormLayout,
|
||||||
QGroupBox,
|
QGroupBox,
|
||||||
QLabel,
|
QLabel,
|
||||||
@@ -41,13 +43,17 @@ from ..motion import ConnectionSpec
|
|||||||
from ..motion.service import AXIS_ROLES, DEFAULT_AXIS_ROLES, PLATFORM_AXES
|
from ..motion.service import AXIS_ROLES, DEFAULT_AXIS_ROLES, PLATFORM_AXES
|
||||||
from .bus_panel import BusPanel
|
from .bus_panel import BusPanel
|
||||||
from .connection_panel import ConnectionPanel
|
from .connection_panel import ConnectionPanel
|
||||||
|
from .units_page import UnitsPage
|
||||||
|
|
||||||
#: Tab identity -> locale key, in the order they are shown. The order is the work:
|
#: Tab identity -> locale key, in the order they are shown. The order is the work:
|
||||||
#: reach the machine, bring its bus up, then say what each drive moves.
|
#: reach the machine, bring its bus up, then say what each drive moves and how the
|
||||||
|
#: press axis's coordinate relates to the contact height the solver reports.
|
||||||
TABS: tuple[tuple[str, str], ...] = (
|
TABS: tuple[tuple[str, str], ...] = (
|
||||||
("connection", "setup.tab_connection"),
|
("connection", "setup.tab_connection"),
|
||||||
("bus", "setup.tab_bus"),
|
("bus", "setup.tab_bus"),
|
||||||
("axes", "setup.tab_axes"),
|
("axes", "setup.tab_axes"),
|
||||||
|
("press", "setup.tab_press"),
|
||||||
|
("units", "setup.tab_units"),
|
||||||
)
|
)
|
||||||
|
|
||||||
#: Role -> locale key for the mapping rows.
|
#: Role -> locale key for the mapping rows.
|
||||||
@@ -165,6 +171,116 @@ class AxisRolesPage(QWidget):
|
|||||||
self._refresh_problem(announce=False)
|
self._refresh_problem(announce=False)
|
||||||
|
|
||||||
|
|
||||||
|
class PressAxisPage(QWidget):
|
||||||
|
"""How the press axis's coordinate relates to the solved contact height.
|
||||||
|
|
||||||
|
A page of its own because it is a measurement on the machine, not a preference:
|
||||||
|
the solver reports millimetres of geometry above the table's origin plane, and
|
||||||
|
the drive is commanded in its own units from its own zero. Nothing in the model
|
||||||
|
can supply the difference, so the operator does — and until they do, the pose
|
||||||
|
move holds the press axis instead of driving it to a geometric number that was
|
||||||
|
never anchored to this machine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pressChanged = Signal()
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
press: object = None,
|
||||||
|
parent: QWidget | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
column = QVBoxLayout(self)
|
||||||
|
column.setContentsMargins(8, 8, 8, 8)
|
||||||
|
column.setSpacing(8)
|
||||||
|
|
||||||
|
self.hint_label = QLabel(self)
|
||||||
|
self.hint_label.setObjectName("hintLabel")
|
||||||
|
self.hint_label.setWordWrap(True)
|
||||||
|
column.addWidget(self.hint_label)
|
||||||
|
|
||||||
|
self.box = QGroupBox(self)
|
||||||
|
self.box.setObjectName("groupBox")
|
||||||
|
form = QFormLayout(self.box)
|
||||||
|
form.setContentsMargins(10, 8, 10, 10)
|
||||||
|
form.setSpacing(6)
|
||||||
|
|
||||||
|
self.zero_label = QLabel(self.box)
|
||||||
|
self.zero_spin = QDoubleSpinBox(self.box)
|
||||||
|
self.zero_spin.setDecimals(4)
|
||||||
|
self.zero_spin.setRange(-10000.0, 10000.0)
|
||||||
|
self.zero_spin.setSuffix(" mm")
|
||||||
|
self.zero_spin.valueChanged.connect(self._on_changed)
|
||||||
|
form.addRow(self.zero_label, self.zero_spin)
|
||||||
|
|
||||||
|
self.rising_check = QCheckBox(self.box)
|
||||||
|
self.rising_check.toggled.connect(self._on_changed)
|
||||||
|
form.addRow(self.rising_check)
|
||||||
|
|
||||||
|
self.calibrated_check = QCheckBox(self.box)
|
||||||
|
self.calibrated_check.toggled.connect(self._on_changed)
|
||||||
|
form.addRow(self.calibrated_check)
|
||||||
|
column.addWidget(self.box)
|
||||||
|
|
||||||
|
self.state_label = QLabel(self)
|
||||||
|
self.state_label.setObjectName("statusValue")
|
||||||
|
self.state_label.setWordWrap(True)
|
||||||
|
column.addWidget(self.state_label)
|
||||||
|
column.addStretch(1)
|
||||||
|
|
||||||
|
self.set_press(press)
|
||||||
|
self.retranslate()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- state
|
||||||
|
|
||||||
|
def set_press(self, press: object) -> None:
|
||||||
|
"""Show the stored relation without announcing a change nobody made."""
|
||||||
|
zero = getattr(press, "zero_mm", 0.0)
|
||||||
|
rising = getattr(press, "rising", True)
|
||||||
|
calibrated = getattr(press, "calibrated", False)
|
||||||
|
for widget, value in (
|
||||||
|
(self.zero_spin, float(zero)),
|
||||||
|
(self.rising_check, bool(rising)),
|
||||||
|
(self.calibrated_check, bool(calibrated)),
|
||||||
|
):
|
||||||
|
widget.blockSignals(True)
|
||||||
|
try:
|
||||||
|
if widget is self.zero_spin:
|
||||||
|
widget.setValue(value)
|
||||||
|
else:
|
||||||
|
widget.setChecked(value)
|
||||||
|
finally:
|
||||||
|
widget.blockSignals(False)
|
||||||
|
self._refresh_state(announce=False)
|
||||||
|
|
||||||
|
def press(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"zero_mm": float(self.zero_spin.value()),
|
||||||
|
"rising": bool(self.rising_check.isChecked()),
|
||||||
|
"calibrated": bool(self.calibrated_check.isChecked()),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _on_changed(self, _value: object = None) -> None:
|
||||||
|
self._refresh_state()
|
||||||
|
|
||||||
|
def _refresh_state(self, *, announce: bool = True) -> None:
|
||||||
|
self.state_label.setText(
|
||||||
|
t("setup.press_set") if self.calibrated_check.isChecked() else t("setup.press_unset")
|
||||||
|
)
|
||||||
|
if announce:
|
||||||
|
self.pressChanged.emit()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- translations
|
||||||
|
|
||||||
|
def retranslate(self) -> None:
|
||||||
|
self.hint_label.setText(t("setup.press_hint"))
|
||||||
|
self.box.setTitle(t("setup.tab_press"))
|
||||||
|
self.zero_label.setText(t("setup.press_zero"))
|
||||||
|
self.rising_check.setText(t("setup.press_rising"))
|
||||||
|
self.calibrated_check.setText(t("setup.press_calibrated"))
|
||||||
|
self._refresh_state(announce=False)
|
||||||
|
|
||||||
|
|
||||||
class DeviceSetupDialog(QDialog):
|
class DeviceSetupDialog(QDialog):
|
||||||
"""Connection and bus initialisation, the once-per-start controls."""
|
"""Connection and bus initialisation, the once-per-start controls."""
|
||||||
|
|
||||||
@@ -173,6 +289,7 @@ class DeviceSetupDialog(QDialog):
|
|||||||
spec: ConnectionSpec,
|
spec: ConnectionSpec,
|
||||||
bas_path: str = "",
|
bas_path: str = "",
|
||||||
roles: dict[str, int] | None = None,
|
roles: dict[str, int] | None = None,
|
||||||
|
press: object = None,
|
||||||
parent: QWidget | None = None,
|
parent: QWidget | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -195,6 +312,8 @@ class DeviceSetupDialog(QDialog):
|
|||||||
self.connection_panel = ConnectionPanel(spec, self.tabs)
|
self.connection_panel = ConnectionPanel(spec, self.tabs)
|
||||||
self.bus_panel = BusPanel(bas_path, self.tabs)
|
self.bus_panel = BusPanel(bas_path, self.tabs)
|
||||||
self.roles_page = AxisRolesPage(roles, self.tabs)
|
self.roles_page = AxisRolesPage(roles, self.tabs)
|
||||||
|
self.press_page = PressAxisPage(press, self.tabs)
|
||||||
|
self.units_page = UnitsPage(self.tabs)
|
||||||
for key, locale_key in TABS:
|
for key, locale_key in TABS:
|
||||||
self.tabs.addTab(self.panel(key), "")
|
self.tabs.addTab(self.panel(key), "")
|
||||||
column.addWidget(self.tabs, 1)
|
column.addWidget(self.tabs, 1)
|
||||||
@@ -215,10 +334,14 @@ class DeviceSetupDialog(QDialog):
|
|||||||
# ----------------------------------------------------------------- content
|
# ----------------------------------------------------------------- content
|
||||||
|
|
||||||
def panel(self, key: str) -> QWidget:
|
def panel(self, key: str) -> QWidget:
|
||||||
"""One of the three pages, by tab key."""
|
"""One of the five pages, by tab key."""
|
||||||
if key == "connection":
|
if key == "connection":
|
||||||
return self.connection_panel
|
return self.connection_panel
|
||||||
return self.bus_panel if key == "bus" else self.roles_page
|
if key == "bus":
|
||||||
|
return self.bus_panel
|
||||||
|
if key == "units":
|
||||||
|
return self.units_page
|
||||||
|
return self.roles_page if key == "axes" else self.press_page
|
||||||
|
|
||||||
def open_for(self, tab: str | None = None) -> None:
|
def open_for(self, tab: str | None = None) -> None:
|
||||||
"""Show the dialog, raising it if it is already up, and pick a tab.
|
"""Show the dialog, raising it if it is already up, and pick a tab.
|
||||||
@@ -283,3 +406,5 @@ class DeviceSetupDialog(QDialog):
|
|||||||
self.connection_panel.retranslate()
|
self.connection_panel.retranslate()
|
||||||
self.bus_panel.retranslate()
|
self.bus_panel.retranslate()
|
||||||
self.roles_page.retranslate()
|
self.roles_page.retranslate()
|
||||||
|
self.press_page.retranslate()
|
||||||
|
self.units_page.retranslate()
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
"""Finger video or live camera input for the AutoCal sensor workspace."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import cv2
|
||||||
|
from orisys_app_shared.acquisition.finger_worker import FingerAcquisitionWorker
|
||||||
|
from orisys_app_shared.acquisition.arrows import depth_map_to_bgr, draw_arrow_display, flow_colormap
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from PySide6.QtCore import Qt, QTimer, Signal
|
||||||
|
from PySide6.QtGui import QImage, QPixmap
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QFileDialog,
|
||||||
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
|
QGridLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPushButton,
|
||||||
|
QSpinBox,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FingerSourcePanel(QWidget):
|
||||||
|
"""Run the shared finger processing path from a video or camera.
|
||||||
|
|
||||||
|
Worker callbacks originate on its acquisition thread; the GUI timer alone
|
||||||
|
reads packets and changes widgets. The same worker also accepts a camera ID.
|
||||||
|
"""
|
||||||
|
|
||||||
|
errorReported = Signal(str)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
video_path: str = "",
|
||||||
|
config_ref: str = "",
|
||||||
|
parent: QWidget | None = None,
|
||||||
|
*,
|
||||||
|
source_kind: str = "video",
|
||||||
|
camera_index: int = 0,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.worker = FingerAcquisitionWorker(on_error=self.errorReported.emit)
|
||||||
|
self._last_frame_id: tuple[int, int] | None = None
|
||||||
|
self._last_image: QImage | None = None
|
||||||
|
self._last_packet = None
|
||||||
|
self._active = False
|
||||||
|
self._error = ""
|
||||||
|
self._status_key = "source.idle"
|
||||||
|
self.mode = None
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
fields = QGridLayout()
|
||||||
|
self.source_label = QLabel(self)
|
||||||
|
self.source_kind = QComboBox(self)
|
||||||
|
self.source_kind.setObjectName("fingerSourceKind")
|
||||||
|
self.source_kind.setMaximumWidth(220)
|
||||||
|
self.source_kind.addItem("", "video")
|
||||||
|
self.source_kind.addItem("", "camera")
|
||||||
|
self.source_kind.setCurrentIndex(1 if source_kind == "camera" else 0)
|
||||||
|
self.video_label = QLabel(self)
|
||||||
|
self.video_path = QLineEdit(video_path, self)
|
||||||
|
self.video_path.setObjectName("fingerVideoPath")
|
||||||
|
self.video_browse = QPushButton(self)
|
||||||
|
self.camera_label = QLabel(self)
|
||||||
|
self.camera_index = QSpinBox(self)
|
||||||
|
self.camera_index.setObjectName("fingerCameraIndex")
|
||||||
|
self.camera_index.setRange(0, 31)
|
||||||
|
try:
|
||||||
|
remembered_camera = int(camera_index)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
remembered_camera = 0
|
||||||
|
self.camera_index.setValue(max(0, min(31, remembered_camera)))
|
||||||
|
self.camera_index.setMaximumWidth(100)
|
||||||
|
self.config_label = QLabel(self)
|
||||||
|
self.config_ref = QLineEdit(config_ref, self)
|
||||||
|
self.config_ref.setObjectName("fingerConfigRef")
|
||||||
|
self.config_browse = QPushButton(self)
|
||||||
|
fields.addWidget(self.source_label, 0, 0)
|
||||||
|
fields.addWidget(self.source_kind, 0, 1)
|
||||||
|
fields.addWidget(self.video_label, 1, 0)
|
||||||
|
fields.addWidget(self.video_path, 1, 1)
|
||||||
|
fields.addWidget(self.video_browse, 1, 2)
|
||||||
|
fields.addWidget(self.camera_label, 2, 0)
|
||||||
|
fields.addWidget(self.camera_index, 2, 1)
|
||||||
|
fields.addWidget(self.config_label, 3, 0)
|
||||||
|
fields.addWidget(self.config_ref, 3, 1)
|
||||||
|
fields.addWidget(self.config_browse, 3, 2)
|
||||||
|
layout.addLayout(fields)
|
||||||
|
|
||||||
|
controls = QHBoxLayout()
|
||||||
|
self.start_button = QPushButton(self)
|
||||||
|
self.start_button.setObjectName("fingerVideoStart")
|
||||||
|
self.stop_button = QPushButton(self)
|
||||||
|
self.stop_button.setObjectName("fingerVideoStop")
|
||||||
|
self.stop_button.setEnabled(False)
|
||||||
|
self.status_label = QLabel(self)
|
||||||
|
self.status_label.setObjectName("fingerVideoStatus")
|
||||||
|
controls.addWidget(self.start_button)
|
||||||
|
controls.addWidget(self.stop_button)
|
||||||
|
controls.addWidget(self.status_label, 1)
|
||||||
|
layout.addLayout(controls)
|
||||||
|
self.preview_hint = QLabel(self)
|
||||||
|
self.preview_hint.setWordWrap(True)
|
||||||
|
layout.addWidget(self.preview_hint)
|
||||||
|
display_row = QHBoxLayout()
|
||||||
|
self.background_check = QCheckBox(self)
|
||||||
|
self.background_check.setObjectName("calibrationRealBackground")
|
||||||
|
self.background_check.setChecked(True)
|
||||||
|
self.arrows_check = QCheckBox(self)
|
||||||
|
self.arrows_check.setObjectName("calibrationArrows")
|
||||||
|
self.depth_check = QCheckBox(self)
|
||||||
|
self.depth_check.setObjectName("calibrationDepth")
|
||||||
|
for check in (self.background_check, self.arrows_check, self.depth_check):
|
||||||
|
display_row.addWidget(check)
|
||||||
|
check.toggled.connect(self._redraw)
|
||||||
|
display_row.addStretch()
|
||||||
|
layout.addLayout(display_row)
|
||||||
|
self.display_note = QLabel(self)
|
||||||
|
self.display_note.setWordWrap(True)
|
||||||
|
layout.addWidget(self.display_note)
|
||||||
|
|
||||||
|
self.preview = QLabel(self)
|
||||||
|
self.preview.setObjectName("fingerVideoPreview")
|
||||||
|
self.preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.preview.setMinimumSize(320, 260)
|
||||||
|
layout.addWidget(self.preview, 1)
|
||||||
|
self.frame_label = QLabel(self)
|
||||||
|
layout.addWidget(self.frame_label)
|
||||||
|
|
||||||
|
self._timer = QTimer(self)
|
||||||
|
self._timer.setInterval(33)
|
||||||
|
self._timer.timeout.connect(self._poll)
|
||||||
|
self.errorReported.connect(self._on_error)
|
||||||
|
self.video_browse.clicked.connect(self._choose_video)
|
||||||
|
self.config_browse.clicked.connect(self._choose_config)
|
||||||
|
self.start_button.clicked.connect(self.start)
|
||||||
|
self.stop_button.clicked.connect(self.stop)
|
||||||
|
self.source_kind.currentIndexChanged.connect(self._on_source_changed)
|
||||||
|
self._on_source_changed()
|
||||||
|
self.retranslate()
|
||||||
|
|
||||||
|
def retranslate(self) -> None:
|
||||||
|
self.source_label.setText(t("source.kind"))
|
||||||
|
self.source_kind.setItemText(0, t("source.kind_video"))
|
||||||
|
self.source_kind.setItemText(1, t("source.kind_camera"))
|
||||||
|
self.video_label.setText(t("source.video"))
|
||||||
|
self.camera_label.setText(t("source.camera_index"))
|
||||||
|
self.config_label.setText(t("source.config"))
|
||||||
|
self.video_browse.setText(t("source.browse"))
|
||||||
|
self.config_browse.setText(t("source.browse"))
|
||||||
|
self.start_button.setText(t("source.start"))
|
||||||
|
self.stop_button.setText(t("source.stop"))
|
||||||
|
self.preview_hint.setText(t("source.preview_only"))
|
||||||
|
self.background_check.setText(t("feedback.background"))
|
||||||
|
self.background_check.setToolTip(t("feedback.background_tip"))
|
||||||
|
self.arrows_check.setText(t("feedback.arrows"))
|
||||||
|
self.depth_check.setText(t("feedback.depth"))
|
||||||
|
self.depth_check.setToolTip(t("feedback.depth_tip"))
|
||||||
|
self._set_status(self._status_key)
|
||||||
|
if self._last_image is None:
|
||||||
|
self.preview.setText(t("source.no_frame"))
|
||||||
|
|
||||||
|
def _choose_video(self) -> None:
|
||||||
|
path, _ = QFileDialog.getOpenFileName(
|
||||||
|
self,
|
||||||
|
t("source.choose_video"),
|
||||||
|
self.video_path.text(),
|
||||||
|
t("source.video_filter"),
|
||||||
|
)
|
||||||
|
if path:
|
||||||
|
self.video_path.setText(path)
|
||||||
|
|
||||||
|
def _choose_config(self) -> None:
|
||||||
|
path, _ = QFileDialog.getOpenFileName(
|
||||||
|
self, t("source.choose_config"), self.config_ref.text(), "JSON (*.json)"
|
||||||
|
)
|
||||||
|
if path:
|
||||||
|
self.config_ref.setText(path)
|
||||||
|
|
||||||
|
def _on_source_changed(self) -> None:
|
||||||
|
video = self.source_kind.currentData() == "video"
|
||||||
|
for widget in (self.video_label, self.video_path, self.video_browse):
|
||||||
|
widget.setVisible(video)
|
||||||
|
self.camera_label.setVisible(not video)
|
||||||
|
self.camera_index.setVisible(not video)
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
video = Path(self.video_path.text().strip())
|
||||||
|
config = Path(self.config_ref.text().strip())
|
||||||
|
is_video = self.source_kind.currentData() == "video"
|
||||||
|
if not config.is_file() or (is_video and not video.is_file()):
|
||||||
|
self._show_error(t("source.missing_input"))
|
||||||
|
return
|
||||||
|
source = str(video) if is_video else self.camera_index.value()
|
||||||
|
self._error = ""
|
||||||
|
self._last_frame_id = None
|
||||||
|
self._last_image = None
|
||||||
|
self._last_packet = None
|
||||||
|
self.preview.clear()
|
||||||
|
self.preview.setText(t("source.no_frame"))
|
||||||
|
self.frame_label.clear()
|
||||||
|
try:
|
||||||
|
self.mode = self.worker.start(source, config_ref=str(config))
|
||||||
|
except Exception as exc: # noqa: BLE001 - show source/config errors to operator
|
||||||
|
self._show_error(str(exc))
|
||||||
|
return
|
||||||
|
self._active = True
|
||||||
|
self._set_running_controls(True)
|
||||||
|
self._set_status("source.opening")
|
||||||
|
self._timer.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if self._active:
|
||||||
|
self.worker.stop()
|
||||||
|
self._set_status("source.stopping")
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
self._timer.stop()
|
||||||
|
self.worker.stop(wait=True)
|
||||||
|
|
||||||
|
def _set_running_controls(self, running: bool) -> None:
|
||||||
|
self.start_button.setEnabled(not running)
|
||||||
|
self.stop_button.setEnabled(running)
|
||||||
|
self.source_kind.setEnabled(not running)
|
||||||
|
self.camera_index.setEnabled(not running)
|
||||||
|
for widget in (
|
||||||
|
self.video_path,
|
||||||
|
self.video_browse,
|
||||||
|
self.config_ref,
|
||||||
|
self.config_browse,
|
||||||
|
):
|
||||||
|
widget.setEnabled(not running)
|
||||||
|
|
||||||
|
def _show_error(self, message: str) -> None:
|
||||||
|
self._error = message
|
||||||
|
self._set_status("source.error")
|
||||||
|
|
||||||
|
def _set_status(self, key: str) -> None:
|
||||||
|
self._status_key = key
|
||||||
|
self.status_label.setText(t(key).format(detail=self._error))
|
||||||
|
|
||||||
|
def _on_error(self, message: str) -> None:
|
||||||
|
self._show_error(message)
|
||||||
|
|
||||||
|
def _poll(self) -> None:
|
||||||
|
packet = self.worker.latest()
|
||||||
|
if packet is not None:
|
||||||
|
identity = (packet.session_id, packet.frame_id)
|
||||||
|
if identity != self._last_frame_id:
|
||||||
|
self._last_frame_id = identity
|
||||||
|
self._last_packet = packet
|
||||||
|
self._redraw()
|
||||||
|
acquired = datetime.fromtimestamp(packet.timestamp).strftime(
|
||||||
|
"%H:%M:%S.%f"
|
||||||
|
)[:-3]
|
||||||
|
basis_key = {
|
||||||
|
"host_replay": "source.basis_replay",
|
||||||
|
"host_camera": "source.basis_camera",
|
||||||
|
}.get(getattr(packet, "time_basis", ""), "source.basis_unknown")
|
||||||
|
basis = t(basis_key)
|
||||||
|
self.frame_label.setText(
|
||||||
|
t("source.frame").format(
|
||||||
|
frame=packet.frame_id,
|
||||||
|
source_frame=getattr(packet, "source_frame_id", None) or "–",
|
||||||
|
time=acquired,
|
||||||
|
basis=basis,
|
||||||
|
dropped=getattr(packet, "capture_dropped", 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not self._error:
|
||||||
|
self._set_status("source.running")
|
||||||
|
if not self.worker.is_running:
|
||||||
|
reason = self.worker.consume_end_reason()
|
||||||
|
self._timer.stop()
|
||||||
|
self._active = False
|
||||||
|
self._set_running_controls(False)
|
||||||
|
if not self._error:
|
||||||
|
key = {
|
||||||
|
"ended": "source.finished",
|
||||||
|
"disconnected": "source.disconnected",
|
||||||
|
"error": "source.failed",
|
||||||
|
}.get(reason, "source.stopped")
|
||||||
|
self._set_status(key)
|
||||||
|
|
||||||
|
def _redraw(self) -> None:
|
||||||
|
packet = self._last_packet
|
||||||
|
if packet is None:
|
||||||
|
return
|
||||||
|
frame = np.array(packet.processed_frame, copy=True)
|
||||||
|
mask = getattr(packet, "valid_mask", None)
|
||||||
|
if mask is not None and np.shape(mask) != frame.shape[:2]:
|
||||||
|
mask = None
|
||||||
|
if not self.background_check.isChecked():
|
||||||
|
frame[:] = (235, 230, 224)
|
||||||
|
if mask is not None:
|
||||||
|
frame[~np.asarray(mask, dtype=bool)] = (25, 25, 25)
|
||||||
|
notes = []
|
||||||
|
if self.depth_check.isChecked():
|
||||||
|
depth = getattr(packet, "depth", None)
|
||||||
|
fresh = (getattr(packet, "depth_valid", None) is True
|
||||||
|
and getattr(packet, "depth_frame_id", None) == packet.frame_id)
|
||||||
|
if fresh and depth is not None and np.shape(depth) == frame.shape[:2]:
|
||||||
|
valid = np.isfinite(depth)
|
||||||
|
if mask is not None:
|
||||||
|
valid &= np.asarray(mask, dtype=bool)
|
||||||
|
if np.any(valid):
|
||||||
|
# Adapt to this frame's valid UV region, excluding the exterior.
|
||||||
|
low, high = float(np.min(depth[valid])), float(np.max(depth[valid]))
|
||||||
|
colored = depth_map_to_bgr(
|
||||||
|
np.where(valid, depth, low), flow_colormap("light"),
|
||||||
|
value_range=(low, high), invalid_mask=valid,
|
||||||
|
)
|
||||||
|
blended = cv2.addWeighted(frame, .4, colored, .6, 0)
|
||||||
|
frame[valid] = blended[valid]
|
||||||
|
notes.append(t("feedback.depth_scale").format(low=low, high=high))
|
||||||
|
else:
|
||||||
|
notes.append(t("feedback.depth_missing"))
|
||||||
|
else:
|
||||||
|
notes.append(t("feedback.depth_missing"))
|
||||||
|
if self.arrows_check.isChecked():
|
||||||
|
flow = getattr(packet, "flow", None)
|
||||||
|
fresh = (getattr(packet, "flow_valid", None) is True
|
||||||
|
and getattr(packet, "flow_frame_id", None) == packet.frame_id)
|
||||||
|
if fresh and flow is not None:
|
||||||
|
frame = draw_arrow_display(frame, flow, background="captured",
|
||||||
|
draw_style="finger", valid_mask=mask,
|
||||||
|
colormap=flow_colormap("light"))
|
||||||
|
diagnostic = getattr(frame, "frame_diagnostic", None)
|
||||||
|
if diagnostic is not None:
|
||||||
|
notes.append(str(diagnostic))
|
||||||
|
else:
|
||||||
|
notes.append(t("feedback.flow_missing"))
|
||||||
|
self.display_note.setText(" · ".join(notes))
|
||||||
|
self._show_frame(frame)
|
||||||
|
|
||||||
|
def _show_frame(self, frame: np.ndarray) -> None:
|
||||||
|
if frame.ndim != 3 or frame.shape[2] != 3:
|
||||||
|
return
|
||||||
|
height, width = frame.shape[:2]
|
||||||
|
self._last_image = QImage(
|
||||||
|
frame.data, width, height, frame.strides[0], QImage.Format.Format_BGR888
|
||||||
|
).copy()
|
||||||
|
self._scale_preview()
|
||||||
|
|
||||||
|
def _scale_preview(self) -> None:
|
||||||
|
if self._last_image is not None:
|
||||||
|
self.preview.setPixmap(
|
||||||
|
QPixmap.fromImage(self._last_image).scaled(
|
||||||
|
self.preview.size(),
|
||||||
|
Qt.AspectRatioMode.KeepAspectRatio,
|
||||||
|
Qt.TransformationMode.SmoothTransformation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def resizeEvent(self, event) -> None: # noqa: N802 - Qt override
|
||||||
|
super().resizeEvent(event)
|
||||||
|
self._scale_preview()
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Operator force feedback from the same samples written by the recorder."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
import pyqtgraph as pg
|
||||||
|
from orisys_app_shared.force.history import MetricHistory
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from orisys_app_shared.record.sample import to_record_sample
|
||||||
|
from PySide6.QtCore import QTimer
|
||||||
|
from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
|
||||||
|
class LiveForceView(QWidget):
|
||||||
|
"""Collect on the worker thread; update widgets only on the GUI thread."""
|
||||||
|
|
||||||
|
def __init__(self, worker, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.worker = worker
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._pending = deque(maxlen=5400)
|
||||||
|
self._overflow = 0
|
||||||
|
self._session = None
|
||||||
|
self._last_arrival = None
|
||||||
|
self._sample = None
|
||||||
|
self.history = MetricHistory(channels=3, window_sec=60, max_fps=90)
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.readings = QLabel(self)
|
||||||
|
self.readings.setObjectName("calibrationForceValues")
|
||||||
|
self.readings.setWordWrap(True)
|
||||||
|
self.readings.setStyleSheet("font-size: 16px; font-weight: 600;")
|
||||||
|
self.state = QLabel(self)
|
||||||
|
self.state.setWordWrap(True)
|
||||||
|
layout.addWidget(self.readings)
|
||||||
|
layout.addWidget(self.state)
|
||||||
|
self.plot = pg.PlotWidget(background="w")
|
||||||
|
self.plot.setMinimumHeight(130)
|
||||||
|
self.plot.showGrid(x=True, y=True, alpha=.15)
|
||||||
|
self.plot.addLegend()
|
||||||
|
self.curves = [self.plot.plot(name=name, pen=pg.mkPen(color, width=2),
|
||||||
|
connect="finite")
|
||||||
|
for name, color in (("FN", "#167ac6"), ("FX", "#dc8413"),
|
||||||
|
("FY", "#40854b"))]
|
||||||
|
layout.addWidget(self.plot, 1)
|
||||||
|
worker.add_sink(self.on_packet)
|
||||||
|
self._timer = QTimer(self)
|
||||||
|
self._timer.setInterval(100)
|
||||||
|
self._timer.timeout.connect(self.refresh)
|
||||||
|
self._timer.start()
|
||||||
|
self.retranslate()
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def on_packet(self, packet):
|
||||||
|
sample = to_record_sample(packet)
|
||||||
|
with self._lock:
|
||||||
|
if len(self._pending) == self._pending.maxlen:
|
||||||
|
self._overflow += 1
|
||||||
|
self._pending.append((packet.session_id, sample, time.monotonic()))
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
with self._lock:
|
||||||
|
pending = list(self._pending)
|
||||||
|
self._pending.clear()
|
||||||
|
overflow = self._overflow
|
||||||
|
for session, sample, arrival in pending:
|
||||||
|
if session != self._session:
|
||||||
|
self.history.reset()
|
||||||
|
self._session = session
|
||||||
|
values = sample.raw_triplet() if sample.valid else (math.nan,) * 3
|
||||||
|
self.history.push(sample.mono_t, *values)
|
||||||
|
self._sample, self._last_arrival = sample, arrival
|
||||||
|
state = self.history.state() if self.history.sample_count else None
|
||||||
|
if state is not None:
|
||||||
|
for index, curve in enumerate(self.curves):
|
||||||
|
curve.setData(state.times, state.values[:, index])
|
||||||
|
self.plot.setXRange(state.x_range[0], max(10, float(state.times[-1])), padding=0)
|
||||||
|
sample = self._sample
|
||||||
|
if sample is None:
|
||||||
|
self.readings.setText(t("feedback.waiting"))
|
||||||
|
else:
|
||||||
|
values = []
|
||||||
|
for axis in ("fn", "fx", "fy"):
|
||||||
|
raw = sample.raw_for(axis) if sample.valid else math.nan
|
||||||
|
newton = sample.newton_for(axis) if sample.valid else math.nan
|
||||||
|
raw_text = f"{raw:.6g}" if math.isfinite(raw) else "—"
|
||||||
|
n_text = f"{newton:.3f} N" if math.isfinite(newton) else t("feedback.uncalibrated")
|
||||||
|
values.append(f"{axis.upper()}: {raw_text} raw · {n_text}")
|
||||||
|
self.readings.setText(" ".join(values))
|
||||||
|
if not self.worker.is_running:
|
||||||
|
message = t("feedback.stopped")
|
||||||
|
elif self._last_arrival is None or time.monotonic() - self._last_arrival > 1:
|
||||||
|
message = t("feedback.stale")
|
||||||
|
elif not sample.valid:
|
||||||
|
message = t("feedback.invalid").format(reason=sample.invalid_reason)
|
||||||
|
else:
|
||||||
|
message = t("feedback.raw_note")
|
||||||
|
if overflow:
|
||||||
|
message += " · " + t("feedback.overflow").format(count=overflow)
|
||||||
|
self.state.setText(message)
|
||||||
|
|
||||||
|
def retranslate(self):
|
||||||
|
self.plot.setLabel("left", t("feedback.raw_axis"))
|
||||||
|
self.plot.setLabel("bottom", t("feedback.time_axis"))
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
self._timer.stop()
|
||||||
|
self.worker.remove_sink(self.on_packet)
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""The processed camera image, shown beside the UV map on the capture page.
|
||||||
|
|
||||||
|
The capture page needs the live 2-D UV image and the pickable UV map *at the same
|
||||||
|
time*: the operator confirms a point's position on the map and watches the same
|
||||||
|
frame the recorder sees. This widget therefore reads the already-running input
|
||||||
|
worker; it never starts, stops or reconfigures it — the input source panel in the
|
||||||
|
settings dialog owns that.
|
||||||
|
|
||||||
|
Multiple readers of ``worker.latest()`` are allowed: the input panel keeps its own
|
||||||
|
copy for the settings dialog, and this view keeps one here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from PySide6.QtCore import Qt, QTimer
|
||||||
|
from PySide6.QtGui import QImage, QPixmap
|
||||||
|
from PySide6.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
|
||||||
|
class LiveUvView(QWidget):
|
||||||
|
"""Show the latest processed frame and the identity of the packet behind it."""
|
||||||
|
|
||||||
|
def __init__(self, source_panel: QWidget, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.source_panel = source_panel
|
||||||
|
self._identity: tuple[int, int] | None = None
|
||||||
|
self._image: QImage | None = None
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(2)
|
||||||
|
self.preview = QLabel(self)
|
||||||
|
self.preview.setObjectName("liveUvPreview")
|
||||||
|
self.preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
# No minimum of its own: the image scales to whatever room the row gives
|
||||||
|
# it, and a minimum would overflow the row the moment the page is squeezed.
|
||||||
|
self.preview.setMinimumSize(1, 1)
|
||||||
|
self.preview.setSizePolicy(
|
||||||
|
# The image scales to whatever room the row gives it; a minimum that
|
||||||
|
# honoured the frame's own size would push the page past the window.
|
||||||
|
QSizePolicy.Policy.Ignored,
|
||||||
|
QSizePolicy.Policy.Ignored,
|
||||||
|
)
|
||||||
|
self.status = QLabel(self)
|
||||||
|
self.status.setWordWrap(True)
|
||||||
|
self.status.setObjectName("hintText")
|
||||||
|
layout.addWidget(self.preview, 1)
|
||||||
|
layout.addWidget(self.status)
|
||||||
|
self._timer = QTimer(self)
|
||||||
|
self._timer.setInterval(100)
|
||||||
|
self._timer.timeout.connect(self.refresh)
|
||||||
|
self._timer.start()
|
||||||
|
self.retranslate()
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def retranslate(self) -> None:
|
||||||
|
if self._image is None:
|
||||||
|
self.preview.setText(t("source.no_frame"))
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def refresh(self) -> None:
|
||||||
|
worker = getattr(self.source_panel, "worker", None)
|
||||||
|
packet = None if worker is None else worker.latest()
|
||||||
|
if packet is not None:
|
||||||
|
identity = (int(packet.session_id), int(packet.frame_id))
|
||||||
|
if identity != self._identity:
|
||||||
|
self._identity = identity
|
||||||
|
self._show(packet.processed_frame)
|
||||||
|
running = bool(getattr(worker, "is_running", False))
|
||||||
|
if packet is None:
|
||||||
|
# The preview already says there is no frame; the line below it says
|
||||||
|
# whether the input is running, which is the fact an operator needs.
|
||||||
|
self.status.setText(t("source.running") if running else t("source.stopped"))
|
||||||
|
return
|
||||||
|
self.status.setText(
|
||||||
|
t("source.frame").format(
|
||||||
|
frame=packet.frame_id,
|
||||||
|
source_frame=getattr(packet, "source_frame_id", None) or "–",
|
||||||
|
basis=t("source.basis_unknown"),
|
||||||
|
time="—",
|
||||||
|
dropped=getattr(packet, "capture_dropped", 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _show(self, frame) -> None:
|
||||||
|
data = np.asarray(frame)
|
||||||
|
if data.ndim != 3 or data.shape[2] != 3:
|
||||||
|
return
|
||||||
|
height, width = data.shape[:2]
|
||||||
|
self._image = QImage(
|
||||||
|
data.data, width, height, int(data.strides[0]), QImage.Format.Format_BGR888
|
||||||
|
).copy()
|
||||||
|
self._scale()
|
||||||
|
|
||||||
|
def _scale(self) -> None:
|
||||||
|
if self._image is None:
|
||||||
|
return
|
||||||
|
self.preview.setPixmap(
|
||||||
|
QPixmap.fromImage(self._image).scaled(
|
||||||
|
self.preview.size(),
|
||||||
|
Qt.AspectRatioMode.KeepAspectRatio,
|
||||||
|
Qt.TransformationMode.SmoothTransformation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def resizeEvent(self, event) -> None: # noqa: N802 - Qt override
|
||||||
|
super().resizeEvent(event)
|
||||||
|
self._scale()
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
self._timer.stop()
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Per-site trial comparisons and held-out validation from reviewed data."""
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog, QHeaderView, QLabel, QTableWidget, QTableWidgetItem,
|
||||||
|
QTabWidget, QVBoxLayout, QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value):
|
||||||
|
return "—" if value is None else f"{value:.6g}"
|
||||||
|
|
||||||
|
|
||||||
|
class QualityReviewDialog(QDialog):
|
||||||
|
def __init__(self, site: dict, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle(t("quality.title") + " · " + site["site_id"])
|
||||||
|
self.resize(1060, 560)
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
notice = QLabel(t("quality.notice"), self)
|
||||||
|
notice.setWordWrap(True)
|
||||||
|
layout.addWidget(notice)
|
||||||
|
self.tabs = QTabWidget(self)
|
||||||
|
layout.addWidget(self.tabs, 1)
|
||||||
|
quality = site["quality"]
|
||||||
|
self._table("repeatability", ("branch", "load", "trials", "range", "std"), [
|
||||||
|
[t("calibration." + row["branch"]), _number(row["reference_n"]), str(row["trials"]),
|
||||||
|
_number(row["raw_range"]), _number(row["raw_std_between_trials"])]
|
||||||
|
for row in quality["repeatability"]])
|
||||||
|
self._table("hysteresis", ("trial", "load", "load_raw", "unload_raw", "delta_raw"), [
|
||||||
|
[row["trial_id"], _number(row["reference_n"]), _number(row["load_raw"]),
|
||||||
|
_number(row["unload_raw"]), _number(row["unload_minus_load_raw"])]
|
||||||
|
for row in quality["hysteresis"]])
|
||||||
|
self._table("zero_return", ("trial", "initial_zero", "final_zero", "delta_raw"), [
|
||||||
|
[row["trial_id"], _number(row["initial_zero_raw"]), _number(row["final_zero_raw"]),
|
||||||
|
_number(row["final_minus_initial_raw"])] for row in quality["zero_return"]])
|
||||||
|
validation = quality["validation"]
|
||||||
|
rows = []
|
||||||
|
for fold in validation["folds"]:
|
||||||
|
prefix = [fold["held_out_trial"], ", ".join(fold["training_trials"]) or "—"]
|
||||||
|
if not fold["windows"]:
|
||||||
|
rows.append(prefix + ["—"] * 4 + [t("quality.insufficient_training")])
|
||||||
|
for window in fold["windows"]:
|
||||||
|
rows.append(prefix + [t("calibration." + window["branch"]),
|
||||||
|
_number(window["reference_n"]), _number(window["predicted_n"]),
|
||||||
|
_number(window["error_n"]),
|
||||||
|
t("quality.outside_range" if window["outside_training_raw_range"] else "quality.in_range")])
|
||||||
|
summary = t("quality.validation_summary").format(
|
||||||
|
count=validation["evaluated_windows"], rmse=_number(validation["rmse_n"]),
|
||||||
|
mae=_number(validation["mae_n"]), maximum=_number(validation["max_abs_error_n"]),
|
||||||
|
outside=validation["outside_training_raw_range"],
|
||||||
|
)
|
||||||
|
self._table("validation", ("held_out", "training", "branch", "load", "predicted", "error", "range_status"),
|
||||||
|
rows, summary)
|
||||||
|
|
||||||
|
def _table(self, key, columns, rows, summary=""):
|
||||||
|
page = QWidget(self.tabs)
|
||||||
|
layout = QVBoxLayout(page)
|
||||||
|
note = QLabel(t("quality." + key + "_note"), page)
|
||||||
|
note.setWordWrap(True)
|
||||||
|
layout.addWidget(note)
|
||||||
|
if summary:
|
||||||
|
label = QLabel(summary, page)
|
||||||
|
label.setWordWrap(True)
|
||||||
|
layout.addWidget(label)
|
||||||
|
if not rows:
|
||||||
|
layout.addWidget(QLabel(t("quality.no_data"), page))
|
||||||
|
table = QTableWidget(len(rows), len(columns), page)
|
||||||
|
table.setObjectName("quality_" + key)
|
||||||
|
table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||||
|
table.setHorizontalHeaderLabels([t("quality.col_" + column) for column in columns])
|
||||||
|
table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
table.horizontalHeader().setStretchLastSection(True)
|
||||||
|
for row, values in enumerate(rows):
|
||||||
|
for column, value in enumerate(values):
|
||||||
|
table.setItem(row, column, QTableWidgetItem(value))
|
||||||
|
layout.addWidget(table, 1)
|
||||||
|
self.tabs.addTab(page, t("quality." + key))
|
||||||
@@ -0,0 +1,662 @@
|
|||||||
|
"""Recorded-site coverage and normal response, with optional operator CSV plan."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
import statistics
|
||||||
|
import math
|
||||||
|
from bisect import bisect_left, bisect_right
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries, QValueAxis
|
||||||
|
from PySide6.QtCore import Qt, QPointF, Slot
|
||||||
|
from PySide6.QtGui import QColor, QPainter
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QComboBox, QDialog, QFileDialog, QHBoxLayout, QHeaderView, QLabel,
|
||||||
|
QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, QTabWidget,
|
||||||
|
QSizePolicy, QToolButton, QMenu,
|
||||||
|
QGraphicsSimpleTextItem,
|
||||||
|
QScrollArea, QFrame,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..record_overview import load_overview, overview_rows, read_plan, save_overview
|
||||||
|
from ..recording_options import RecordingOptions
|
||||||
|
from ..session_review import review_session
|
||||||
|
from ..record_diagnostics import trial_diagnostics
|
||||||
|
from ..sensor.uv_outline import recorded_outline
|
||||||
|
from .session_review_dialog import SessionReviewDialog
|
||||||
|
from .chart_helpers import add_value_axes, clear_chart
|
||||||
|
from .review_job import ReviewJob
|
||||||
|
|
||||||
|
|
||||||
|
class RecordOverviewPage(QWidget):
|
||||||
|
"""Keep the existing record viewer inside the main task workspace."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.overview = None
|
||||||
|
self._job = None
|
||||||
|
self.directory = Path.cwd()
|
||||||
|
self.content_layout = QVBoxLayout(self)
|
||||||
|
self.open_button = QPushButton(self)
|
||||||
|
self.open_button.setObjectName("openCalibrationRecords")
|
||||||
|
self.open_button.clicked.connect(self._open)
|
||||||
|
self.open_button.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
|
||||||
|
self.open_button.setStyleSheet("font-size: 14px; min-height: 22px; padding: 3px 10px;")
|
||||||
|
self.path_label = QLabel(self)
|
||||||
|
self.path_label.setWordWrap(True)
|
||||||
|
self.message = QLabel(self)
|
||||||
|
self.message.setWordWrap(True)
|
||||||
|
self.message.hide()
|
||||||
|
self.viewer = QScrollArea(self)
|
||||||
|
self.viewer.setWidgetResizable(True)
|
||||||
|
self.viewer.setFrameShape(QFrame.Shape.NoFrame)
|
||||||
|
header = QHBoxLayout()
|
||||||
|
header.addWidget(self.open_button)
|
||||||
|
header.addWidget(self.path_label, 1)
|
||||||
|
self.content_layout.addLayout(header)
|
||||||
|
self.content_layout.addWidget(self.message)
|
||||||
|
self.content_layout.addStretch(1)
|
||||||
|
self.retranslate()
|
||||||
|
|
||||||
|
def retranslate(self):
|
||||||
|
self.open_button.setText(t("overview.open_session"))
|
||||||
|
if self.overview is None:
|
||||||
|
self.path_label.setText(t("overview.empty"))
|
||||||
|
|
||||||
|
def _open(self):
|
||||||
|
path = QFileDialog.getExistingDirectory(self, t("overview.open_session"), str(self.directory))
|
||||||
|
if path:
|
||||||
|
self.load_session(Path(path))
|
||||||
|
|
||||||
|
def load_session(self, directory: Path):
|
||||||
|
if self._job is not None:
|
||||||
|
return
|
||||||
|
self._loading_directory = directory
|
||||||
|
def read():
|
||||||
|
review = review_session(directory)
|
||||||
|
diagnostics = {trial["trial_id"]: trial_diagnostics(directory, trial)
|
||||||
|
for trial in review["trials"]}
|
||||||
|
return review, diagnostics
|
||||||
|
self.open_button.setEnabled(False)
|
||||||
|
self.message.setText(t("review.checking"))
|
||||||
|
self.message.show()
|
||||||
|
self._job = ReviewJob(read, self._loaded)
|
||||||
|
self._job.start()
|
||||||
|
|
||||||
|
@Slot(object, object)
|
||||||
|
def _loaded(self, value, error):
|
||||||
|
self._job = None
|
||||||
|
self.open_button.setEnabled(True)
|
||||||
|
if error is not None:
|
||||||
|
self.message.setText(t("calibration.error").format(detail=error))
|
||||||
|
self.message.show()
|
||||||
|
return
|
||||||
|
directory = self._loading_directory
|
||||||
|
try:
|
||||||
|
overview = RecordOverviewDialog(directory, self, review=value[0], diagnostics=value[1])
|
||||||
|
except (OSError, ValueError, KeyError, TypeError) as exc:
|
||||||
|
self.message.setText(t("calibration.error").format(detail=exc))
|
||||||
|
self.message.show()
|
||||||
|
return
|
||||||
|
overview.setWindowFlags(Qt.WindowType.Widget)
|
||||||
|
if self.overview is not None:
|
||||||
|
self.viewer.takeWidget()
|
||||||
|
self.overview.hide()
|
||||||
|
self.overview.deleteLater()
|
||||||
|
else:
|
||||||
|
self.content_layout.takeAt(self.content_layout.count() - 1)
|
||||||
|
self.content_layout.addWidget(self.viewer, 1)
|
||||||
|
self.overview = overview
|
||||||
|
self.directory = directory
|
||||||
|
self.path_label.setText(directory.name)
|
||||||
|
self.path_label.setToolTip(str(directory))
|
||||||
|
self.message.clear()
|
||||||
|
self.message.hide()
|
||||||
|
self.viewer.setWidget(overview)
|
||||||
|
overview.show()
|
||||||
|
|
||||||
|
|
||||||
|
class RecordOverviewDialog(QDialog):
|
||||||
|
def reject(self):
|
||||||
|
if self.isWindow():
|
||||||
|
super().reject()
|
||||||
|
|
||||||
|
def __init__(self, directory: Path, parent=None, *, review=None, diagnostics=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.directory = directory
|
||||||
|
self._job = None
|
||||||
|
self.review = review if review is not None else review_session(directory)
|
||||||
|
self.diagnostics = diagnostics if diagnostics is not None else {
|
||||||
|
trial["trial_id"]: trial_diagnostics(directory, trial) for trial in self.review["trials"]}
|
||||||
|
self.outline, self.outline_state = recorded_outline(self.review)
|
||||||
|
self._uv_bounds = None
|
||||||
|
self._uv_label = None
|
||||||
|
self._fitting_uv = False
|
||||||
|
self._dirty = False
|
||||||
|
self._selected_site_id = None
|
||||||
|
self.plan = None
|
||||||
|
self._review_path = None
|
||||||
|
self.rows = []
|
||||||
|
self.setWindowTitle(t("overview.title"))
|
||||||
|
self.resize(1240, 850)
|
||||||
|
self.setObjectName("recordOverview")
|
||||||
|
self.setStyleSheet("#recordOverview QLabel, #recordOverview QTableWidget, #recordOverview QComboBox, "
|
||||||
|
"#recordOverview QPushButton, #recordOverview QToolButton {font-size: 14px;} "
|
||||||
|
"#recordOverview QPushButton, #recordOverview QComboBox {min-height: 22px; padding: 3px 10px;} "
|
||||||
|
"#recordOverview QHeaderView::section {font-size: 13px;}")
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(6, 6, 6, 6)
|
||||||
|
layout.setSpacing(6)
|
||||||
|
self.identity = QLabel(self)
|
||||||
|
self.identity.setWordWrap(True)
|
||||||
|
layout.addWidget(self.identity)
|
||||||
|
self.notice = QLabel(self)
|
||||||
|
self.notice.setWordWrap(True)
|
||||||
|
self.notice.setToolTip(t("overview.notice"))
|
||||||
|
layout.addWidget(self.notice)
|
||||||
|
toolbar = QHBoxLayout()
|
||||||
|
self.map_mode = QComboBox(self)
|
||||||
|
self.map_mode.addItems([t("overview.coverage"), t("overview.sensitivity")])
|
||||||
|
toolbar.addWidget(self.map_mode)
|
||||||
|
self.map_mode.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
|
||||||
|
self.tools = QToolButton(self)
|
||||||
|
self.tools.setText(t("overview.tools"))
|
||||||
|
self.tools.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||||
|
menu = QMenu(self.tools)
|
||||||
|
for name, slot in (("video", self._video), ("summary", self._publish_summary),
|
||||||
|
("import_plan", self._import_plan),
|
||||||
|
("clear_plan", self._clear_plan), ("load", self._load)):
|
||||||
|
action = menu.addAction(t("overview." + name) if name != "summary" else t("summary.retry"))
|
||||||
|
action.setObjectName("overview_" + name)
|
||||||
|
action.triggered.connect(slot)
|
||||||
|
self.tools.setMenu(menu)
|
||||||
|
toolbar.addWidget(self.tools)
|
||||||
|
toolbar.addStretch(1)
|
||||||
|
layout.addLayout(toolbar)
|
||||||
|
self.summary = QLabel(self)
|
||||||
|
self.summary.setWordWrap(True)
|
||||||
|
layout.addWidget(self.summary)
|
||||||
|
charts = QHBoxLayout()
|
||||||
|
self.uv_chart, self.response_chart = QChart(), QChart()
|
||||||
|
self.timeline_chart = QChart()
|
||||||
|
self.uv_view, self.response_view = QChartView(self.uv_chart), QChartView(self.response_chart)
|
||||||
|
self.timeline_view = QChartView(self.timeline_chart)
|
||||||
|
for view in (self.uv_view, self.response_view, self.timeline_view):
|
||||||
|
view.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
view.chart().setMinimumSize(0, 0)
|
||||||
|
view.setMinimumHeight(260)
|
||||||
|
font = view.chart().font()
|
||||||
|
font.setPixelSize(14)
|
||||||
|
view.chart().setFont(font)
|
||||||
|
uv_column = QVBoxLayout()
|
||||||
|
uv_column.addWidget(self.uv_view, 1)
|
||||||
|
self.outline_message = QLabel(t("overview.outline_" + self.outline_state), self)
|
||||||
|
self.outline_message.setWordWrap(True)
|
||||||
|
uv_column.addWidget(self.outline_message)
|
||||||
|
charts.addLayout(uv_column, 2)
|
||||||
|
self.plots = QTabWidget(self)
|
||||||
|
self.plots.addTab(self.timeline_view, t("overview.timeline"))
|
||||||
|
self.plots.addTab(self.response_view, t("overview.response"))
|
||||||
|
charts.addWidget(self.plots, 3)
|
||||||
|
self.uv_chart.plotAreaChanged.connect(self._fit_uv_axes)
|
||||||
|
layout.addLayout(charts, 2)
|
||||||
|
self.table = QTableWidget(0, 11, self)
|
||||||
|
self.table.setObjectName("overviewSites")
|
||||||
|
self.table.setMinimumHeight(125)
|
||||||
|
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||||
|
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||||
|
self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
|
||||||
|
self.table.setHorizontalHeaderLabels([t("overview.col_" + key) for key in (
|
||||||
|
"order", "site", "uv", "xyz", "status", "trials", "windows", "levels", "sensitivity", "range", "fit_trials")])
|
||||||
|
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
self.table.horizontalHeader().setStretchLastSection(True)
|
||||||
|
for column in (0, 3, 9, 10):
|
||||||
|
self.table.setColumnHidden(column, True)
|
||||||
|
layout.addWidget(self.table, 1)
|
||||||
|
selection = QHBoxLayout()
|
||||||
|
self.trials = QComboBox(self)
|
||||||
|
self.trials.setObjectName("overviewTrial")
|
||||||
|
self.details_button = QPushButton(t("overview.details"), self)
|
||||||
|
selection.addWidget(QLabel(t("overview.trial_filter"), self))
|
||||||
|
selection.addWidget(self.trials)
|
||||||
|
selection.addStretch(1)
|
||||||
|
selection.addWidget(self.details_button)
|
||||||
|
self.save_button = QPushButton(t("overview.save"), self)
|
||||||
|
self.save_button.setObjectName("overview_save")
|
||||||
|
self.save_button.clicked.connect(self._save)
|
||||||
|
selection.addWidget(self.save_button)
|
||||||
|
layout.addLayout(selection)
|
||||||
|
self.selected_summary = QLabel(self)
|
||||||
|
self.selected_summary.setWordWrap(True)
|
||||||
|
layout.addWidget(self.selected_summary)
|
||||||
|
self.diagnostic_summary = QLabel(self)
|
||||||
|
self.diagnostic_summary.setWordWrap(True)
|
||||||
|
layout.addWidget(self.diagnostic_summary)
|
||||||
|
self.message = QLabel(self)
|
||||||
|
self.message.setWordWrap(True)
|
||||||
|
layout.addWidget(self.message)
|
||||||
|
self.selected_summary.hide()
|
||||||
|
self.plots.currentChanged.connect(lambda index: self.selected_summary.setVisible(index == 1))
|
||||||
|
self.table.itemSelectionChanged.connect(self._select_site)
|
||||||
|
self.map_mode.currentIndexChanged.connect(self._show_uv)
|
||||||
|
self.trials.currentIndexChanged.connect(self._show_response)
|
||||||
|
self.trials.currentIndexChanged.connect(self._show_timeline)
|
||||||
|
self.details_button.clicked.connect(self._details)
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _refresh(self):
|
||||||
|
sources = sorted({d.get("source", {}).get("kind", "") for d in self.diagnostics.values()} - {""})
|
||||||
|
date = self.review.get("started_at")
|
||||||
|
if date:
|
||||||
|
try:
|
||||||
|
date = datetime.fromisoformat(date).astimezone().strftime("%Y-%m-%d %H:%M:%S %z")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
self.identity.setText(t("overview.identity").format(
|
||||||
|
model=self.review.get("sensor_model") or "—", sn=self.review.get("sensor_sn") or "—",
|
||||||
|
date=date or "—", source=" / ".join(t("overview.source_" + s) for s in sources) or "—",
|
||||||
|
status=t("overview.capture_in_progress") if self.review["session_status"] == "in_progress"
|
||||||
|
else t("calibration." + self.review["session_status"]))
|
||||||
|
+ ("" if RecordingOptions.from_block(self.review.get("recording")).video
|
||||||
|
else "\n" + t("overview.video_not_recorded")))
|
||||||
|
self.identity.setToolTip(self.review["session_id"] + "\n" + str(self.directory))
|
||||||
|
selected = self.table.currentRow()
|
||||||
|
self.rows = overview_rows(self.review, self.plan)
|
||||||
|
self.table.blockSignals(True)
|
||||||
|
self.table.setRowCount(len(self.rows))
|
||||||
|
for index, row in enumerate(self.rows):
|
||||||
|
fit = row["fit"]
|
||||||
|
sensitivity = (f"{fit['sensitivity_raw_per_n']:.6g} · " + t("overview." + row["fit_basis"])) if fit else "—"
|
||||||
|
values = [", ".join(map(str, row["orders"])) or "—", row["site_id"] or "—",
|
||||||
|
", ".join(map(str, row["uv_xy_px"])),
|
||||||
|
", ".join(f"{x:.3f}" for x in row["position_sensor_mm"]) if row["position_sensor_mm"] else "—",
|
||||||
|
t("overview." + row["status"]), f"{row['complete_trials']}/{row['trials']}",
|
||||||
|
f"{row['confirmed_windows']}/{row['windows']}",
|
||||||
|
", ".join(f"{v:g}" for v in row["confirmed_levels_n"]) or "—", sensitivity,
|
||||||
|
"–".join(f"{v:g}" for v in row["fit_range_n"]) if fit else "—", str(row["fit_trials"])]
|
||||||
|
analysis = "reviewed" if row["windows"] and row["confirmed_windows"] == row["windows"] else "pending" if row["windows"] else "no_analysis"
|
||||||
|
values[4] = t("overview.analysis_" + analysis)
|
||||||
|
for column, value in enumerate(values):
|
||||||
|
self.table.setItem(index, column, QTableWidgetItem(value))
|
||||||
|
self.table.item(index, 1).setToolTip(t("overview.site_details").format(
|
||||||
|
xyz=values[3], attempts=values[5], range=values[9], trials=values[10]))
|
||||||
|
self.table.blockSignals(False)
|
||||||
|
collected = sum(row["trials"] > 0 for row in self.rows)
|
||||||
|
self.summary.setText(t("overview.summary").format(
|
||||||
|
collected=collected, planned=sum(r["planned"] for r in self.rows) if self.plan else "—",
|
||||||
|
missing=sum(r["status"] == "uncollected" for r in self.rows) if self.plan else "—",
|
||||||
|
incomplete=sum(r["status"] == "incomplete" for r in self.rows),
|
||||||
|
pending=sum(r["status"] == "pending" for r in self.rows),
|
||||||
|
reviewed=sum(r["status"] == "reviewed" for r in self.rows),
|
||||||
|
extra=sum(not r["planned"] and r["trials"] > 0 for r in self.rows) if self.plan else "—",
|
||||||
|
) + ("\n" + t("overview.no_plan") if self.plan is None else ""))
|
||||||
|
self.message.setText(t("overview.unsaved") if self._dirty else "")
|
||||||
|
self.message.setVisible(self._dirty)
|
||||||
|
if self.rows:
|
||||||
|
self.table.selectRow(min(max(selected, 0), len(self.rows) - 1))
|
||||||
|
self._select_site()
|
||||||
|
|
||||||
|
def _select_site(self):
|
||||||
|
index = self.table.currentRow()
|
||||||
|
previous = self.trials.currentData()
|
||||||
|
self.trials.blockSignals(True)
|
||||||
|
self.trials.clear()
|
||||||
|
self.trials.addItem(t("overview.all_trials"), None)
|
||||||
|
if index >= 0:
|
||||||
|
site_id = self.rows[index]["site_id"]
|
||||||
|
for trial in self.review["trials"]:
|
||||||
|
if site_id is not None and trial.get("site_id") == site_id:
|
||||||
|
self.trials.addItem(trial["trial_id"] + " · " + t("calibration." + trial["status"]), trial["trial_id"])
|
||||||
|
match = self.trials.findData(previous) if site_id == self._selected_site_id else -1
|
||||||
|
self.trials.setCurrentIndex(match if match >= 0 else 1 if self.trials.count() > 1 else 0)
|
||||||
|
self._selected_site_id = site_id
|
||||||
|
self.trials.blockSignals(False)
|
||||||
|
self._show_uv()
|
||||||
|
self._show_response()
|
||||||
|
self._show_timeline()
|
||||||
|
|
||||||
|
_clear_chart = staticmethod(clear_chart)
|
||||||
|
_axes = staticmethod(add_value_axes)
|
||||||
|
|
||||||
|
def _show_uv(self):
|
||||||
|
self._uv_bounds = None
|
||||||
|
if self._uv_label is not None:
|
||||||
|
self.uv_chart.scene().removeItem(self._uv_label)
|
||||||
|
self._uv_label = None
|
||||||
|
self._clear_chart(self.uv_chart)
|
||||||
|
sensitivity = self.map_mode.currentIndex() == 1
|
||||||
|
values = [s["confirmed_fit"]["sensitivity_raw_per_n"] for s in self.review["sites"] if s["confirmed_fit"]]
|
||||||
|
low, high = (min(values), max(values)) if values else (0, 0)
|
||||||
|
colors = {"uncollected": "#929aa7", "incomplete": "#ca4c46", "pending": "#d8911d", "reviewed": "#29835f"}
|
||||||
|
for index, row in enumerate(self.rows):
|
||||||
|
color = QColor(colors[row["status"]])
|
||||||
|
if sensitivity:
|
||||||
|
color = QColor("#929aa7")
|
||||||
|
if row["fit_basis"] == "confirmed":
|
||||||
|
value = row["fit"]["sensitivity_raw_per_n"]
|
||||||
|
ratio = (value - low) / (high - low) if high > low else .5
|
||||||
|
color = QColor.fromHsvF((1 - ratio) * .65, .8, .85)
|
||||||
|
series = QScatterSeries()
|
||||||
|
series.setColor(color)
|
||||||
|
series.setBorderColor(QColor("#151515") if index == self.table.currentRow() else color)
|
||||||
|
series.setMarkerSize(15 if index == self.table.currentRow() else 10)
|
||||||
|
series.append(*row["uv_xy_px"])
|
||||||
|
series.clicked.connect(lambda _point, target=index: self.table.selectRow(target))
|
||||||
|
self.uv_chart.addSeries(series)
|
||||||
|
if index == self.table.currentRow():
|
||||||
|
self._uv_label = QGraphicsSimpleTextItem(row["site_id"] or str(row["orders"]), self.uv_chart)
|
||||||
|
self._uv_label.setFont(self.uv_chart.font())
|
||||||
|
self._uv_label.setZValue(10)
|
||||||
|
self._uv_label_point = QPointF(*row["uv_xy_px"])
|
||||||
|
self._axes(self.uv_chart)
|
||||||
|
bounds = [tuple(row["uv_xy_px"]) for row in self.rows] + [(0, 0)]
|
||||||
|
if self.outline:
|
||||||
|
ox, oy = self.outline["origin_raster_xy_px"]
|
||||||
|
for region, color in (("surface", "#687585"), ("patch", "#719fb8")):
|
||||||
|
for ring in self.outline[region]:
|
||||||
|
series = QLineSeries()
|
||||||
|
series.setColor(QColor(color))
|
||||||
|
for x, y in ring + ring[:1]:
|
||||||
|
series.append(x - ox, oy - y)
|
||||||
|
bounds.append((x - ox, oy - y))
|
||||||
|
self.uv_chart.addSeries(series)
|
||||||
|
for axis in self.uv_chart.axes():
|
||||||
|
series.attachAxis(axis)
|
||||||
|
xs, ys = zip(*bounds)
|
||||||
|
self._uv_bounds = min(xs), max(xs), min(ys), max(ys)
|
||||||
|
for axis in self.uv_chart.axes(Qt.Orientation.Horizontal):
|
||||||
|
axis.setTitleText("U (px)")
|
||||||
|
axis.setLabelFormat("%.0f")
|
||||||
|
for axis in self.uv_chart.axes(Qt.Orientation.Vertical):
|
||||||
|
axis.setTitleText("V (px)")
|
||||||
|
axis.setLabelFormat("%.0f")
|
||||||
|
self.uv_chart.legend().hide()
|
||||||
|
self.uv_chart.setTitle(t("overview.sensitivity_map").format(low=low, high=high)
|
||||||
|
if sensitivity else t("overview.coverage_map"))
|
||||||
|
self._fit_uv_axes()
|
||||||
|
|
||||||
|
def _fit_uv_axes(self, *_args):
|
||||||
|
area = self.uv_chart.plotArea()
|
||||||
|
if self._fitting_uv or self._uv_bounds is None or area.width() <= 0 or area.height() <= 0:
|
||||||
|
return
|
||||||
|
self._fitting_uv = True
|
||||||
|
try:
|
||||||
|
left, right, bottom, top = self._uv_bounds
|
||||||
|
scale = max(max(right - left, 2) / area.width(), max(top - bottom, 2) / area.height()) * 1.12
|
||||||
|
for horizontal in (True, False):
|
||||||
|
axis = self.uv_chart.axes(Qt.Orientation.Horizontal if horizontal else Qt.Orientation.Vertical)[0]
|
||||||
|
center = (left + right) / 2 if horizontal else (bottom + top) / 2
|
||||||
|
span = scale * (area.width() if horizontal else area.height())
|
||||||
|
axis.setRange(center - span / 2, center + span / 2)
|
||||||
|
if self._uv_label is not None:
|
||||||
|
self._uv_label.setPos(self.uv_chart.mapToPosition(self._uv_label_point) + QPointF(8, -20))
|
||||||
|
finally:
|
||||||
|
self._fitting_uv = False
|
||||||
|
|
||||||
|
def _show_response(self):
|
||||||
|
self._clear_chart(self.response_chart)
|
||||||
|
index = self.table.currentRow()
|
||||||
|
site_id = self.rows[index]["site_id"] if index >= 0 else None
|
||||||
|
site = next((s for s in self.review["sites"] if s["site_id"] == site_id), None)
|
||||||
|
trial_id = self.trials.currentData()
|
||||||
|
windows = [w for w in site["windows"] if trial_id is None or w["trial_id"] == trial_id] if site else []
|
||||||
|
attempts = [t for t in self.review["trials"] if site is not None and t.get("site_id") == site_id
|
||||||
|
and (trial_id is None or t["trial_id"] == trial_id)]
|
||||||
|
self.details_button.setEnabled(bool(attempts))
|
||||||
|
self.details_button.setText(t("overview.details" if windows else "overview.inspect_incomplete"))
|
||||||
|
self.notice.setText(t("overview.guide_missing" if not attempts else "overview.guide_incomplete" if not windows
|
||||||
|
else "overview.guide_save" if self._dirty else "overview.guide_check"))
|
||||||
|
for trial_index, trial in enumerate(sorted({w["trial_id"] for w in windows})):
|
||||||
|
for branch, color in (("zero", "#657388"), ("load", "#167ac6"), ("unload", "#df8b13")):
|
||||||
|
for confirmed in (True, False):
|
||||||
|
points = [w for w in windows if w["trial_id"] == trial and w["branch"] == branch
|
||||||
|
and w["status"] == "candidate" and w["stability_verified"] == confirmed]
|
||||||
|
if not points:
|
||||||
|
continue
|
||||||
|
series = QScatterSeries()
|
||||||
|
series.setName(trial + " · " + t("calibration." + branch) + " · " + t("overview.confirmed" if confirmed else "overview.candidate"))
|
||||||
|
series.setMarkerShape(QScatterSeries.MarkerShape.MarkerShapeCircle if confirmed else QScatterSeries.MarkerShape.MarkerShapeRectangle)
|
||||||
|
series.setColor(QColor(color).lighter(100 + 20 * (trial_index % 4)))
|
||||||
|
series.setMarkerSize(10 if confirmed else 7)
|
||||||
|
for window in points:
|
||||||
|
series.append(window["reference_n"], window["raw_mean"])
|
||||||
|
self.response_chart.addSeries(series)
|
||||||
|
fit = (site["confirmed_fit"] or site["fit"]) if site else None
|
||||||
|
if fit and windows:
|
||||||
|
training = [w for w in site["windows"] if w["branch"] in {"zero", "load"}
|
||||||
|
and w["status"] == "candidate" and (not site["confirmed_fit"] or w["stability_verified"])]
|
||||||
|
line = QLineSeries()
|
||||||
|
line.setName(t("overview.site_fit"))
|
||||||
|
for load in (min(w["reference_n"] for w in training), max(w["reference_n"] for w in training)):
|
||||||
|
line.append(load, (load - fit["offset_n"]) / fit["gain_n_per_raw"])
|
||||||
|
self.response_chart.addSeries(line)
|
||||||
|
self._axes(self.response_chart)
|
||||||
|
for axis in self.response_chart.axes(Qt.Orientation.Horizontal):
|
||||||
|
axis.setTitleText(t("review.force_axis"))
|
||||||
|
for axis in self.response_chart.axes(Qt.Orientation.Vertical):
|
||||||
|
axis.setTitleText(t("review.raw_axis"))
|
||||||
|
self.response_chart.setTitle(t("overview.response_title"))
|
||||||
|
self.response_chart.legend().setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||||
|
self.selected_summary.setText(t("overview.selected_fit").format(
|
||||||
|
sensitivity=f"{fit['sensitivity_raw_per_n']:.6g}" if fit else "—",
|
||||||
|
basis=t("overview.confirmed" if site and site["confirmed_fit"] else "overview.candidate") if fit else "—",
|
||||||
|
) if windows else t("overview.no_windows"))
|
||||||
|
|
||||||
|
def _show_timeline(self):
|
||||||
|
self._clear_chart(self.timeline_chart)
|
||||||
|
index = self.table.currentRow()
|
||||||
|
site_id = self.rows[index]["site_id"] if index >= 0 else None
|
||||||
|
trial_id = self.trials.currentData()
|
||||||
|
reports = [self.diagnostics[t["trial_id"]] for t in self.review["trials"]
|
||||||
|
if site_id is not None and t.get("site_id") == site_id
|
||||||
|
and (trial_id is None or t["trial_id"] == trial_id)]
|
||||||
|
summaries, reference_series = [], []
|
||||||
|
for report in reports:
|
||||||
|
trace = report["trace"]
|
||||||
|
summaries.append(t("overview.diagnostic").format(
|
||||||
|
trial=report["trial_id"], status=t("calibration." + report["status"]),
|
||||||
|
samples=len(trace), invalid=report["invalid"], dropped=report["dropped"],
|
||||||
|
gap=f"{report['max_gap_s']:.3g}" if report["max_gap_s"] is not None else "—",
|
||||||
|
reason=report["reason"] or "—"))
|
||||||
|
if report["errors"]:
|
||||||
|
summaries.append(t("overview.read_errors").format(detail=", ".join(report["errors"])))
|
||||||
|
if not trace:
|
||||||
|
continue
|
||||||
|
origin = trace[0][0]
|
||||||
|
gaps = [(b[0] - a[0]) / 1e9 for a, b in zip(trace, trace[1:]) if b[0] > a[0]]
|
||||||
|
limit = max(statistics.median(gaps) * 5, .25) if gaps else .25
|
||||||
|
series = None
|
||||||
|
previous = None
|
||||||
|
for stamp, raw in trace:
|
||||||
|
if series is None or (previous is not None and ((stamp - previous) / 1e9 > limit
|
||||||
|
or bisect_right(report["breaks_ns"], previous) < bisect_left(report["breaks_ns"], stamp))):
|
||||||
|
series = QLineSeries()
|
||||||
|
series.setName(report["trial_id"])
|
||||||
|
series.setColor(QColor("#167ac6"))
|
||||||
|
series.setPointsVisible(True)
|
||||||
|
self.timeline_chart.addSeries(series)
|
||||||
|
series.append((stamp - origin) / 1e9, raw)
|
||||||
|
previous = stamp
|
||||||
|
for ref in report["references"]:
|
||||||
|
try:
|
||||||
|
start, end = int(ref["t_start_host_mono_ns"]), int(ref["t_end_host_mono_ns"])
|
||||||
|
force = float(ref["F_ref_normal_N"])
|
||||||
|
except (KeyError, ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if end <= start or not math.isfinite(force):
|
||||||
|
continue
|
||||||
|
series = QLineSeries()
|
||||||
|
series.setColor(QColor("#df8b13"))
|
||||||
|
series.append((start - origin) / 1e9, force)
|
||||||
|
series.append((end - origin) / 1e9, force)
|
||||||
|
reference_series.append(series)
|
||||||
|
self._axes(self.timeline_chart)
|
||||||
|
for axis in self.timeline_chart.axes(Qt.Orientation.Horizontal):
|
||||||
|
axis.setTitleText(t("overview.time_axis"))
|
||||||
|
for axis in self.timeline_chart.axes(Qt.Orientation.Vertical):
|
||||||
|
axis.setTitleText(t("review.raw_axis"))
|
||||||
|
if reference_series:
|
||||||
|
reference_axis = QValueAxis()
|
||||||
|
reference_axis.setTitleText(t("overview.reference_axis"))
|
||||||
|
forces = [p.y() for series in reference_series for p in series.points()]
|
||||||
|
reference_axis.setRange(min(0, min(forces)), max(1, max(forces)) * 1.1)
|
||||||
|
self.timeline_chart.addAxis(reference_axis, Qt.AlignmentFlag.AlignRight)
|
||||||
|
horizontal = self.timeline_chart.axes(Qt.Orientation.Horizontal)[0]
|
||||||
|
for series in reference_series:
|
||||||
|
self.timeline_chart.addSeries(series)
|
||||||
|
series.attachAxis(horizontal)
|
||||||
|
series.attachAxis(reference_axis)
|
||||||
|
self.timeline_chart.legend().hide()
|
||||||
|
self.timeline_chart.setTitle(t("overview.timeline_title") if any(r["trace"] for r in reports) else t("overview.no_trace"))
|
||||||
|
self.selected_summary.setToolTip("\n".join(summaries))
|
||||||
|
self.diagnostic_summary.setText("\n".join(summaries) if len(reports) == 1
|
||||||
|
else t("overview.choose_trial") if reports else t("overview.guide_missing"))
|
||||||
|
|
||||||
|
def _import_plan(self):
|
||||||
|
path, _ = QFileDialog.getOpenFileName(self, t("overview.import_plan"), str(self.directory), "CSV (*.csv)")
|
||||||
|
if path:
|
||||||
|
try:
|
||||||
|
plan = read_plan(Path(path), self.review["uv_definition"])
|
||||||
|
overview_rows(self.review, plan)
|
||||||
|
self.plan = plan
|
||||||
|
self._dirty = True
|
||||||
|
self._refresh()
|
||||||
|
except (OSError, ValueError, KeyError, TypeError) as exc:
|
||||||
|
self.message.setText(t("calibration.error").format(detail=exc))
|
||||||
|
self.message.show()
|
||||||
|
|
||||||
|
def _clear_plan(self):
|
||||||
|
self.plan = None
|
||||||
|
self._dirty = True
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _details(self):
|
||||||
|
row = self.rows[self.table.currentRow()]
|
||||||
|
index = next(i for i, site in enumerate(self.review["sites"]) if site["site_id"] == row["site_id"])
|
||||||
|
trial = self.trials.currentData()
|
||||||
|
if not any(trial is None or w["trial_id"] == trial for w in self.review["sites"][index]["windows"]):
|
||||||
|
self.plots.setCurrentIndex(0)
|
||||||
|
self.notice.setText(t("overview.guide_incomplete"))
|
||||||
|
return
|
||||||
|
before = dict(self.review["selections"])
|
||||||
|
dialog = SessionReviewDialog(self.directory, self, review=self.review)
|
||||||
|
dialog.sites.setCurrentIndex(index)
|
||||||
|
trial = self.trials.currentData()
|
||||||
|
if trial:
|
||||||
|
windows = dialog.review["sites"][index]["windows"]
|
||||||
|
match = next((i for i, w in enumerate(windows) if w["trial_id"] == trial), None)
|
||||||
|
if match is not None:
|
||||||
|
dialog.table.selectRow(match)
|
||||||
|
dialog.exec()
|
||||||
|
self.review = dialog.review
|
||||||
|
self._dirty = self._dirty or before != self.review["selections"]
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _video(self):
|
||||||
|
"""Open the pictures of the selected point's current window."""
|
||||||
|
from ..video_review import trial_is_closed
|
||||||
|
from .video_review_dialog import VideoReviewDialog
|
||||||
|
|
||||||
|
index = self.table.currentRow()
|
||||||
|
site_id = self.rows[index]["site_id"] if index >= 0 else None
|
||||||
|
site = next((s for s in self.review["sites"] if s["site_id"] == site_id), None)
|
||||||
|
if site is None or not site.get("windows"):
|
||||||
|
self.notice.setText(t("overview.guide_missing"))
|
||||||
|
return None
|
||||||
|
trial_filter = self.trials.currentData()
|
||||||
|
window = next(
|
||||||
|
(w for w in site["windows"] if trial_filter is None or w["trial_id"] == trial_filter),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if window is None:
|
||||||
|
self.notice.setText(t("overview.guide_incomplete"))
|
||||||
|
return None
|
||||||
|
trial = next(
|
||||||
|
(item for item in self.review.get("trials") or []
|
||||||
|
if item["trial_id"] == window["trial_id"]), None,
|
||||||
|
)
|
||||||
|
if not trial_is_closed(self.directory, trial):
|
||||||
|
self.notice.setText(t("overview.guide_incomplete"))
|
||||||
|
return None
|
||||||
|
dialog = VideoReviewDialog(
|
||||||
|
self.directory, self.review,
|
||||||
|
trial_id=window["trial_id"], step_index=int(window["step_index"]), parent=self,
|
||||||
|
)
|
||||||
|
# Non-blocking modal: the operator keeps one window, but the caller can
|
||||||
|
# still be tested without entering a nested event loop.
|
||||||
|
dialog.setModal(True)
|
||||||
|
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||||
|
dialog.show()
|
||||||
|
return dialog
|
||||||
|
|
||||||
|
def _save(self):
|
||||||
|
review, plan = self.review, self.plan
|
||||||
|
directory = self.directory
|
||||||
|
|
||||||
|
def action():
|
||||||
|
path = save_overview(review, plan)
|
||||||
|
from ..summary_export import publish_reviewed_summary
|
||||||
|
|
||||||
|
summary_path = publish_reviewed_summary(directory, review, review_path=path)
|
||||||
|
return path, summary_path
|
||||||
|
|
||||||
|
self._start_job("save", action)
|
||||||
|
|
||||||
|
def _publish_summary(self):
|
||||||
|
"""Re-publish the summary for the loaded review (the retry path)."""
|
||||||
|
review = self.review
|
||||||
|
directory = self.directory
|
||||||
|
review_path = self._review_path
|
||||||
|
|
||||||
|
def action():
|
||||||
|
from ..summary_export import publish_reviewed_summary
|
||||||
|
|
||||||
|
return publish_reviewed_summary(directory, review, review_path=review_path)
|
||||||
|
|
||||||
|
self._start_job("summary", action)
|
||||||
|
|
||||||
|
@Slot(object, object)
|
||||||
|
def _job_finished(self, value, error):
|
||||||
|
self._job = None
|
||||||
|
self.setEnabled(True)
|
||||||
|
if error is not None:
|
||||||
|
self.message.setText(t("calibration.error").format(detail=error))
|
||||||
|
self.message.show()
|
||||||
|
elif self._job_kind == "save":
|
||||||
|
self._dirty = False
|
||||||
|
self._show_response()
|
||||||
|
review_path, summary_path = value
|
||||||
|
self._review_path = review_path
|
||||||
|
self.message.setText(t("overview.saved_summary").format(
|
||||||
|
path=review_path, summary=summary_path))
|
||||||
|
self.message.show()
|
||||||
|
self.message.setToolTip(f"{review_path}\n{summary_path}")
|
||||||
|
elif self._job_kind == "summary":
|
||||||
|
self.message.setText(t("summary.done").format(path=value))
|
||||||
|
self.message.show()
|
||||||
|
else:
|
||||||
|
self.review, self.plan, self._review_path = value
|
||||||
|
self._dirty = False
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _start_job(self, kind, action):
|
||||||
|
if self._job is not None:
|
||||||
|
return
|
||||||
|
self._job_kind = kind
|
||||||
|
self.setEnabled(False)
|
||||||
|
self.message.setText(t("review.checking"))
|
||||||
|
self.message.show()
|
||||||
|
self._job = ReviewJob(action, self._job_finished)
|
||||||
|
self._job.start()
|
||||||
|
|
||||||
|
def _load(self):
|
||||||
|
path, _ = QFileDialog.getOpenFileName(self, t("overview.load"), str(self.directory / "derived"), "JSON (*.json)")
|
||||||
|
if path:
|
||||||
|
directory = self.directory
|
||||||
|
saved = Path(path)
|
||||||
|
|
||||||
|
def action():
|
||||||
|
review, plan = load_overview(directory, saved)
|
||||||
|
return review, plan, saved
|
||||||
|
|
||||||
|
self._start_job("load", action)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Open a file or folder in the platform's file manager.
|
||||||
|
|
||||||
|
One implementation for the menu's "show config" actions and for the capture page's
|
||||||
|
session row: the operator has the recording path in front of them and wants the
|
||||||
|
folder, not a path string to paste. Deliberately Qt-free — it hands the path to
|
||||||
|
the OS, and a headless test can assert the exact argument the file manager gets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def reveal(path: str | Path | None, select: str | Path | None = None) -> bool:
|
||||||
|
"""Show ``path`` in the platform file manager; return whether it launched.
|
||||||
|
|
||||||
|
On Windows a ``select`` names a file to highlight, which is the
|
||||||
|
``explorer /select,<file>`` form. Explorer expects that switch and the quoted
|
||||||
|
path as one command-line string; passing the two as separate argv entries
|
||||||
|
breaks on a path containing a space. When the selected file has gone since
|
||||||
|
the operator clicked, the containing folder is opened instead of failing
|
||||||
|
silently.
|
||||||
|
|
||||||
|
A missing path, an empty path or a failed launch returns ``False`` and is
|
||||||
|
logged: this is a convenience action, never a reason to raise into the event
|
||||||
|
loop that called it.
|
||||||
|
"""
|
||||||
|
if path is None or not str(path).strip():
|
||||||
|
return False
|
||||||
|
target = Path(path)
|
||||||
|
if not target.exists():
|
||||||
|
logger.warning("Cannot reveal a path that does not exist: %s", target)
|
||||||
|
return False
|
||||||
|
folder = target if target.is_dir() else target.parent
|
||||||
|
selected = None if select is None else Path(select)
|
||||||
|
if selected is not None and not selected.exists():
|
||||||
|
logger.warning("The file to select is gone; opening its folder instead: %s", selected)
|
||||||
|
selected = None
|
||||||
|
command: str | list[str]
|
||||||
|
if sys.platform == "win32":
|
||||||
|
if selected is not None:
|
||||||
|
command = f'explorer /select,"{selected}"'
|
||||||
|
else:
|
||||||
|
command = ["explorer", str(folder)]
|
||||||
|
elif sys.platform == "darwin": # pragma: no cover - Windows is the target
|
||||||
|
command = ["open", str(folder)]
|
||||||
|
else: # pragma: no cover - Windows is the target
|
||||||
|
command = ["xdg-open", str(folder)]
|
||||||
|
try:
|
||||||
|
subprocess.Popen(command)
|
||||||
|
except OSError:
|
||||||
|
logger.exception("Could not open %s", folder)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Run recording reads and integrity checks without blocking Qt's event loop."""
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal
|
||||||
|
|
||||||
|
|
||||||
|
class _Result(QObject):
|
||||||
|
ready = Signal(object, object)
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewJob(QRunnable):
|
||||||
|
def __init__(self, action, receiver):
|
||||||
|
super().__init__()
|
||||||
|
self.action = action
|
||||||
|
self.result = _Result()
|
||||||
|
self.result.ready.connect(receiver)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
QThreadPool.globalInstance().start(self)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
value = self.action()
|
||||||
|
except Exception as exc:
|
||||||
|
# Worker failures must return to Qt so controls are always restored.
|
||||||
|
self._emit(None, str(exc))
|
||||||
|
else:
|
||||||
|
self._emit(value, None)
|
||||||
|
|
||||||
|
def _emit(self, value, error) -> None:
|
||||||
|
"""Deliver the result, unless the window it belongs to has gone away."""
|
||||||
|
try:
|
||||||
|
self.result.ready.emit(value, error)
|
||||||
|
except RuntimeError:
|
||||||
|
# Closing the window deletes the receiver while a read is still in
|
||||||
|
# flight; there is nobody left to tell, and crashing the pool thread
|
||||||
|
# would be the only alternative.
|
||||||
|
return
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@ from PySide6.QtWidgets import (
|
|||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
|
QTabWidget,
|
||||||
QTableWidget,
|
QTableWidget,
|
||||||
QTableWidgetItem,
|
QTableWidgetItem,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
@@ -53,6 +54,7 @@ from ..sequence_io import (
|
|||||||
format_span,
|
format_span,
|
||||||
summarise,
|
summarise,
|
||||||
)
|
)
|
||||||
|
from .array_tool import ArrayTool
|
||||||
|
|
||||||
#: Profile fields, in form order: ``(key, locale_key, kind)``.
|
#: Profile fields, in form order: ``(key, locale_key, kind)``.
|
||||||
#: ``kind`` is ``choice`` (a combo box) or ``number`` (a line edit).
|
#: ``kind`` is ``choice`` (a combo box) or ``number`` (a line edit).
|
||||||
@@ -72,7 +74,7 @@ PROFILE_FIELDS: tuple[tuple[str, str, str], ...] = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
#: How many rows the two-column profile form has.
|
#: How many rows the two-column profile form has.
|
||||||
PROFILE_ROWS = (len(PROFILE_FIELDS) + 1) // 2
|
PROFILE_ROWS = (len(PROFILE_FIELDS) + 2) // 3
|
||||||
|
|
||||||
#: Point-table columns: ``(key, locale_key)``.
|
#: Point-table columns: ``(key, locale_key)``.
|
||||||
#:
|
#:
|
||||||
@@ -84,17 +86,25 @@ POINT_COLUMNS: tuple[tuple[str, str], ...] = (
|
|||||||
("x", "sequence.col_x"),
|
("x", "sequence.col_x"),
|
||||||
("y", "sequence.col_y"),
|
("y", "sequence.col_y"),
|
||||||
("z", "sequence.col_z"),
|
("z", "sequence.col_z"),
|
||||||
|
("u", "sequence.col_u"),
|
||||||
|
("v", "sequence.col_v"),
|
||||||
("status", "sequence.col_status"),
|
("status", "sequence.col_status"),
|
||||||
("sensitivity", "sequence.col_sensitivity"),
|
("sensitivity", "sequence.col_sensitivity"),
|
||||||
("samples", "sequence.col_samples"),
|
("samples", "sequence.col_samples"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: Column index by key, so no code has to count columns.
|
||||||
|
_COL = {key: index for index, (key, _locale_key) in enumerate(POINT_COLUMNS)}
|
||||||
|
|
||||||
|
#: Columns whose text is centred.
|
||||||
|
_CENTRED = ("u", "v", "status", "sensitivity", "samples")
|
||||||
|
|
||||||
#: Editability of the point table. Positions are *generated*, not typed: a typed
|
#: Editability of the point table. Positions are *generated*, not typed: a typed
|
||||||
#: coordinate is one that nothing has checked against the sensor.
|
#: coordinate is one that nothing has checked against the sensor.
|
||||||
_EDITABLE_POINT_COLUMNS: tuple[str, ...] = ()
|
_EDITABLE_POINT_COLUMNS: tuple[str, ...] = ()
|
||||||
|
|
||||||
#: Column widths of the point table, by column index.
|
#: Column widths of the point table, by column index.
|
||||||
_POINT_COLUMN_WIDTHS = (60, 150, 150, 150, 140, 190, 110)
|
_POINT_COLUMN_WIDTHS = (50, 110, 110, 110, 100, 100, 110, 160, 100)
|
||||||
|
|
||||||
#: Reserved controls and the phase that will fill each one.
|
#: Reserved controls and the phase that will fill each one.
|
||||||
PENDING_CONTROLS: tuple[str, ...] = ("generate", "simulate", "dry_run")
|
PENDING_CONTROLS: tuple[str, ...] = ("generate", "simulate", "dry_run")
|
||||||
@@ -117,6 +127,9 @@ VALIDATION_CHECKS: tuple[tuple[str, str], ...] = (
|
|||||||
("contact_confirmable", "sequence.check_contact"),
|
("contact_confirmable", "sequence.check_contact"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: Height the shared 2D and 3D boxes get on this page, in pixels.
|
||||||
|
SHARED_VIEW_MIN_HEIGHT = 420
|
||||||
|
|
||||||
#: Placeholder rows, so the flow shows its shape. Marked as unmeasured: a row
|
#: Placeholder rows, so the flow shows its shape. Marked as unmeasured: a row
|
||||||
#: with no status is a design, not a measurement.
|
#: with no status is a design, not a measurement.
|
||||||
_EXAMPLE_POINTS: tuple[tuple[float, float, float], ...] = (
|
_EXAMPLE_POINTS: tuple[tuple[float, float, float], ...] = (
|
||||||
@@ -148,6 +161,13 @@ class SequenceStep:
|
|||||||
class SequencePanel(QGroupBox):
|
class SequencePanel(QGroupBox):
|
||||||
"""Workflow P5: profile, preview, validation and the point list."""
|
"""Workflow P5: profile, preview, validation and the point list."""
|
||||||
|
|
||||||
|
#: The list changed (rows added, removed or replaced), or the selection did: the
|
||||||
|
#: window redraws the sequence on the 2D map from either.
|
||||||
|
pointsChanged = Signal()
|
||||||
|
#: Add the point picked on the 2D map to the list.
|
||||||
|
addPickedRequested = Signal()
|
||||||
|
#: Send the selected rows' U/V to the capture page's queue: ``[(u, v), ...]``.
|
||||||
|
sendToCaptureRequested = Signal(object)
|
||||||
generateRequested = Signal()
|
generateRequested = Signal()
|
||||||
simulateRequested = Signal()
|
simulateRequested = Signal()
|
||||||
dryRunRequested = Signal()
|
dryRunRequested = Signal()
|
||||||
@@ -164,6 +184,8 @@ class SequencePanel(QGroupBox):
|
|||||||
self._check_rows: dict[str, QLabel] = {}
|
self._check_rows: dict[str, QLabel] = {}
|
||||||
self._summary: dict[str, Any] = {}
|
self._summary: dict[str, Any] = {}
|
||||||
self._dirty = False
|
self._dirty = False
|
||||||
|
#: The 2D picker and 3D preview, while the geometry page has lent them.
|
||||||
|
self._views: tuple[QWidget, QWidget] | None = None
|
||||||
|
|
||||||
outer = QVBoxLayout(self)
|
outer = QVBoxLayout(self)
|
||||||
outer.setContentsMargins(10, 8, 10, 10)
|
outer.setContentsMargins(10, 8, 10, 10)
|
||||||
@@ -197,57 +219,70 @@ class SequencePanel(QGroupBox):
|
|||||||
box.setObjectName("groupBox")
|
box.setObjectName("groupBox")
|
||||||
outer = QVBoxLayout(box)
|
outer = QVBoxLayout(box)
|
||||||
outer.setContentsMargins(10, 8, 10, 8)
|
outer.setContentsMargins(10, 8, 10, 8)
|
||||||
outer.setSpacing(4)
|
outer.setSpacing(6)
|
||||||
|
|
||||||
# The name is what the document is called on disk and in the export, so
|
# 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
|
# it lives at the top of the block that defines the sequence rather than
|
||||||
# in a dialog.
|
# in a dialog. Name and note share one row: two stacked full-width rows
|
||||||
|
# took more height than the whole array tool below them.
|
||||||
name_row = QHBoxLayout()
|
name_row = QHBoxLayout()
|
||||||
name_row.setSpacing(8)
|
name_row.setSpacing(8)
|
||||||
self._name_label = QLabel(box)
|
self._name_label = QLabel(box)
|
||||||
self._name_label.setObjectName("hintLabel")
|
self._name_label.setObjectName("hintLabel")
|
||||||
self.name_edit = QLineEdit(box)
|
self.name_edit = QLineEdit(box)
|
||||||
|
self.name_edit.setObjectName("compactField")
|
||||||
self.name_edit.setFixedHeight(28)
|
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 = QLabel(box)
|
||||||
self._note_label.setObjectName("hintLabel")
|
self._note_label.setObjectName("hintLabel")
|
||||||
self.note_edit = QLineEdit(box)
|
self.note_edit = QLineEdit(box)
|
||||||
|
self.note_edit.setObjectName("compactField")
|
||||||
self.note_edit.setFixedHeight(28)
|
self.note_edit.setFixedHeight(28)
|
||||||
outer.addWidget(self._note_label)
|
name_row.addWidget(self._name_label)
|
||||||
outer.addWidget(self.note_edit)
|
name_row.addWidget(self.name_edit, 1)
|
||||||
|
name_row.addWidget(self._note_label)
|
||||||
|
name_row.addWidget(self.note_edit, 1)
|
||||||
|
outer.addLayout(name_row)
|
||||||
|
|
||||||
grid = QGridLayout()
|
# Two pages: where the points go (the array tool) and how each is measured
|
||||||
grid.setContentsMargins(0, 0, 0, 0)
|
# (the motion parameters). The parameters are set once and then left alone,
|
||||||
|
# so they sit behind a tab instead of taking half the block every time.
|
||||||
|
self.profile_tabs = QTabWidget(box)
|
||||||
|
self.profile_tabs.setObjectName("innerTabs")
|
||||||
|
self.array_tool = ArrayTool(self.profile_tabs)
|
||||||
|
self.profile_tabs.addTab(self.array_tool, "")
|
||||||
|
params = QWidget(self.profile_tabs)
|
||||||
|
grid = QGridLayout(params)
|
||||||
|
grid.setContentsMargins(4, 8, 4, 4)
|
||||||
grid.setHorizontalSpacing(10)
|
grid.setHorizontalSpacing(10)
|
||||||
grid.setVerticalSpacing(4)
|
grid.setVerticalSpacing(6)
|
||||||
outer.addLayout(grid)
|
|
||||||
|
|
||||||
# Two fields per row: label, field, label, field. Seeing the whole profile
|
# Three label/field pairs per row, so the whole profile is visible at once
|
||||||
# at once is what lets an operator sanity-check a pitch against a depth.
|
# (that is what lets an operator sanity-check a pitch against a depth) in a
|
||||||
|
# third of the height two pairs per row took.
|
||||||
for index, (key, _locale_key, kind) in enumerate(PROFILE_FIELDS):
|
for index, (key, _locale_key, kind) in enumerate(PROFILE_FIELDS):
|
||||||
row, side = divmod(index, 2)
|
row, side = divmod(index, 3)
|
||||||
column = side * 2
|
column = side * 2
|
||||||
label = QLabel(box)
|
label = QLabel(params)
|
||||||
label.setObjectName("hintLabel")
|
label.setObjectName("hintLabel")
|
||||||
grid.addWidget(label, row, column)
|
grid.addWidget(label, row, column)
|
||||||
self._profile_labels.append(label)
|
self._profile_labels.append(label)
|
||||||
|
|
||||||
if kind == "choice":
|
if kind == "choice":
|
||||||
field: QWidget = QComboBox(box)
|
field: QWidget = QComboBox(params)
|
||||||
field.setFixedHeight(28)
|
|
||||||
self._choices[key] = field # type: ignore[assignment]
|
self._choices[key] = field # type: ignore[assignment]
|
||||||
else:
|
else:
|
||||||
field = QLineEdit(box)
|
field = QLineEdit(params)
|
||||||
|
field.setObjectName("compactField")
|
||||||
field.setFixedHeight(28)
|
field.setFixedHeight(28)
|
||||||
field.setProperty("fieldKey", key)
|
field.setProperty("fieldKey", key)
|
||||||
grid.addWidget(field, row, column + 1)
|
grid.addWidget(field, row, column + 1)
|
||||||
self._profile_fields[key] = field
|
self._profile_fields[key] = field
|
||||||
|
|
||||||
grid.setColumnStretch(1, 1)
|
for column in (1, 3, 5):
|
||||||
grid.setColumnStretch(3, 1)
|
grid.setColumnStretch(column, 1)
|
||||||
|
grid.setRowStretch(PROFILE_ROWS, 1)
|
||||||
|
self.profile_tabs.addTab(params, "")
|
||||||
|
outer.addWidget(self.profile_tabs, 1)
|
||||||
self._profile_box = box
|
self._profile_box = box
|
||||||
return box
|
return box
|
||||||
|
|
||||||
@@ -267,6 +302,13 @@ class SequencePanel(QGroupBox):
|
|||||||
self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
column.addWidget(self.preview_label, 1)
|
column.addWidget(self.preview_label, 1)
|
||||||
|
|
||||||
|
# The geometry page's two views are shown here while this page is open:
|
||||||
|
# a sequence is designed against the surface and the pose, not a table.
|
||||||
|
self._view_row = QHBoxLayout()
|
||||||
|
self._view_row.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self._view_row.setSpacing(8)
|
||||||
|
column.addLayout(self._view_row, 1)
|
||||||
|
|
||||||
self.preview_hint = QLabel(box)
|
self.preview_hint = QLabel(box)
|
||||||
self.preview_hint.setObjectName("hintLabel")
|
self.preview_hint.setObjectName("hintLabel")
|
||||||
self.preview_hint.setWordWrap(True)
|
self.preview_hint.setWordWrap(True)
|
||||||
@@ -274,6 +316,47 @@ class SequencePanel(QGroupBox):
|
|||||||
self._preview_box = box
|
self._preview_box = box
|
||||||
return box
|
return box
|
||||||
|
|
||||||
|
def adopt_views(self, picker: QWidget, preview: QWidget) -> None:
|
||||||
|
"""Show the shared 2D picker and 3D preview in place of the placeholder."""
|
||||||
|
if self._views is not None:
|
||||||
|
return
|
||||||
|
self._views = (picker, preview)
|
||||||
|
self.preview_label.setVisible(False)
|
||||||
|
# An explicit floor. Left to its contents the box was allotted less height
|
||||||
|
# than its rows (a 3D view that will not shrink below 240, check boxes,
|
||||||
|
# buttons) need, and the layout then drew them on top of each other.
|
||||||
|
for widget in (picker, preview):
|
||||||
|
widget.setMinimumHeight(SHARED_VIEW_MIN_HEIGHT)
|
||||||
|
self._view_row.addWidget(picker, 2)
|
||||||
|
self._view_row.addWidget(preview, 3)
|
||||||
|
picker.show()
|
||||||
|
preview.show()
|
||||||
|
self._relayout(preview)
|
||||||
|
|
||||||
|
def release_views(self) -> bool:
|
||||||
|
"""Stop showing the shared views; the caller puts them back where they live."""
|
||||||
|
if self._views is None:
|
||||||
|
return False
|
||||||
|
for widget in self._views:
|
||||||
|
self._view_row.removeWidget(widget)
|
||||||
|
widget.setMinimumHeight(0)
|
||||||
|
self._views = None
|
||||||
|
self.preview_label.setVisible(True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _relayout(widget: QWidget) -> None:
|
||||||
|
"""Recompute a moved widget's inner layout for its new size."""
|
||||||
|
layout = widget.layout()
|
||||||
|
if layout is not None:
|
||||||
|
layout.invalidate()
|
||||||
|
layout.activate()
|
||||||
|
widget.updateGeometry()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_views(self) -> bool:
|
||||||
|
return self._views is not None
|
||||||
|
|
||||||
# --------------------------------------------------------------- validation
|
# --------------------------------------------------------------- validation
|
||||||
|
|
||||||
def _build_validation(self) -> QWidget:
|
def _build_validation(self) -> QWidget:
|
||||||
@@ -339,7 +422,10 @@ class SequencePanel(QGroupBox):
|
|||||||
|
|
||||||
self.table = QTableWidget(len(_EXAMPLE_POINTS), len(POINT_COLUMNS), box)
|
self.table = QTableWidget(len(_EXAMPLE_POINTS), len(POINT_COLUMNS), box)
|
||||||
self.table.verticalHeader().setVisible(False)
|
self.table.verticalHeader().setVisible(False)
|
||||||
self.table.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
|
# Rows are selectable: a few of them are sent to the capture page, and a
|
||||||
|
# whole-row highlight says which ones.
|
||||||
|
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||||
|
self.table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection)
|
||||||
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||||
self.table.setAlternatingRowColors(True)
|
self.table.setAlternatingRowColors(True)
|
||||||
for index, width in enumerate(_POINT_COLUMN_WIDTHS):
|
for index, width in enumerate(_POINT_COLUMN_WIDTHS):
|
||||||
@@ -352,20 +438,7 @@ class SequencePanel(QGroupBox):
|
|||||||
self.table.setMinimumHeight(120)
|
self.table.setMinimumHeight(120)
|
||||||
|
|
||||||
for row, (x, y, z) in enumerate(_EXAMPLE_POINTS):
|
for row, (x, y, z) in enumerate(_EXAMPLE_POINTS):
|
||||||
values = {
|
self._write_row(row, str(row + 1), x, y, z, None, t("sequence.status_pending"))
|
||||||
0: str(row + 1),
|
|
||||||
1: f"{x:.3f}",
|
|
||||||
2: f"{y:.3f}",
|
|
||||||
3: f"{z:.3f}",
|
|
||||||
4: t("sequence.status_pending"),
|
|
||||||
5: "—",
|
|
||||||
6: "—",
|
|
||||||
}
|
|
||||||
for column_index, text in values.items():
|
|
||||||
item = QTableWidgetItem(text)
|
|
||||||
if column_index >= 4:
|
|
||||||
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 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
|
# the only place a wrong pitch or margin shows up before a seven-minute
|
||||||
@@ -375,6 +448,27 @@ class SequencePanel(QGroupBox):
|
|||||||
self.summary_label.setWordWrap(True)
|
self.summary_label.setWordWrap(True)
|
||||||
|
|
||||||
column.addWidget(self.table, 1)
|
column.addWidget(self.table, 1)
|
||||||
|
|
||||||
|
# What to do with the rows: take the point picked on the map, drop rows,
|
||||||
|
# and hand the selected ones to the capture page.
|
||||||
|
actions = QHBoxLayout()
|
||||||
|
actions.setContentsMargins(0, 0, 0, 0)
|
||||||
|
actions.setSpacing(8)
|
||||||
|
self.add_picked_button = QPushButton(box)
|
||||||
|
self.add_picked_button.setProperty("rail", True)
|
||||||
|
self.add_picked_button.clicked.connect(self.addPickedRequested)
|
||||||
|
self.remove_button = QPushButton(box)
|
||||||
|
self.remove_button.setProperty("rail", True)
|
||||||
|
self.remove_button.clicked.connect(self.remove_selected)
|
||||||
|
self.send_button = QPushButton(box)
|
||||||
|
self.send_button.setObjectName("primaryButton")
|
||||||
|
self.send_button.clicked.connect(self._on_send)
|
||||||
|
actions.addWidget(self.add_picked_button)
|
||||||
|
actions.addWidget(self.remove_button)
|
||||||
|
actions.addStretch(1)
|
||||||
|
actions.addWidget(self.send_button)
|
||||||
|
column.addLayout(actions)
|
||||||
|
self.table.itemSelectionChanged.connect(self._sync_action_buttons)
|
||||||
column.addWidget(self.summary_label)
|
column.addWidget(self.summary_label)
|
||||||
|
|
||||||
self.point_hint = QLabel(box)
|
self.point_hint = QLabel(box)
|
||||||
@@ -452,26 +546,108 @@ class SequencePanel(QGroupBox):
|
|||||||
def point_row_count(self) -> int:
|
def point_row_count(self) -> int:
|
||||||
return self.table.rowCount()
|
return self.table.rowCount()
|
||||||
|
|
||||||
def append_point(self, x: float, y: float, z: float, status: str = "") -> int:
|
def _write_row(
|
||||||
"""Add a generated point. Used by the generator once P5 exists."""
|
self,
|
||||||
|
row: int,
|
||||||
|
index_text: str,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
z: float,
|
||||||
|
uv: tuple[float, float] | None,
|
||||||
|
status: str,
|
||||||
|
) -> None:
|
||||||
|
"""Fill one row by column key, so the layout lives in one place."""
|
||||||
|
values = {
|
||||||
|
"index": index_text,
|
||||||
|
"x": f"{x:.3f}",
|
||||||
|
"y": f"{y:.3f}",
|
||||||
|
"z": f"{z:.3f}",
|
||||||
|
"u": "—" if uv is None else f"{uv[0]:.1f}",
|
||||||
|
"v": "—" if uv is None else f"{uv[1]:.1f}",
|
||||||
|
"status": status,
|
||||||
|
"sensitivity": "—",
|
||||||
|
"samples": "—",
|
||||||
|
}
|
||||||
|
for key, text in values.items():
|
||||||
|
item = QTableWidgetItem(text)
|
||||||
|
if key in _CENTRED:
|
||||||
|
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.table.setItem(row, _COL[key], item)
|
||||||
|
|
||||||
|
def append_point(
|
||||||
|
self,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
z: float,
|
||||||
|
status: str = "",
|
||||||
|
uv: tuple[float, float] | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Add a point: from the 2D map's pick, or from the generator once P5 exists."""
|
||||||
row = self.table.rowCount()
|
row = self.table.rowCount()
|
||||||
self.table.insertRow(row)
|
self.table.insertRow(row)
|
||||||
values = {
|
self._write_row(row, str(row + 1), x, y, z, uv, status or t("sequence.status_pending"))
|
||||||
0: str(row + 1),
|
self._dirty = True
|
||||||
1: f"{x:.3f}",
|
self.refresh_summary()
|
||||||
2: f"{y:.3f}",
|
self._sync_action_buttons()
|
||||||
3: f"{z:.3f}",
|
|
||||||
4: status or t("sequence.status_pending"),
|
|
||||||
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)
|
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
def remove_selected(self) -> int:
|
||||||
|
"""Remove the selected rows and renumber the rest; returns how many went."""
|
||||||
|
rows = self.selected_rows()
|
||||||
|
for row in reversed(rows):
|
||||||
|
self.table.removeRow(row)
|
||||||
|
for row in range(self.table.rowCount()):
|
||||||
|
item = self.table.item(row, _COL["index"])
|
||||||
|
if item is not None:
|
||||||
|
item.setText(str(row + 1))
|
||||||
|
if rows:
|
||||||
|
self._dirty = True
|
||||||
|
self.refresh_summary()
|
||||||
|
self._sync_action_buttons()
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
def selected_rows(self) -> list[int]:
|
||||||
|
return sorted({index.row() for index in self.table.selectionModel().selectedRows()})
|
||||||
|
|
||||||
|
def uv_of_row(self, row: int) -> tuple[float, float] | None:
|
||||||
|
"""The row's U/V, or ``None`` when it has none (a placeholder or imported row)."""
|
||||||
|
cells = self.point_row(row)
|
||||||
|
try:
|
||||||
|
return float(cells["u"]), float(cells["v"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def selected_uv(self) -> list[tuple[float, float]]:
|
||||||
|
"""U/V of the selected rows that have one, in list order."""
|
||||||
|
found = (self.uv_of_row(row) for row in self.selected_rows())
|
||||||
|
return [uv for uv in found if uv is not None]
|
||||||
|
|
||||||
|
def _on_send(self) -> None:
|
||||||
|
uvs = self.selected_uv()
|
||||||
|
if uvs:
|
||||||
|
self.sendToCaptureRequested.emit(uvs)
|
||||||
|
|
||||||
|
def map_points(self) -> list[tuple[int, float, float, bool]]:
|
||||||
|
"""``(row number, u, v, selected)`` for every row with a U/V, in list order."""
|
||||||
|
selected = set(self.selected_rows())
|
||||||
|
found = []
|
||||||
|
for row in range(self.table.rowCount()):
|
||||||
|
uv = self.uv_of_row(row)
|
||||||
|
if uv is not None:
|
||||||
|
found.append((row + 1, uv[0], uv[1], row in selected))
|
||||||
|
return found
|
||||||
|
|
||||||
|
def _sync_action_buttons(self) -> None:
|
||||||
|
rows = self.selected_rows()
|
||||||
|
self.pointsChanged.emit()
|
||||||
|
self.remove_button.setEnabled(bool(rows))
|
||||||
|
with_uv = len(self.selected_uv())
|
||||||
|
self.send_button.setEnabled(with_uv > 0)
|
||||||
|
self.send_button.setText(t("sequence.btn_send", count=with_uv))
|
||||||
|
self.send_button.setToolTip(
|
||||||
|
t("sequence.send_tip") if with_uv else t("sequence.send_none")
|
||||||
|
)
|
||||||
|
|
||||||
# ----------------------------------------------------------- points & state
|
# ----------------------------------------------------------- points & state
|
||||||
|
|
||||||
def points(self) -> list[SequencePoint]:
|
def points(self) -> list[SequencePoint]:
|
||||||
@@ -483,8 +659,14 @@ class SequencePanel(QGroupBox):
|
|||||||
target = {axis: float(cells[axis]) for axis in "xyz"}
|
target = {axis: float(cells[axis]) for axis in "xyz"}
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue # a placeholder row that was never generated
|
continue # a placeholder row that was never generated
|
||||||
|
uv = self.uv_of_row(row)
|
||||||
points.append(
|
points.append(
|
||||||
SequencePoint(id=row + 1, target_mm=target, enabled=cells["status"] != t("sequence.status_skipped"))
|
SequencePoint(
|
||||||
|
id=row + 1,
|
||||||
|
target_mm=target,
|
||||||
|
uv_px=None if uv is None else {"u": uv[0], "v": uv[1]},
|
||||||
|
enabled=cells["status"] != t("sequence.status_skipped"),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return points
|
return points
|
||||||
|
|
||||||
@@ -494,22 +676,23 @@ class SequencePanel(QGroupBox):
|
|||||||
for point in points:
|
for point in points:
|
||||||
row = self.table.rowCount()
|
row = self.table.rowCount()
|
||||||
self.table.insertRow(row)
|
self.table.insertRow(row)
|
||||||
values = {
|
uv = (
|
||||||
0: str(point.id),
|
None
|
||||||
1: f"{point.target_mm.get('x', 0.0):.3f}",
|
if point.uv_px is None
|
||||||
2: f"{point.target_mm.get('y', 0.0):.3f}",
|
else (float(point.uv_px.get("u", 0.0)), float(point.uv_px.get("v", 0.0)))
|
||||||
3: f"{point.target_mm.get('z', 0.0):.3f}",
|
)
|
||||||
4: t("sequence.status_pending") if point.enabled else t("sequence.status_skipped"),
|
self._write_row(
|
||||||
5: "—",
|
row,
|
||||||
6: "—",
|
str(point.id),
|
||||||
}
|
point.target_mm.get("x", 0.0),
|
||||||
for column, text in values.items():
|
point.target_mm.get("y", 0.0),
|
||||||
item = QTableWidgetItem(text)
|
point.target_mm.get("z", 0.0),
|
||||||
if column >= 4:
|
uv,
|
||||||
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
t("sequence.status_pending") if point.enabled else t("sequence.status_skipped"),
|
||||||
self.table.setItem(row, column, item)
|
)
|
||||||
self._dirty = True
|
self._dirty = True
|
||||||
self.refresh_summary()
|
self.refresh_summary()
|
||||||
|
self._sync_action_buttons()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def dirty(self) -> bool:
|
def dirty(self) -> bool:
|
||||||
@@ -566,6 +749,9 @@ class SequencePanel(QGroupBox):
|
|||||||
for key, combo in self._choices.items():
|
for key, combo in self._choices.items():
|
||||||
_fill_choice(combo, key)
|
_fill_choice(combo, key)
|
||||||
|
|
||||||
|
self.profile_tabs.setTabText(0, t("sequence.tab_array"))
|
||||||
|
self.profile_tabs.setTabText(1, t("sequence.tab_motion"))
|
||||||
|
self.array_tool.retranslate()
|
||||||
self._name_label.setText(t("sequence.field_name"))
|
self._name_label.setText(t("sequence.field_name"))
|
||||||
self.name_edit.setPlaceholderText(t("sequence.name_hint"))
|
self.name_edit.setPlaceholderText(t("sequence.name_hint"))
|
||||||
self._note_label.setText(t("sequence.field_note"))
|
self._note_label.setText(t("sequence.field_note"))
|
||||||
@@ -586,10 +772,14 @@ class SequencePanel(QGroupBox):
|
|||||||
|
|
||||||
self.table.setHorizontalHeaderLabels([t(locale_key) for _key, locale_key in POINT_COLUMNS])
|
self.table.setHorizontalHeaderLabels([t(locale_key) for _key, locale_key in POINT_COLUMNS])
|
||||||
for row in range(self.table.rowCount()):
|
for row in range(self.table.rowCount()):
|
||||||
item = self.table.item(row, 4)
|
item = self.table.item(row, _COL["status"])
|
||||||
if item is not None and item.text() in ("", t("sequence.status_pending")):
|
if item is not None and item.text() in ("", t("sequence.status_pending")):
|
||||||
item.setText(t("sequence.status_pending"))
|
item.setText(t("sequence.status_pending"))
|
||||||
self.point_hint.setText(t("sequence.points_hint"))
|
self.point_hint.setText(t("sequence.points_hint"))
|
||||||
|
self.add_picked_button.setText(t("sequence.btn_add_picked"))
|
||||||
|
self.add_picked_button.setToolTip(t("sequence.add_picked_tip"))
|
||||||
|
self.remove_button.setText(t("sequence.btn_remove"))
|
||||||
|
self._sync_action_buttons()
|
||||||
self.refresh_summary()
|
self.refresh_summary()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
"""Exploratory hold-window table and per-site loading/unloading charts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries
|
||||||
|
from PySide6.QtCore import Qt, QPointF, Slot
|
||||||
|
from PySide6.QtGui import QColor, QPainter
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QComboBox, QDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton,
|
||||||
|
QTableWidget, QTableWidgetItem, QVBoxLayout, QDoubleSpinBox, QCheckBox, QFileDialog, QTabWidget, QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..session_review import review_session, save_review, load_review, reselect_review
|
||||||
|
from ..video_review import trial_is_closed
|
||||||
|
from .review_job import ReviewJob
|
||||||
|
from .chart_helpers import add_value_axes, clear_chart
|
||||||
|
|
||||||
|
|
||||||
|
class SessionReviewDialog(QDialog):
|
||||||
|
"""Open persisted data independently of the acquisition page's state."""
|
||||||
|
|
||||||
|
def __init__(self, directory: Path, parent=None, *, review: dict | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle(t("review.title"))
|
||||||
|
self.resize(1080, 760)
|
||||||
|
self.setObjectName("holdInspection")
|
||||||
|
self.setStyleSheet("#holdInspection QLabel, #holdInspection QPushButton, #holdInspection QComboBox, "
|
||||||
|
"#holdInspection QTableWidget, #holdInspection QCheckBox {font-size: 14px;} "
|
||||||
|
"#holdInspection QPushButton {min-height: 22px; padding: 3px 10px;}")
|
||||||
|
self.review = review if review is not None else review_session(directory)
|
||||||
|
self.directory = directory
|
||||||
|
self._job = None
|
||||||
|
self._job_kind = ""
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
self.notice = QLabel(t("review.notice"), self)
|
||||||
|
self.notice.setWordWrap(True)
|
||||||
|
layout.addWidget(self.notice)
|
||||||
|
row = QHBoxLayout()
|
||||||
|
self.sites = QComboBox(self)
|
||||||
|
self.sites.setObjectName("reviewSite")
|
||||||
|
for site in self.review["sites"]:
|
||||||
|
u, v = site["uv_xy_px"]
|
||||||
|
self.sites.addItem(f"{site['site_id']} · U {u}, V {v} px")
|
||||||
|
self.save_button = QPushButton(t("review.save"), self)
|
||||||
|
self.load_button = QPushButton(t("review.load_selection"), self)
|
||||||
|
row.addWidget(self.sites, 1)
|
||||||
|
row.addWidget(self.save_button)
|
||||||
|
row.addWidget(self.load_button)
|
||||||
|
layout.addLayout(row)
|
||||||
|
self.pages = QTabWidget(self)
|
||||||
|
hold_page, response_page = QWidget(self), QWidget(self)
|
||||||
|
hold_layout, response_layout = QVBoxLayout(hold_page), QVBoxLayout(response_page)
|
||||||
|
self.pages.addTab(hold_page, t("overview.details"))
|
||||||
|
self.pages.addTab(response_page, t("overview.response"))
|
||||||
|
layout.addWidget(self.pages, 1)
|
||||||
|
self.summary = QLabel(self)
|
||||||
|
self.summary.setWordWrap(True)
|
||||||
|
summary_row = QHBoxLayout()
|
||||||
|
summary_row.addWidget(self.summary, 1)
|
||||||
|
self.quality_button = QPushButton(t("quality.open"), self)
|
||||||
|
self.quality_button.setObjectName("reviewQuality")
|
||||||
|
self.quality_button.clicked.connect(self._quality)
|
||||||
|
summary_row.addWidget(self.quality_button)
|
||||||
|
response_layout.addLayout(summary_row)
|
||||||
|
self.chart = QChart()
|
||||||
|
self.chart_view = QChartView(self.chart, self)
|
||||||
|
self.chart_view.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
self.uv_chart = QChart()
|
||||||
|
self.uv_view = QChartView(self.uv_chart, self)
|
||||||
|
self.uv_view.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
charts = QHBoxLayout()
|
||||||
|
charts.addWidget(self.uv_view, 1)
|
||||||
|
charts.addWidget(self.chart_view, 2)
|
||||||
|
response_layout.addLayout(charts, 1)
|
||||||
|
self.table = QTableWidget(0, 9, self)
|
||||||
|
self.table.setObjectName("reviewWindows")
|
||||||
|
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||||
|
self.table.setHorizontalHeaderLabels([t(f"review.col_{key}") for key in (
|
||||||
|
"trial", "branch", "load", "samples", "mean", "std", "duration", "quality", "drift")])
|
||||||
|
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
self.table.horizontalHeader().setStretchLastSection(True)
|
||||||
|
hold_layout.addWidget(self.table, 1)
|
||||||
|
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||||
|
self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
|
||||||
|
editor = QHBoxLayout()
|
||||||
|
self.start_s = QDoubleSpinBox(self)
|
||||||
|
self.end_s = QDoubleSpinBox(self)
|
||||||
|
for spin in (self.start_s, self.end_s):
|
||||||
|
spin.setDecimals(3)
|
||||||
|
spin.setSuffix(" s")
|
||||||
|
self.verified = QCheckBox(t("review.confirm_stable"), self)
|
||||||
|
self.apply_button = QPushButton(t("review.apply_window"), self)
|
||||||
|
self.video_button = QPushButton(t("video.open"), self)
|
||||||
|
self.video_button.setObjectName("reviewVideo")
|
||||||
|
self.video_button.clicked.connect(self._video)
|
||||||
|
editor.addWidget(QLabel(t("review.start_offset"), self))
|
||||||
|
editor.addWidget(self.start_s)
|
||||||
|
editor.addWidget(QLabel(t("review.end_offset"), self))
|
||||||
|
editor.addWidget(self.end_s)
|
||||||
|
editor.addWidget(self.verified)
|
||||||
|
editor.addWidget(self.apply_button)
|
||||||
|
editor.addWidget(self.video_button)
|
||||||
|
hold_layout.addLayout(editor)
|
||||||
|
self.trace_chart = QChart()
|
||||||
|
self.trace_view = QChartView(self.trace_chart, self)
|
||||||
|
self.trace_view.setMinimumHeight(240)
|
||||||
|
hold_layout.addWidget(self.trace_view, 2)
|
||||||
|
self.message = QLabel(self)
|
||||||
|
self.message.setWordWrap(True)
|
||||||
|
layout.addWidget(self.message)
|
||||||
|
self.sites.currentIndexChanged.connect(self._show_site)
|
||||||
|
self.save_button.clicked.connect(self._save)
|
||||||
|
self.load_button.clicked.connect(self._load)
|
||||||
|
self.table.itemSelectionChanged.connect(self._show_window)
|
||||||
|
self.apply_button.clicked.connect(self._apply_window)
|
||||||
|
self._show_site()
|
||||||
|
|
||||||
|
def _show_site(self) -> None:
|
||||||
|
index = self.sites.currentIndex()
|
||||||
|
self.quality_button.setEnabled(index >= 0)
|
||||||
|
clear_chart(self.chart)
|
||||||
|
if index < 0:
|
||||||
|
self.summary.setText(t("review.no_sites"))
|
||||||
|
return
|
||||||
|
site = self.review["sites"][index]
|
||||||
|
windows = site["windows"]
|
||||||
|
self.table.setRowCount(len(windows))
|
||||||
|
for row, window in enumerate(windows):
|
||||||
|
quality = t("review." + window["status"]) if window["status"] != "candidate" else t(
|
||||||
|
"review.verified" if window["stability_verified"] else "review.unverified")
|
||||||
|
if not window["reference_alignment_possible"]:
|
||||||
|
quality += " · " + t("review.replay")
|
||||||
|
values = [window["trial_id"], t("calibration." + window["branch"]),
|
||||||
|
f"{window['reference_n']:g}", str(window["samples"]),
|
||||||
|
self._number(window["raw_mean"]), self._number(window["raw_std"]),
|
||||||
|
f"{window['duration_s']:g}", quality, self._number(window["raw_drift_per_s"])]
|
||||||
|
for column, value in enumerate(values):
|
||||||
|
self.table.setItem(row, column, QTableWidgetItem(value))
|
||||||
|
fit = site["confirmed_fit"] or site["fit"]
|
||||||
|
self.summary.setText(
|
||||||
|
((t("review.confirmed_fit") if site["confirmed_fit"] else t("review.candidate_fit"))
|
||||||
|
+ "\n" + t("review.fit").format(**fit)) if fit else t("review.no_fit")
|
||||||
|
)
|
||||||
|
for branch, color in (("zero", "#657388"), ("load", "#167ac6"), ("unload", "#df8b13")):
|
||||||
|
series = QScatterSeries()
|
||||||
|
series.setName(t("calibration." + branch))
|
||||||
|
series.setColor(QColor(color))
|
||||||
|
series.setMarkerSize(9)
|
||||||
|
for window in windows:
|
||||||
|
if window["branch"] == branch and window["status"] == "candidate":
|
||||||
|
series.append(window["raw_mean"], window["reference_n"])
|
||||||
|
if series.count():
|
||||||
|
self.chart.addSeries(series)
|
||||||
|
if fit:
|
||||||
|
raw = [w["raw_mean"] for w in windows if w["status"] == "candidate"]
|
||||||
|
line = QLineSeries()
|
||||||
|
line.setName(t("review.fit_line"))
|
||||||
|
for value in (min(raw), max(raw)):
|
||||||
|
line.append(value, fit["gain_n_per_raw"] * value + fit["offset_n"])
|
||||||
|
self.chart.addSeries(line)
|
||||||
|
add_value_axes(self.chart)
|
||||||
|
for axis in self.chart.axes(Qt.Orientation.Horizontal):
|
||||||
|
axis.setTitleText(t("review.raw_axis"))
|
||||||
|
for axis in self.chart.axes(Qt.Orientation.Vertical):
|
||||||
|
axis.setTitleText(t("review.force_axis"))
|
||||||
|
self.chart.setTitle(t("review.chart_title"))
|
||||||
|
self._show_uv(index)
|
||||||
|
self.message.setText(t("review.issues").format(count=len(self.review["issues"])))
|
||||||
|
if windows:
|
||||||
|
self.table.selectRow(0)
|
||||||
|
self._show_window()
|
||||||
|
|
||||||
|
def _quality(self):
|
||||||
|
from .quality_review_dialog import QualityReviewDialog
|
||||||
|
|
||||||
|
index = self.sites.currentIndex()
|
||||||
|
if index >= 0:
|
||||||
|
QualityReviewDialog(self.review["sites"][index], self).exec()
|
||||||
|
|
||||||
|
def _current_window(self):
|
||||||
|
site, row = self.sites.currentIndex(), self.table.currentRow()
|
||||||
|
if site < 0 or row < 0 or row >= len(self.review["sites"][site]["windows"]):
|
||||||
|
return None
|
||||||
|
return self.review["sites"][site]["windows"][row]
|
||||||
|
|
||||||
|
def _show_window(self):
|
||||||
|
window = self._current_window()
|
||||||
|
clear_chart(self.trace_chart)
|
||||||
|
self.apply_button.setEnabled(window is not None)
|
||||||
|
self.video_button.setEnabled(window is not None)
|
||||||
|
if window is None:
|
||||||
|
return
|
||||||
|
origin = window["hold_start_host_mono_ns"]
|
||||||
|
duration = (window["hold_end_host_mono_ns"] - origin) / 1e9
|
||||||
|
self.start_s.setRange(0, duration)
|
||||||
|
self.end_s.setRange(0, duration)
|
||||||
|
self.start_s.setValue((window["t_start_host_mono_ns"] - origin) / 1e9)
|
||||||
|
self.end_s.setValue((window["t_end_host_mono_ns"] - origin) / 1e9)
|
||||||
|
self.verified.setChecked(window["stability_verified"])
|
||||||
|
trace = QScatterSeries()
|
||||||
|
trace.setMarkerSize(3)
|
||||||
|
trace.setName(t("review.valid_samples"))
|
||||||
|
trace.replace([QPointF((stamp - origin) / 1e9, raw) for stamp, _, raw in window["trace"]])
|
||||||
|
self.trace_chart.addSeries(trace)
|
||||||
|
if window["trace"]:
|
||||||
|
raw_values = [r[2] for r in window["trace"]]
|
||||||
|
low, high = min(raw_values), max(raw_values)
|
||||||
|
margin = max((high - low) * .1, .1)
|
||||||
|
for stamp in (window["t_start_host_mono_ns"], window["t_end_host_mono_ns"]):
|
||||||
|
line = QLineSeries()
|
||||||
|
line.append((stamp - origin) / 1e9, low - margin)
|
||||||
|
line.append((stamp - origin) / 1e9, high + margin)
|
||||||
|
self.trace_chart.addSeries(line)
|
||||||
|
add_value_axes(self.trace_chart)
|
||||||
|
for axis in self.trace_chart.axes(Qt.Orientation.Horizontal):
|
||||||
|
axis.setRange(0, duration)
|
||||||
|
axis.setTitleText(t("review.hold_time_axis"))
|
||||||
|
for axis in self.trace_chart.axes(Qt.Orientation.Vertical):
|
||||||
|
axis.setTitleText(t("review.raw_axis"))
|
||||||
|
self.trace_chart.legend().hide()
|
||||||
|
self.trace_chart.setTitle(t("review.trace_title"))
|
||||||
|
|
||||||
|
def _video(self):
|
||||||
|
"""Open the recorded pictures of the selected hold, read-only."""
|
||||||
|
window = self._current_window()
|
||||||
|
if window is None:
|
||||||
|
return None
|
||||||
|
trial = next(
|
||||||
|
(item for item in self.review.get("trials") or []
|
||||||
|
if item["trial_id"] == window["trial_id"]), None,
|
||||||
|
)
|
||||||
|
if not trial_is_closed(self.directory, trial):
|
||||||
|
self.message.setText(t("overview.guide_incomplete"))
|
||||||
|
return None
|
||||||
|
from .video_review_dialog import VideoReviewDialog
|
||||||
|
|
||||||
|
dialog = VideoReviewDialog(
|
||||||
|
self.directory, self.review,
|
||||||
|
trial_id=window["trial_id"], step_index=int(window["step_index"]), parent=self,
|
||||||
|
)
|
||||||
|
dialog.setModal(True)
|
||||||
|
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||||
|
dialog.show()
|
||||||
|
return dialog
|
||||||
|
|
||||||
|
def _apply_window(self):
|
||||||
|
window = self._current_window()
|
||||||
|
if window is None:
|
||||||
|
return
|
||||||
|
selections = dict(self.review["selections"])
|
||||||
|
origin = window["hold_start_host_mono_ns"]
|
||||||
|
selections[f"{window['trial_id']}:{window['step_index']}"] = {
|
||||||
|
"t_start_host_mono_ns": origin + round(self.start_s.value() * 1e9),
|
||||||
|
"t_end_host_mono_ns": origin + round(self.end_s.value() * 1e9),
|
||||||
|
"stability_verified": self.verified.isChecked(),
|
||||||
|
}
|
||||||
|
row = self.table.currentRow()
|
||||||
|
try:
|
||||||
|
updated = reselect_review(self.review, selections)
|
||||||
|
self.review = updated
|
||||||
|
self._show_site()
|
||||||
|
self.table.selectRow(row)
|
||||||
|
self._show_window()
|
||||||
|
self.message.setText(t("review.selection_unsaved"))
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
self.message.setText(str(exc))
|
||||||
|
|
||||||
|
def _load(self):
|
||||||
|
path, _ = QFileDialog.getOpenFileName(self, t("review.load_selection"),
|
||||||
|
str(self.directory / "derived"), "JSON (*.json)")
|
||||||
|
if path:
|
||||||
|
directory = self.directory
|
||||||
|
self._start_job("load", lambda: load_review(directory, Path(path)))
|
||||||
|
|
||||||
|
def _show_uv(self, selected: int) -> None:
|
||||||
|
clear_chart(self.uv_chart)
|
||||||
|
fits = [s["confirmed_fit"] or s["fit"] for s in self.review["sites"]]
|
||||||
|
values = [fit["sensitivity_raw_per_n"] for fit in fits if fit]
|
||||||
|
low, high = (min(values), max(values)) if values else (0, 0)
|
||||||
|
for index, site in enumerate(self.review["sites"]):
|
||||||
|
series = QScatterSeries()
|
||||||
|
series.setName(site["site_id"])
|
||||||
|
color = QColor("#929aa7")
|
||||||
|
if fits[index]:
|
||||||
|
value = fits[index]["sensitivity_raw_per_n"]
|
||||||
|
ratio = (value - low) / (high - low) if high > low else .5
|
||||||
|
color = QColor.fromHsvF((1 - ratio) * .65, .8, .85)
|
||||||
|
series.setColor(color)
|
||||||
|
series.setBorderColor(QColor("#151515") if index == selected else color)
|
||||||
|
series.setMarkerSize(15 if index == selected else 10)
|
||||||
|
series.append(*site["uv_xy_px"])
|
||||||
|
series.clicked.connect(lambda _point, target=index: self.sites.setCurrentIndex(target))
|
||||||
|
self.uv_chart.addSeries(series)
|
||||||
|
add_value_axes(self.uv_chart)
|
||||||
|
for axis in self.uv_chart.axes(Qt.Orientation.Horizontal):
|
||||||
|
axis.setTitleText("U (px)")
|
||||||
|
for axis in self.uv_chart.axes(Qt.Orientation.Vertical):
|
||||||
|
axis.setTitleText("V (px)")
|
||||||
|
self.uv_chart.legend().hide()
|
||||||
|
self.uv_chart.setTitle(t("review.uv_title").format(low=low, high=high))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _number(value) -> str:
|
||||||
|
return "—" if value is None else f"{value:.6g}"
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
review = self.review
|
||||||
|
directory = self.directory
|
||||||
|
|
||||||
|
def action():
|
||||||
|
path = save_review(review)
|
||||||
|
from ..summary_export import publish_reviewed_summary
|
||||||
|
|
||||||
|
summary_path = publish_reviewed_summary(directory, review, review_path=path)
|
||||||
|
return path, summary_path
|
||||||
|
|
||||||
|
self._start_job("save", action)
|
||||||
|
|
||||||
|
def _start_job(self, kind, action):
|
||||||
|
if self._job is not None:
|
||||||
|
return
|
||||||
|
self._job_kind = kind
|
||||||
|
self._job = ReviewJob(action, self._job_finished)
|
||||||
|
self.message.setText(t("review.checking"))
|
||||||
|
for button in (self.save_button, self.load_button, self.apply_button):
|
||||||
|
button.setEnabled(False)
|
||||||
|
self._job.start()
|
||||||
|
|
||||||
|
@Slot(object, object)
|
||||||
|
def _job_finished(self, value, error):
|
||||||
|
self._job = None
|
||||||
|
for button in (self.save_button, self.load_button, self.apply_button):
|
||||||
|
button.setEnabled(True)
|
||||||
|
if error is not None:
|
||||||
|
self.message.setText(t("calibration.error").format(detail=error))
|
||||||
|
elif self._job_kind == "load":
|
||||||
|
self.review = value
|
||||||
|
self._show_site()
|
||||||
|
else:
|
||||||
|
review_path, summary_path = value
|
||||||
|
self.message.setText(t("review.saved_summary").format(
|
||||||
|
path=review_path, summary=summary_path))
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""The operator's point sheet: one CSV row per queue point.
|
||||||
|
|
||||||
|
This is the file an operator reproduces by hand at the UV map: the pixel to click,
|
||||||
|
the sensor-frame position behind it, the platform target the simulated mounting
|
||||||
|
produces, and the mapping identity that makes the pixel coordinates reproducible.
|
||||||
|
It is an *operation sheet*, not an executed sequence.
|
||||||
|
|
||||||
|
The field list is one definition because two readers depend on it: the exporter
|
||||||
|
below and the test that checks it against
|
||||||
|
``apps/autocal/docs/calibration-data-format.zh.md``. A second copy of the names is
|
||||||
|
how the table and the document drift apart.
|
||||||
|
|
||||||
|
The platform columns are the point's stored solved pose — the same numbers the
|
||||||
|
align move commands — so the sheet says what the machine would do, not a second
|
||||||
|
solution computed for the sheet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
|
from ..sensor.uv_flat import FlatSurface, mapping_sha256, site_at, uv_origin
|
||||||
|
|
||||||
|
#: Column order of the operation table. First block is the pixel, second the
|
||||||
|
#: sensor-frame position, third the platform target, last the mapping identity —
|
||||||
|
#: the same grouping the data-format document uses.
|
||||||
|
OPERATION_TABLE_FIELDS: tuple[str, ...] = (
|
||||||
|
"order",
|
||||||
|
"site_id",
|
||||||
|
"u_px",
|
||||||
|
"v_px",
|
||||||
|
"x_sensor_mm",
|
||||||
|
"y_sensor_mm",
|
||||||
|
"z_sensor_mm",
|
||||||
|
"x_platform_mm",
|
||||||
|
"y_platform_mm",
|
||||||
|
"z_platform_mm",
|
||||||
|
"yaw_deg",
|
||||||
|
"pitch_deg",
|
||||||
|
"origin_raster_x_px",
|
||||||
|
"origin_raster_y_px",
|
||||||
|
"raster_width_px",
|
||||||
|
"raster_height_px",
|
||||||
|
"u_positive",
|
||||||
|
"v_positive",
|
||||||
|
"mapping_sha256",
|
||||||
|
)
|
||||||
|
|
||||||
|
#: One pixel of the 1024-wide production raster is about 0.0147 mm, so a gap
|
||||||
|
#: larger than this means the recorded point and the map no longer agree.
|
||||||
|
_POINT_MATCH_MM = 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def operation_table_rows(rows, flat: FlatSurface) -> list[dict]:
|
||||||
|
"""One dict per queue row, in queue order, ready for :data:`OPERATION_TABLE_FIELDS`.
|
||||||
|
|
||||||
|
``rows`` are the controller's ``SiteRow`` snapshots. A row without UV pixels,
|
||||||
|
without a solved pose, or whose recorded sensor position no longer matches the
|
||||||
|
current map refuses the export: an operation sheet that points at a pixel the
|
||||||
|
record does not describe is worse than no sheet.
|
||||||
|
"""
|
||||||
|
if flat is None:
|
||||||
|
raise ValueError(t("cap.queue.export_no_map"))
|
||||||
|
origin_x, origin_y = uv_origin(flat)
|
||||||
|
identity = mapping_sha256(flat)
|
||||||
|
records: list[dict] = []
|
||||||
|
for order, row in enumerate(rows, 1):
|
||||||
|
if row.uv_px is None:
|
||||||
|
raise ValueError(t("cap.queue.export_missing_uv").format(order=order))
|
||||||
|
if row.pose is None:
|
||||||
|
raise ValueError(t("cap.queue.export_no_pose").format(order=order))
|
||||||
|
site = site_at(
|
||||||
|
flat,
|
||||||
|
int(row.uv_px[0]),
|
||||||
|
int(row.uv_px[1]),
|
||||||
|
origin=(origin_x, origin_y),
|
||||||
|
mapping_digest=identity,
|
||||||
|
)
|
||||||
|
recorded = np.asarray(
|
||||||
|
[float(row.position[axis]) for axis in "xyz"], dtype=np.float64
|
||||||
|
)
|
||||||
|
if not np.allclose(recorded, site.position_sensor_mm, rtol=0, atol=_POINT_MATCH_MM):
|
||||||
|
raise ValueError(t("cap.queue.export_edited_point").format(order=order))
|
||||||
|
pose = row.pose
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"order": order,
|
||||||
|
"site_id": row.site_id,
|
||||||
|
"u_px": site.u_px,
|
||||||
|
"v_px": site.v_px,
|
||||||
|
**{f"{axis}_sensor_mm": float(row.position[axis]) for axis in "xyz"},
|
||||||
|
**{
|
||||||
|
f"{axis}_platform_mm": float(pose[axis])
|
||||||
|
for axis in ("x", "y", "z")
|
||||||
|
},
|
||||||
|
"yaw_deg": float(pose["yaw"]),
|
||||||
|
"pitch_deg": float(pose["pitch"]),
|
||||||
|
"origin_raster_x_px": origin_x,
|
||||||
|
"origin_raster_y_px": origin_y,
|
||||||
|
"raster_width_px": flat.width,
|
||||||
|
"raster_height_px": flat.height,
|
||||||
|
"u_positive": "right",
|
||||||
|
"v_positive": "up",
|
||||||
|
"mapping_sha256": identity,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def write_operation_table(path: str | Path, rows, flat: FlatSurface) -> Path:
|
||||||
|
"""Write the operation table as UTF-8 CSV with the frozen field order."""
|
||||||
|
if not rows:
|
||||||
|
raise ValueError(t("cap.queue.export_no_points"))
|
||||||
|
records = operation_table_rows(rows, flat)
|
||||||
|
destination = Path(path)
|
||||||
|
with destination.open("w", newline="", encoding="utf-8-sig") as handle:
|
||||||
|
writer = csv.DictWriter(handle, fieldnames=list(OPERATION_TABLE_FIELDS))
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(records)
|
||||||
|
return destination
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
"""The units page of the device setup: what the platform's scales are, and whether they hold.
|
||||||
|
|
||||||
|
Every millimetre and degree the app shows or commands is *derived* from a drive's
|
||||||
|
pulse equivalent (``UNITS``) and a measured constant. If the equivalent configured here
|
||||||
|
and the one the controller reports differ, the motion guards refuse the move; this page
|
||||||
|
puts the same facts in front of the operator one step earlier, so a wrong scale is seen
|
||||||
|
at start-up and not as a refused move.
|
||||||
|
|
||||||
|
It is **locked by default**. Changing ``UNITS`` rescales every position the machine is
|
||||||
|
told about, so the cells are read-only until the operator presses *Modify*, and a change
|
||||||
|
is written only after a confirmation that lists what will change. The page itself
|
||||||
|
writes nothing: it emits :attr:`UnitsPage.unitsApplied` and the window sends the values
|
||||||
|
through the same axis-parameter path the axis page uses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtGui import QBrush, QColor
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QHBoxLayout,
|
||||||
|
QHeaderView,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
|
QTableWidget,
|
||||||
|
QTableWidgetItem,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..motion.unit_report import (
|
||||||
|
INVALID,
|
||||||
|
MISMATCH,
|
||||||
|
OK,
|
||||||
|
UNCALIBRATED,
|
||||||
|
UNMAPPED,
|
||||||
|
UNREAD,
|
||||||
|
UnitRow,
|
||||||
|
summarise,
|
||||||
|
)
|
||||||
|
from ..motion.units import (
|
||||||
|
PULSES_PER_MM,
|
||||||
|
ROTARY_CALIBRATION_UNITS,
|
||||||
|
ROTARY_UNITS_PER_90_DEG,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Table columns, in display order.
|
||||||
|
COLUMNS = (
|
||||||
|
"units.col_role",
|
||||||
|
"units.col_axis",
|
||||||
|
"units.col_kind",
|
||||||
|
"units.col_configured",
|
||||||
|
"units.col_observed",
|
||||||
|
"units.col_scale",
|
||||||
|
"units.col_check",
|
||||||
|
)
|
||||||
|
CONFIGURED_COLUMN = 3
|
||||||
|
STRETCH_COLUMNS = (0, 5, 6)
|
||||||
|
ROW_HEIGHT = 40
|
||||||
|
EDIT_HEIGHT = 26
|
||||||
|
|
||||||
|
_ROLE_KEYS = {
|
||||||
|
"x": "setup.role_x",
|
||||||
|
"y": "setup.role_y",
|
||||||
|
"z": "setup.role_z",
|
||||||
|
"yaw": "setup.role_yaw",
|
||||||
|
"pitch": "setup.role_pitch",
|
||||||
|
}
|
||||||
|
_STATUS_KEYS = {
|
||||||
|
OK: "units.status_ok",
|
||||||
|
UNREAD: "units.status_unread",
|
||||||
|
MISMATCH: "units.status_mismatch",
|
||||||
|
UNCALIBRATED: "units.status_uncalibrated",
|
||||||
|
INVALID: "units.status_invalid",
|
||||||
|
UNMAPPED: "units.status_unmapped",
|
||||||
|
}
|
||||||
|
_TIP_KEYS = {
|
||||||
|
UNREAD: "units.tip_unread",
|
||||||
|
MISMATCH: "units.tip_mismatch",
|
||||||
|
UNCALIBRATED: "units.tip_uncalibrated",
|
||||||
|
INVALID: "units.tip_invalid",
|
||||||
|
UNMAPPED: "units.tip_unmapped",
|
||||||
|
}
|
||||||
|
_GOOD = "#2e7d32"
|
||||||
|
_BAD = "#c62828"
|
||||||
|
_QUIET = "#7a7a7a"
|
||||||
|
|
||||||
|
|
||||||
|
def _g(value: float | None, places: int = 6) -> str:
|
||||||
|
"""A number as the operator would write it, or a dash when there is none."""
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
try:
|
||||||
|
number = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "—"
|
||||||
|
return f"{number:.{places}g}" if math.isfinite(number) else "—"
|
||||||
|
|
||||||
|
|
||||||
|
class UnitsPage(QWidget):
|
||||||
|
"""Read-only unit table with a guarded way to change it."""
|
||||||
|
|
||||||
|
#: ``{drive: new UNITS}`` for the drives the operator changed and confirmed.
|
||||||
|
unitsApplied = Signal(object)
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._rows: list[UnitRow] = []
|
||||||
|
self._connected = False
|
||||||
|
self._locked = True
|
||||||
|
self._unlocked_axes: dict[str, int] = {}
|
||||||
|
self._edits: dict[str, QLineEdit] = {}
|
||||||
|
|
||||||
|
column = QVBoxLayout(self)
|
||||||
|
column.setContentsMargins(8, 8, 8, 8)
|
||||||
|
column.setSpacing(10)
|
||||||
|
|
||||||
|
self.hint_label = QLabel(self)
|
||||||
|
self.hint_label.setObjectName("hintLabel")
|
||||||
|
self.hint_label.setWordWrap(True)
|
||||||
|
column.addWidget(self.hint_label)
|
||||||
|
|
||||||
|
top = QHBoxLayout()
|
||||||
|
top.setSpacing(8)
|
||||||
|
self.summary_label = QLabel(self)
|
||||||
|
self.summary_label.setObjectName("statusValue")
|
||||||
|
self.summary_label.setWordWrap(True)
|
||||||
|
self.modify_button = QPushButton(self)
|
||||||
|
self.modify_button.setObjectName("unitsModify")
|
||||||
|
self.modify_button.setProperty("rail", True)
|
||||||
|
self.modify_button.clicked.connect(self._unlock)
|
||||||
|
self.apply_button = QPushButton(self)
|
||||||
|
self.apply_button.setObjectName("unitsApply")
|
||||||
|
self.apply_button.setProperty("rail", True)
|
||||||
|
self.apply_button.clicked.connect(self._on_apply)
|
||||||
|
self.cancel_button = QPushButton(self)
|
||||||
|
self.cancel_button.setObjectName("unitsCancel")
|
||||||
|
self.cancel_button.setProperty("rail", True)
|
||||||
|
self.cancel_button.clicked.connect(self._on_cancel)
|
||||||
|
top.addWidget(self.summary_label, 1)
|
||||||
|
top.addWidget(self.modify_button)
|
||||||
|
top.addWidget(self.apply_button)
|
||||||
|
top.addWidget(self.cancel_button)
|
||||||
|
column.addLayout(top)
|
||||||
|
|
||||||
|
self.table = QTableWidget(5, len(COLUMNS), self)
|
||||||
|
self.table.setObjectName("unitsTable")
|
||||||
|
self.table.verticalHeader().setVisible(False)
|
||||||
|
self.table.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
|
||||||
|
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||||
|
self.table.setAlternatingRowColors(True)
|
||||||
|
self.table.verticalHeader().setDefaultSectionSize(ROW_HEIGHT)
|
||||||
|
self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
self.table.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
self.table.setWordWrap(False)
|
||||||
|
self.table.setTextElideMode(Qt.TextElideMode.ElideRight)
|
||||||
|
header = self.table.horizontalHeader()
|
||||||
|
header.setMinimumSectionSize(48)
|
||||||
|
header.setDefaultAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
# Short columns size to their text; the wide ones share what is left, so the
|
||||||
|
# table always fits the dialog and never needs a scroll bar.
|
||||||
|
for index in range(len(COLUMNS)):
|
||||||
|
header.setSectionResizeMode(
|
||||||
|
index,
|
||||||
|
QHeaderView.ResizeMode.Stretch
|
||||||
|
if index in STRETCH_COLUMNS
|
||||||
|
else QHeaderView.ResizeMode.ResizeToContents,
|
||||||
|
)
|
||||||
|
self.table.setFixedHeight(header.height() + ROW_HEIGHT * 5 + 6)
|
||||||
|
for row in range(5):
|
||||||
|
for col in range(len(COLUMNS)):
|
||||||
|
if col != CONFIGURED_COLUMN:
|
||||||
|
item = QTableWidgetItem("")
|
||||||
|
if col in (1, 3, 4):
|
||||||
|
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.table.setItem(row, col, item)
|
||||||
|
column.addWidget(self.table)
|
||||||
|
|
||||||
|
self.constants_label = QLabel(self)
|
||||||
|
self.constants_label.setObjectName("hintLabel")
|
||||||
|
self.constants_label.setWordWrap(True)
|
||||||
|
column.addWidget(self.constants_label)
|
||||||
|
|
||||||
|
self.message_label = QLabel(self)
|
||||||
|
self.message_label.setObjectName("hintLabel")
|
||||||
|
self.message_label.setWordWrap(True)
|
||||||
|
column.addWidget(self.message_label)
|
||||||
|
column.addStretch(1)
|
||||||
|
|
||||||
|
self._apply_lock()
|
||||||
|
self.retranslate()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def locked(self) -> bool:
|
||||||
|
return self._locked
|
||||||
|
|
||||||
|
def set_rows(self, rows: Iterable[UnitRow], connected: bool) -> None:
|
||||||
|
"""Show one poll's worth of unit facts.
|
||||||
|
|
||||||
|
While the page is unlocked the configured column belongs to the operator and
|
||||||
|
is not overwritten by a refresh; if the drive mapping changes underneath an
|
||||||
|
unlocked edit, the edit is abandoned instead of being applied to another drive.
|
||||||
|
"""
|
||||||
|
self._rows = list(rows)
|
||||||
|
self._connected = bool(connected)
|
||||||
|
if not self._locked and {r.role: r.axis for r in self._rows} != self._unlocked_axes:
|
||||||
|
self._lock()
|
||||||
|
self.message_label.setText(t("units.mapping_changed"))
|
||||||
|
for index, row in enumerate(self._rows[: self.table.rowCount()]):
|
||||||
|
self._render_row(index, row)
|
||||||
|
self._render_summary()
|
||||||
|
self._apply_lock()
|
||||||
|
|
||||||
|
def _render_row(self, index: int, row: UnitRow) -> None:
|
||||||
|
def put(column: int, text: str, *, colour: str | None = None, tip: str = "") -> None:
|
||||||
|
item = self.table.item(index, column)
|
||||||
|
if item is None:
|
||||||
|
return
|
||||||
|
item.setText(text)
|
||||||
|
item.setToolTip(tip)
|
||||||
|
if colour:
|
||||||
|
item.setForeground(QBrush(QColor(colour)))
|
||||||
|
else:
|
||||||
|
item.setData(Qt.ItemDataRole.ForegroundRole, None)
|
||||||
|
|
||||||
|
put(0, t(_ROLE_KEYS.get(row.role, row.role)))
|
||||||
|
put(1, "—" if row.axis < 0 else str(row.axis))
|
||||||
|
put(2, t("units.kind_linear" if row.kind == "linear" else "units.kind_rotary"))
|
||||||
|
put(4, _g(row.observed, 6))
|
||||||
|
put(5, self._scale_text(row))
|
||||||
|
colour = _GOOD if row.ok else (_QUIET if row.status in {UNREAD, UNMAPPED} else _BAD)
|
||||||
|
put(
|
||||||
|
6,
|
||||||
|
t(_STATUS_KEYS[row.status]),
|
||||||
|
colour=colour,
|
||||||
|
tip=t(_TIP_KEYS[row.status]) if row.status in _TIP_KEYS else "",
|
||||||
|
)
|
||||||
|
edit = self._edit_for(index, row)
|
||||||
|
if edit is not None and (self._locked or not edit.isModified()):
|
||||||
|
edit.setText(_g(row.expected, 9))
|
||||||
|
edit.setModified(False)
|
||||||
|
|
||||||
|
def _edit_for(self, index: int, row: UnitRow) -> QLineEdit | None:
|
||||||
|
"""The configured-UNITS cell of one direction, created on first use."""
|
||||||
|
if row.axis < 0:
|
||||||
|
if self._edits.pop(row.role, None) is not None:
|
||||||
|
self.table.removeCellWidget(index, CONFIGURED_COLUMN)
|
||||||
|
return None
|
||||||
|
edit = self._edits.get(row.role)
|
||||||
|
if edit is None:
|
||||||
|
edit = QLineEdit(self.table)
|
||||||
|
edit.setObjectName(f"unitsEdit_{row.role}")
|
||||||
|
edit.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
edit.setFixedHeight(EDIT_HEIGHT)
|
||||||
|
edit.setMinimumWidth(84)
|
||||||
|
# The global style gives line edits generous padding; inside a table cell
|
||||||
|
# that clips the text, so the cell edit is compact.
|
||||||
|
edit.setStyleSheet(
|
||||||
|
"QLineEdit { padding: 0 6px; margin: 0; min-height: 0; border-radius: 4px; }"
|
||||||
|
)
|
||||||
|
self._edits[row.role] = edit
|
||||||
|
self.table.setCellWidget(index, CONFIGURED_COLUMN, edit)
|
||||||
|
edit.setReadOnly(self._locked)
|
||||||
|
return edit
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _scale_text(row: UnitRow) -> str:
|
||||||
|
if not math.isfinite(row.per_unit) or not math.isfinite(row.per_mm):
|
||||||
|
return "—"
|
||||||
|
if row.kind == "linear":
|
||||||
|
return t("units.scale_linear", mm=_g(row.per_unit), per=_g(row.per_mm))
|
||||||
|
return t("units.scale_rotary", deg=_g(row.per_unit), per=_g(row.per_mm))
|
||||||
|
|
||||||
|
def _render_summary(self) -> None:
|
||||||
|
if not self._rows:
|
||||||
|
self.summary_label.setText("")
|
||||||
|
return
|
||||||
|
good, total, problems = summarise(self._rows)
|
||||||
|
only_unread = bool(problems) and all(row.status == UNREAD for row in problems)
|
||||||
|
if not problems:
|
||||||
|
self.summary_label.setText(t("units.summary_ok", total=total))
|
||||||
|
colour = _GOOD
|
||||||
|
elif only_unread:
|
||||||
|
self.summary_label.setText(t("units.summary_unread"))
|
||||||
|
colour = _QUIET
|
||||||
|
else:
|
||||||
|
self.summary_label.setText(
|
||||||
|
t("units.summary_bad", bad=len(problems), total=total)
|
||||||
|
)
|
||||||
|
colour = _BAD
|
||||||
|
self.summary_label.setStyleSheet(f"color: {colour};")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- locking
|
||||||
|
|
||||||
|
def _apply_lock(self) -> None:
|
||||||
|
for edit in self._edits.values():
|
||||||
|
edit.setReadOnly(self._locked)
|
||||||
|
self.modify_button.setVisible(self._locked)
|
||||||
|
self.modify_button.setEnabled(self._connected)
|
||||||
|
self.modify_button.setToolTip("" if self._connected else t("units.modify_offline"))
|
||||||
|
self.apply_button.setVisible(not self._locked)
|
||||||
|
self.apply_button.setEnabled(self._connected)
|
||||||
|
self.cancel_button.setVisible(not self._locked)
|
||||||
|
|
||||||
|
def _unlock(self) -> None:
|
||||||
|
if not self._connected:
|
||||||
|
return
|
||||||
|
self._locked = False
|
||||||
|
self._unlocked_axes = {row.role: row.axis for row in self._rows}
|
||||||
|
self.message_label.setText(t("units.unlocked_note"))
|
||||||
|
self._apply_lock()
|
||||||
|
|
||||||
|
def _lock(self) -> None:
|
||||||
|
self._locked = True
|
||||||
|
self._unlocked_axes = {}
|
||||||
|
for row in self._rows:
|
||||||
|
edit = self._edits.get(row.role)
|
||||||
|
if edit is not None:
|
||||||
|
edit.setText(_g(row.expected, 9))
|
||||||
|
edit.setModified(False)
|
||||||
|
self._apply_lock()
|
||||||
|
|
||||||
|
def _on_cancel(self) -> None:
|
||||||
|
self._lock()
|
||||||
|
self.message_label.setText(t("units.locked_note"))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ apply
|
||||||
|
|
||||||
|
def collect_changes(self) -> tuple[dict[int, float], str]:
|
||||||
|
"""``(changes, problem)`` from the typed values; a problem means nothing is applied."""
|
||||||
|
changes: dict[int, float] = {}
|
||||||
|
for row in self._rows:
|
||||||
|
edit = self._edits.get(row.role)
|
||||||
|
if edit is None or row.axis < 0:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
value = float(edit.text().strip())
|
||||||
|
except ValueError:
|
||||||
|
return {}, t("units.invalid", role=t(_ROLE_KEYS.get(row.role, row.role)))
|
||||||
|
if not math.isfinite(value) or value <= 0:
|
||||||
|
return {}, t("units.invalid", role=t(_ROLE_KEYS.get(row.role, row.role)))
|
||||||
|
if not math.isclose(value, row.expected, rel_tol=1e-9, abs_tol=0.0):
|
||||||
|
changes[row.axis] = value
|
||||||
|
return changes, ""
|
||||||
|
|
||||||
|
def _on_apply(self) -> None:
|
||||||
|
changes, problem = self.collect_changes()
|
||||||
|
if problem:
|
||||||
|
self.message_label.setText(problem)
|
||||||
|
return
|
||||||
|
if not changes:
|
||||||
|
self._lock()
|
||||||
|
self.message_label.setText(t("units.no_change"))
|
||||||
|
return
|
||||||
|
if not self._confirm(changes):
|
||||||
|
return
|
||||||
|
self._lock()
|
||||||
|
self.message_label.setText(t("units.applied", count=len(changes)))
|
||||||
|
self.unitsApplied.emit(dict(changes))
|
||||||
|
|
||||||
|
def _confirm(self, changes: dict[int, float]) -> bool:
|
||||||
|
by_axis = {row.axis: row for row in self._rows if row.axis >= 0}
|
||||||
|
lines = []
|
||||||
|
off_calibration = []
|
||||||
|
for axis, value in sorted(changes.items()):
|
||||||
|
row = by_axis.get(axis)
|
||||||
|
role = t(_ROLE_KEYS.get(row.role, row.role)) if row else "?"
|
||||||
|
lines.append(
|
||||||
|
t("units.change_line", role=role, axis=axis, old=_g(row.expected if row else None, 9), new=_g(value, 9))
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
row is not None
|
||||||
|
and row.kind == "rotary"
|
||||||
|
and axis in ROTARY_UNITS_PER_90_DEG
|
||||||
|
and not math.isclose(value, ROTARY_CALIBRATION_UNITS, rel_tol=1e-7, abs_tol=0.0)
|
||||||
|
):
|
||||||
|
off_calibration.append(role)
|
||||||
|
body = t("units.confirm_body", changes="\n".join(lines))
|
||||||
|
if off_calibration:
|
||||||
|
body += "\n\n" + t(
|
||||||
|
"units.confirm_rotary",
|
||||||
|
roles=", ".join(off_calibration),
|
||||||
|
cal=_g(ROTARY_CALIBRATION_UNITS),
|
||||||
|
)
|
||||||
|
answer = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
t("units.confirm_title"),
|
||||||
|
body,
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
QMessageBox.StandardButton.No,
|
||||||
|
)
|
||||||
|
return answer == QMessageBox.StandardButton.Yes
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- translations
|
||||||
|
|
||||||
|
def retranslate(self) -> None:
|
||||||
|
self.hint_label.setText(t("units.hint"))
|
||||||
|
self.modify_button.setText(t("units.modify"))
|
||||||
|
self.apply_button.setText(t("units.apply"))
|
||||||
|
self.cancel_button.setText(t("units.cancel"))
|
||||||
|
self.table.setHorizontalHeaderLabels([t(key) for key in COLUMNS])
|
||||||
|
rotary = ";".join(
|
||||||
|
t("units.rotary_item", axis=axis, units=_g(value, 9))
|
||||||
|
for axis, value in sorted(ROTARY_UNITS_PER_90_DEG.items())
|
||||||
|
)
|
||||||
|
self.constants_label.setText(
|
||||||
|
t(
|
||||||
|
"units.constants",
|
||||||
|
ppm=_g(PULSES_PER_MM, 9),
|
||||||
|
rotary=rotary,
|
||||||
|
cal=_g(ROTARY_CALIBRATION_UNITS),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not self.message_label.text():
|
||||||
|
self.message_label.setText(t("units.locked_note"))
|
||||||
|
for index, row in enumerate(self._rows[: self.table.rowCount()]):
|
||||||
|
self._render_row(index, row)
|
||||||
|
self._render_summary()
|
||||||
|
self._apply_lock()
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
"""Play back one reviewed hold window from the recording that already exists.
|
||||||
|
|
||||||
|
The dialog decodes frames off the Qt thread and drops results that belong to a
|
||||||
|
request the operator has already moved past, so switching windows quickly cannot
|
||||||
|
show the previous hold's picture. Closing it releases the decoder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries, QValueAxis
|
||||||
|
from PySide6.QtCore import QPointF, Qt, QTimer, Slot
|
||||||
|
from PySide6.QtGui import QImage, QPixmap
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QSlider,
|
||||||
|
QVBoxLayout,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..video_review import Location, open_window_video
|
||||||
|
from .chart_helpers import clear_chart
|
||||||
|
from .review_job import ReviewJob
|
||||||
|
|
||||||
|
#: Playback advances one recorded frame per tick; the recorded timing is the
|
||||||
|
#: frame index, not the container's nominal rate.
|
||||||
|
PLAYBACK_INTERVAL_MS = 100
|
||||||
|
|
||||||
|
|
||||||
|
class VideoReviewDialog(QDialog):
|
||||||
|
"""The pictures of one hold, beside the curve the operator reviewed."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
directory: Path,
|
||||||
|
review: dict,
|
||||||
|
*,
|
||||||
|
trial_id: str,
|
||||||
|
step_index: int,
|
||||||
|
parent=None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("videoReview")
|
||||||
|
self.resize(980, 720)
|
||||||
|
self.setWindowTitle(t("video.title"))
|
||||||
|
self.directory = Path(directory)
|
||||||
|
self.review = review
|
||||||
|
self.trial_id = trial_id
|
||||||
|
self.step_index = int(step_index)
|
||||||
|
self.window = self._window()
|
||||||
|
self.video = None
|
||||||
|
self.location: Location | None = None
|
||||||
|
self.index = 0
|
||||||
|
self._generation = 0
|
||||||
|
self._playing = False
|
||||||
|
self._job = None
|
||||||
|
self._reason = ""
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
self.header = QLabel(self)
|
||||||
|
self.header.setWordWrap(True)
|
||||||
|
layout.addWidget(self.header)
|
||||||
|
self.image = QLabel(self)
|
||||||
|
self.image.setMinimumSize(480, 320)
|
||||||
|
self.image.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.image.setStyleSheet("#videoReview QLabel#videoFrame {background:#111;color:#ddd;}")
|
||||||
|
self.image.setObjectName("videoFrame")
|
||||||
|
layout.addWidget(self.image, 3)
|
||||||
|
self.curve = QChart()
|
||||||
|
self.curve_view = QChartView(self.curve, self)
|
||||||
|
self.curve_view.setMinimumHeight(180)
|
||||||
|
layout.addWidget(self.curve_view, 2)
|
||||||
|
controls = QHBoxLayout()
|
||||||
|
self.prev_button = QPushButton(t("video.previous"), self)
|
||||||
|
self.play_button = QPushButton(t("video.play"), self)
|
||||||
|
self.next_button = QPushButton(t("video.next"), self)
|
||||||
|
self.slider = QSlider(Qt.Orientation.Horizontal, self)
|
||||||
|
self.slider.setMinimum(0)
|
||||||
|
controls.addWidget(self.prev_button)
|
||||||
|
controls.addWidget(self.play_button)
|
||||||
|
controls.addWidget(self.next_button)
|
||||||
|
controls.addWidget(self.slider, 1)
|
||||||
|
layout.addLayout(controls)
|
||||||
|
self.position = QLabel(self)
|
||||||
|
self.position.setWordWrap(True)
|
||||||
|
layout.addWidget(self.position)
|
||||||
|
self.message = QLabel(self)
|
||||||
|
self.message.setWordWrap(True)
|
||||||
|
layout.addWidget(self.message)
|
||||||
|
|
||||||
|
self.prev_button.clicked.connect(lambda: self._step(-1))
|
||||||
|
self.next_button.clicked.connect(lambda: self._step(1))
|
||||||
|
self.play_button.clicked.connect(self._toggle_play)
|
||||||
|
self.slider.valueChanged.connect(self._slider_moved)
|
||||||
|
self.timer = QTimer(self)
|
||||||
|
self.timer.setInterval(PLAYBACK_INTERVAL_MS)
|
||||||
|
self.timer.timeout.connect(lambda: self._step(1, from_playback=True))
|
||||||
|
|
||||||
|
self._describe_window()
|
||||||
|
self._draw_curve()
|
||||||
|
self._open()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- set-up
|
||||||
|
def _window(self) -> dict:
|
||||||
|
for site in self.review.get("sites") or []:
|
||||||
|
for window in site.get("windows") or []:
|
||||||
|
if window["trial_id"] == self.trial_id and int(window["step_index"]) == self.step_index:
|
||||||
|
return window
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _describe_window(self) -> None:
|
||||||
|
window = self.window
|
||||||
|
if not window:
|
||||||
|
self.header.setText(t("video.reason_window_not_found"))
|
||||||
|
return
|
||||||
|
self.header.setText(t("video.header").format(
|
||||||
|
trial=self.trial_id,
|
||||||
|
step=window.get("step_index"),
|
||||||
|
branch=t("calibration." + str(window.get("branch") or "load")),
|
||||||
|
load=f"{float(window.get('reference_n') or 0.0):g}",
|
||||||
|
status=str(window.get("status") or ""),
|
||||||
|
samples=window.get("samples", 0),
|
||||||
|
))
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
if not self.window:
|
||||||
|
self._disable(t("video.reason_window_not_found"))
|
||||||
|
return
|
||||||
|
self._set_busy(True)
|
||||||
|
review = self.review
|
||||||
|
trial_id, step_index = self.trial_id, self.step_index
|
||||||
|
review_job = ReviewJob(
|
||||||
|
lambda: open_window_video(review, trial_id=trial_id, step_index=step_index),
|
||||||
|
self._opened,
|
||||||
|
)
|
||||||
|
self._job = review_job
|
||||||
|
review_job.start()
|
||||||
|
|
||||||
|
@Slot(object, object)
|
||||||
|
def _opened(self, value, error) -> None:
|
||||||
|
self._job = None
|
||||||
|
if self._closed:
|
||||||
|
if error is None and value is not None:
|
||||||
|
value.close()
|
||||||
|
return
|
||||||
|
self._set_busy(False)
|
||||||
|
if error is not None:
|
||||||
|
self._disable(self._reason_text(error))
|
||||||
|
return
|
||||||
|
self.video = value
|
||||||
|
self.location = value.location
|
||||||
|
self.slider.setMaximum(max(0, value.frame_count - 1))
|
||||||
|
self.slider.setValue(0)
|
||||||
|
self.prev_button.setEnabled(True)
|
||||||
|
self.next_button.setEnabled(True)
|
||||||
|
self.play_button.setEnabled(value.frame_count > 0)
|
||||||
|
self._request(0)
|
||||||
|
|
||||||
|
def _reason_text(self, error: str) -> str:
|
||||||
|
key = {
|
||||||
|
"no_frame_source": "video.reason_no_frame_source",
|
||||||
|
"no_video_frame": "video.reason_no_video_frame",
|
||||||
|
"trial_not_found": "video.reason_trial_not_found",
|
||||||
|
"window_not_found": "video.reason_window_not_found",
|
||||||
|
"hold_not_found": "video.reason_hold_not_found",
|
||||||
|
"hold_not_complete": "video.reason_hold_not_complete",
|
||||||
|
"video_undecodable": "video.reason_video_undecodable",
|
||||||
|
}.get(str(error))
|
||||||
|
return t(key) if key else t("video.reason_missing").format(detail=error)
|
||||||
|
|
||||||
|
def _disable(self, message: str) -> None:
|
||||||
|
self._reason = message
|
||||||
|
self.play_button.setEnabled(False)
|
||||||
|
self.prev_button.setEnabled(False)
|
||||||
|
self.next_button.setEnabled(False)
|
||||||
|
self.slider.setEnabled(False)
|
||||||
|
self.image.setText(message)
|
||||||
|
self.position.setText(message)
|
||||||
|
self.message.setText(message)
|
||||||
|
|
||||||
|
def _set_busy(self, busy: bool) -> None:
|
||||||
|
for widget in (self.play_button, self.prev_button, self.next_button, self.slider):
|
||||||
|
widget.setEnabled(not busy)
|
||||||
|
if busy:
|
||||||
|
self.message.setText(t("review.checking"))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ playback
|
||||||
|
def _request(self, index: int) -> None:
|
||||||
|
video = self.video
|
||||||
|
if video is None or video.frame_count == 0 or self._closed:
|
||||||
|
return
|
||||||
|
index = max(0, min(int(index), video.frame_count - 1))
|
||||||
|
self.index = index
|
||||||
|
self._generation += 1
|
||||||
|
generation = self._generation
|
||||||
|
self._show_position(index)
|
||||||
|
job = ReviewJob(
|
||||||
|
lambda: (generation, index, video.frame(index)),
|
||||||
|
self._frame_ready,
|
||||||
|
)
|
||||||
|
self._job = job
|
||||||
|
job.start()
|
||||||
|
|
||||||
|
@Slot(object, object)
|
||||||
|
def _frame_ready(self, value, error) -> None:
|
||||||
|
self._job = None
|
||||||
|
if self._closed:
|
||||||
|
# The decoder was released with the dialog; a late picture must not
|
||||||
|
# reopen it or paint over the closed window.
|
||||||
|
return
|
||||||
|
if error is not None:
|
||||||
|
self.message.setText(t("video.reason_decode"))
|
||||||
|
return
|
||||||
|
generation, index, image = value
|
||||||
|
if generation != self._generation:
|
||||||
|
return # the operator moved on; this picture belongs to an old request
|
||||||
|
if image is None:
|
||||||
|
self.image.setText(t("video.frame_missing"))
|
||||||
|
self.message.setText(t("video.frame_missing"))
|
||||||
|
return
|
||||||
|
self.image.setPixmap(self._pixmap(image))
|
||||||
|
self.message.clear()
|
||||||
|
self._draw_cursor(index)
|
||||||
|
|
||||||
|
def _pixmap(self, image: np.ndarray) -> QPixmap:
|
||||||
|
rgb = np.ascontiguousarray(np.asarray(image)[:, :, ::-1])
|
||||||
|
height, width = rgb.shape[:2]
|
||||||
|
qimage = QImage(rgb.data, width, height, 3 * width, QImage.Format.Format_RGB888)
|
||||||
|
return QPixmap.fromImage(qimage.copy()).scaled(
|
||||||
|
self.image.size(),
|
||||||
|
Qt.AspectRatioMode.KeepAspectRatio,
|
||||||
|
Qt.TransformationMode.SmoothTransformation,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _slider_moved(self, value: int) -> None:
|
||||||
|
if self.video is None:
|
||||||
|
return
|
||||||
|
if value != self.index:
|
||||||
|
self._request(value)
|
||||||
|
|
||||||
|
def _step(self, delta: int, *, from_playback: bool = False) -> None:
|
||||||
|
if self.video is None or self.video.frame_count == 0:
|
||||||
|
return
|
||||||
|
target = self.index + delta
|
||||||
|
if target >= self.video.frame_count:
|
||||||
|
self._stop_playback()
|
||||||
|
return
|
||||||
|
target = max(0, target)
|
||||||
|
self.slider.blockSignals(True)
|
||||||
|
self.slider.setValue(target)
|
||||||
|
self.slider.blockSignals(False)
|
||||||
|
self._request(target)
|
||||||
|
if not from_playback:
|
||||||
|
self._stop_playback()
|
||||||
|
|
||||||
|
def _toggle_play(self) -> None:
|
||||||
|
if self._playing:
|
||||||
|
self._stop_playback()
|
||||||
|
else:
|
||||||
|
self._playing = True
|
||||||
|
self.play_button.setText(t("video.pause"))
|
||||||
|
self.timer.start()
|
||||||
|
|
||||||
|
def _stop_playback(self) -> None:
|
||||||
|
self._playing = False
|
||||||
|
self.timer.stop()
|
||||||
|
self.play_button.setText(t("video.play"))
|
||||||
|
|
||||||
|
def _show_position(self, index: int) -> None:
|
||||||
|
location = self.location
|
||||||
|
window = self.window
|
||||||
|
if location is None:
|
||||||
|
return
|
||||||
|
frame = self.video.frames[index] if self.video is not None else None
|
||||||
|
self.position.setText(t("video.position").format(
|
||||||
|
index=index + 1,
|
||||||
|
total=self.video.frame_count if self.video is not None else 0,
|
||||||
|
frame=frame.frame_id if frame is not None else "—",
|
||||||
|
segment=frame.segment if frame is not None else "—",
|
||||||
|
segment_frame=frame.segment_frame if frame is not None else "—",
|
||||||
|
basis=str(window.get("time_basis") or t("video.basis_unknown")),
|
||||||
|
))
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- charts
|
||||||
|
def _draw_curve(self) -> None:
|
||||||
|
clear_chart(self.curve)
|
||||||
|
window = self.window
|
||||||
|
if not window:
|
||||||
|
return
|
||||||
|
origin = int(window["t_start_host_mono_ns"])
|
||||||
|
duration = max((int(window["t_end_host_mono_ns"]) - origin) / 1e9, 0.001)
|
||||||
|
selected_trace = [
|
||||||
|
(stamp, value) for stamp, _frame, value in window.get("trace") or []
|
||||||
|
if origin <= stamp < int(window["t_end_host_mono_ns"])
|
||||||
|
]
|
||||||
|
trace = QScatterSeries()
|
||||||
|
trace.setMarkerSize(3)
|
||||||
|
trace.setName(t("review.valid_samples"))
|
||||||
|
trace.replace([
|
||||||
|
QPointF((stamp - origin) / 1e9, value)
|
||||||
|
for stamp, value in selected_trace
|
||||||
|
])
|
||||||
|
self.curve.addSeries(trace)
|
||||||
|
self.curve_line = QLineSeries()
|
||||||
|
self.curve_line.setName(t("video.cursor"))
|
||||||
|
self.curve.addSeries(self.curve_line)
|
||||||
|
reference = QLineSeries()
|
||||||
|
reference.setName(t("video.reference"))
|
||||||
|
reference.append(0.0, float(window.get("reference_n") or 0.0))
|
||||||
|
reference.append(duration,
|
||||||
|
float(window.get("reference_n") or 0.0))
|
||||||
|
self.curve.addSeries(reference)
|
||||||
|
axis_x = QValueAxis()
|
||||||
|
axis_x.setTitleText(t("review.hold_time_axis"))
|
||||||
|
axis_x.setRange(0, duration)
|
||||||
|
axis_y = QValueAxis()
|
||||||
|
axis_y.setTitleText(t("review.raw_axis"))
|
||||||
|
values = [value for _stamp, value in selected_trace]
|
||||||
|
values.append(float(window.get("reference_n") or 0.0))
|
||||||
|
low, high = (min(values), max(values)) if values else (0.0, 1.0)
|
||||||
|
margin = max((high - low) * 0.1, 0.1)
|
||||||
|
axis_y.setRange(low - margin, high + margin)
|
||||||
|
self.curve.addAxis(axis_x, Qt.AlignmentFlag.AlignBottom)
|
||||||
|
self.curve.addAxis(axis_y, Qt.AlignmentFlag.AlignLeft)
|
||||||
|
for series in self.curve.series():
|
||||||
|
series.attachAxis(axis_x)
|
||||||
|
series.attachAxis(axis_y)
|
||||||
|
self.curve.legend().hide()
|
||||||
|
self.curve.setTitle(t("video.curve_title"))
|
||||||
|
self._cursor_axis_range = (0.0, axis_x.max())
|
||||||
|
|
||||||
|
def _draw_cursor(self, index: int) -> None:
|
||||||
|
if self.video is None or not hasattr(self, "curve_line"):
|
||||||
|
return
|
||||||
|
window = self.window
|
||||||
|
frame = self.video.frames[index]
|
||||||
|
origin = int(window["t_start_host_mono_ns"])
|
||||||
|
x = (frame.t_host_mono_ns - origin) / 1e9
|
||||||
|
axis = self.curve.axes(Qt.Orientation.Vertical)
|
||||||
|
low, high = (axis[0].min(), axis[0].max()) if axis else (0.0, 1.0)
|
||||||
|
self.curve_line.replace([QPointF(x, low), QPointF(x, high)])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- teardown
|
||||||
|
def closeEvent(self, event) -> None:
|
||||||
|
self._closed = True
|
||||||
|
self._stop_playback()
|
||||||
|
if self.video is not None:
|
||||||
|
self.video.close()
|
||||||
|
self.video = None
|
||||||
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
def done(self, result: int) -> None:
|
||||||
|
self._closed = True
|
||||||
|
self._stop_playback()
|
||||||
|
if self.video is not None:
|
||||||
|
self.video.close()
|
||||||
|
self.video = None
|
||||||
|
super().done(result)
|
||||||
@@ -10,8 +10,7 @@ the ctypes handle single-threaded.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import math
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -39,9 +38,16 @@ from PySide6.QtWidgets import (
|
|||||||
from .. import __version__ as app_version
|
from .. import __version__ as app_version
|
||||||
from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot
|
from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot
|
||||||
from ..motion.kinematics import PlatformStack
|
from ..motion.kinematics import PlatformStack
|
||||||
from ..motion.pose_move import PoseMover, plan_pose_move, plan_return_to_initial
|
from ..motion.pose_move import (
|
||||||
|
PoseMover,
|
||||||
|
PressAxis,
|
||||||
|
plan_pose_move,
|
||||||
|
plan_return_to_initial,
|
||||||
|
press_targets,
|
||||||
|
)
|
||||||
from ..motion.service import clamp_bas_task
|
from ..motion.service import clamp_bas_task
|
||||||
from ..motion.units import DEFAULT_UNITS
|
from ..motion.unit_report import build_unit_rows
|
||||||
|
from ..motion.units import DEFAULT_UNITS, user_units_to_deg, user_units_to_mm
|
||||||
from ..preferences import load_preferences, save_preferences
|
from ..preferences import load_preferences, save_preferences
|
||||||
from ..sensor import (
|
from ..sensor import (
|
||||||
BundleError,
|
BundleError,
|
||||||
@@ -50,7 +56,7 @@ from ..sensor import (
|
|||||||
rasterise,
|
rasterise,
|
||||||
texture_path_for,
|
texture_path_for,
|
||||||
)
|
)
|
||||||
from ..sensor.uv_flat import DEFAULT_FLAT_SIZE
|
from ..sensor.uv_flat import DEFAULT_FLAT_SIZE, uv_origin
|
||||||
from ..sequence_io import (
|
from ..sequence_io import (
|
||||||
FILE_SUFFIX,
|
FILE_SUFFIX,
|
||||||
SequenceDocument,
|
SequenceDocument,
|
||||||
@@ -64,7 +70,10 @@ from .axis_readout import AxisReadoutPanel
|
|||||||
from .brand_bar import BrandBar
|
from .brand_bar import BrandBar
|
||||||
from .bus_data_panel import BusDataPanel
|
from .bus_data_panel import BusDataPanel
|
||||||
from .bus_panel import default_bas_dir
|
from .bus_panel import default_bas_dir
|
||||||
|
from .calibration_panel import CalibrationPanel
|
||||||
from .device_setup import DeviceSetupDialog
|
from .device_setup import DeviceSetupDialog
|
||||||
|
from .finger_source_panel import FingerSourcePanel
|
||||||
|
from .reveal import reveal
|
||||||
from .sensor_panel import FingerPanel
|
from .sensor_panel import FingerPanel
|
||||||
from .sequence_panel import SequencePanel
|
from .sequence_panel import SequencePanel
|
||||||
from .status_strip import RunBar, StatusStrip
|
from .status_strip import RunBar, StatusStrip
|
||||||
@@ -134,6 +143,10 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self._last_snapshot = Snapshot()
|
self._last_snapshot = Snapshot()
|
||||||
self._pending: dict[int, str] = {}
|
self._pending: dict[int, str] = {}
|
||||||
self._last_error = ""
|
self._last_error = ""
|
||||||
|
#: Whether the move in flight was requested by the capture page, so that the
|
||||||
|
#: page can be told the alignment finished or did not. A move the finger page
|
||||||
|
#: asked for must never mark a capture point as aligned.
|
||||||
|
self._capture_alignment = False
|
||||||
|
|
||||||
# Apply the remembered language before anything is built. The translator
|
# Apply the remembered language before anything is built. The translator
|
||||||
# is process-global, and only the menu ever set it, so a saved language
|
# is process-global, and only the menu ever set it, so a saved language
|
||||||
@@ -217,6 +230,7 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self.spec,
|
self.spec,
|
||||||
str(self.prefs.get("bas_path") or ""),
|
str(self.prefs.get("bas_path") or ""),
|
||||||
self.prefs.get("axis_roles"),
|
self.prefs.get("axis_roles"),
|
||||||
|
self._press_axis,
|
||||||
self,
|
self,
|
||||||
)
|
)
|
||||||
self.connection_panel = self.device_setup.connection_panel
|
self.connection_panel = self.device_setup.connection_panel
|
||||||
@@ -224,6 +238,9 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
# The mapping is live: the guards read it from the service, so an edit on the
|
# The mapping is live: the guards read it from the service, so an edit on the
|
||||||
# page has to reach the service rather than only the preference file.
|
# page has to reach the service rather than only the preference file.
|
||||||
self.device_setup.roles_page.rolesChanged.connect(self._on_axis_roles_changed)
|
self.device_setup.roles_page.rolesChanged.connect(self._on_axis_roles_changed)
|
||||||
|
# The press-axis relation is live for the same reason: it decides whether a
|
||||||
|
# geometric contact height may become a controller coordinate at all.
|
||||||
|
self.device_setup.press_page.pressChanged.connect(self._on_press_axis_changed)
|
||||||
|
|
||||||
self.axis_readout = AxisReadoutPanel(
|
self.axis_readout = AxisReadoutPanel(
|
||||||
MotionService().axis_count, self._theme, self.rail_content
|
MotionService().axis_count, self._theme, self.rail_content
|
||||||
@@ -276,8 +293,8 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
# platform connected. Its stack carries the bench's stage travel limits,
|
# platform connected. Its stack carries the bench's stage travel limits,
|
||||||
# because a pose outside them is not a pose.
|
# because a pose outside them is not a pose.
|
||||||
self.finger_stack = PlatformStack(
|
self.finger_stack = PlatformStack(
|
||||||
yaw_limit_deg=float(self.prefs.get("finger_yaw_limit_deg") or 90.0),
|
yaw_limit_deg=float(self.prefs.get("finger_yaw_limit_deg") or 180.0),
|
||||||
pitch_limit_deg=float(self.prefs.get("finger_pitch_limit_deg") or 360.0),
|
pitch_limit_deg=float(self.prefs.get("finger_pitch_limit_deg") or 90.0),
|
||||||
)
|
)
|
||||||
self.finger_panel = FingerPanel(
|
self.finger_panel = FingerPanel(
|
||||||
self.finger_stack,
|
self.finger_stack,
|
||||||
@@ -287,9 +304,33 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
tool_xy_mm=_triple(self.prefs.get("finger_tool_xy_mm"), 2),
|
tool_xy_mm=_triple(self.prefs.get("finger_tool_xy_mm"), 2),
|
||||||
parent=body,
|
parent=body,
|
||||||
)
|
)
|
||||||
self.workspace = WorkspaceTabs(
|
self.finger_panel.set_press(self._press_axis)
|
||||||
self.axis_panel, self.sequence_panel, self.finger_panel, body
|
self.finger_source_panel = FingerSourcePanel(
|
||||||
|
video_path=str(self.prefs.get("finger_video_path") or ""),
|
||||||
|
config_ref=str(self.prefs.get("finger_config_ref") or ""),
|
||||||
|
parent=body,
|
||||||
|
source_kind=str(self.prefs.get("finger_source_kind") or "video"),
|
||||||
|
camera_index=self.prefs.get("finger_camera_index") or 0,
|
||||||
)
|
)
|
||||||
|
self.calibration_panel = CalibrationPanel(
|
||||||
|
self.finger_source_panel,
|
||||||
|
body,
|
||||||
|
prefs=self.prefs,
|
||||||
|
solver=self.finger_stack,
|
||||||
|
bus_problem=self._capture_bus_problem,
|
||||||
|
)
|
||||||
|
self.calibration_panel.loadBundleRequested.connect(self._on_load_bundle)
|
||||||
|
self.workspace = WorkspaceTabs(
|
||||||
|
self.axis_panel,
|
||||||
|
self.sequence_panel,
|
||||||
|
self.finger_panel,
|
||||||
|
body,
|
||||||
|
sensor_panel=self.calibration_panel,
|
||||||
|
)
|
||||||
|
self.calibration_panel.reviewRequested.connect(self._on_review_records)
|
||||||
|
# The geometry page's 2D and 3D views are also the sequence page's.
|
||||||
|
self.workspace.pageChanged.connect(self._place_shared_views)
|
||||||
|
self._place_shared_views(self.workspace.current_key())
|
||||||
|
|
||||||
self.main_splitter = QSplitter(Qt.Orientation.Horizontal, body)
|
self.main_splitter = QSplitter(Qt.Orientation.Horizontal, body)
|
||||||
self.main_splitter.addWidget(self.left_rail)
|
self.main_splitter.addWidget(self.left_rail)
|
||||||
@@ -310,6 +351,10 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
|
|
||||||
self._wire_signals()
|
self._wire_signals()
|
||||||
|
|
||||||
|
def _on_review_records(self, directory: str) -> None:
|
||||||
|
self.workspace.page("records").load_session(Path(directory))
|
||||||
|
self.workspace.set_current_key("records")
|
||||||
|
|
||||||
def _wire_signals(self) -> None:
|
def _wire_signals(self) -> None:
|
||||||
self.connection_panel.connectRequested.connect(self._on_connect)
|
self.connection_panel.connectRequested.connect(self._on_connect)
|
||||||
self.connection_panel.disconnectRequested.connect(self._on_disconnect)
|
self.connection_panel.disconnectRequested.connect(self._on_disconnect)
|
||||||
@@ -326,6 +371,8 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self.axis_panel.moveRelRequested.connect(self._on_move_rel)
|
self.axis_panel.moveRelRequested.connect(self._on_move_rel)
|
||||||
self.axis_panel.homeRequested.connect(self._on_home)
|
self.axis_panel.homeRequested.connect(self._on_home)
|
||||||
self.axis_panel.clearAlarmRequested.connect(self._on_clear_alarm)
|
self.axis_panel.clearAlarmRequested.connect(self._on_clear_alarm)
|
||||||
|
self.axis_panel.unitsRequested.connect(lambda: self._show_device_setup("units"))
|
||||||
|
self.device_setup.units_page.unitsApplied.connect(self._on_units_applied)
|
||||||
self.bus_data_panel.sdoReadRequested.connect(self._on_sdo_read)
|
self.bus_data_panel.sdoReadRequested.connect(self._on_sdo_read)
|
||||||
self.bus_data_panel.sdoWriteRequested.connect(self._on_sdo_write)
|
self.bus_data_panel.sdoWriteRequested.connect(self._on_sdo_write)
|
||||||
self.bus_data_panel.rtexReadRequested.connect(self._on_rtex_read)
|
self.bus_data_panel.rtexReadRequested.connect(self._on_rtex_read)
|
||||||
@@ -349,17 +396,44 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self.finger_panel.executeRequested.connect(self._on_execute_pose)
|
self.finger_panel.executeRequested.connect(self._on_execute_pose)
|
||||||
self.finger_panel.stopRequested.connect(self._on_stop_all)
|
self.finger_panel.stopRequested.connect(self._on_stop_all)
|
||||||
self.finger_panel.confirmChanged.connect(self._on_confirm_moves_changed)
|
self.finger_panel.confirmChanged.connect(self._on_confirm_moves_changed)
|
||||||
|
self.calibration_panel.alignRequested.connect(self._on_align_multipoint)
|
||||||
self.run_bar.runRequested.connect(self._on_run_requested)
|
self.run_bar.runRequested.connect(self._on_run_requested)
|
||||||
self.run_bar.pauseRequested.connect(self._on_run_pause)
|
self.run_bar.pauseRequested.connect(self._on_run_pause)
|
||||||
self.run_bar.abortRequested.connect(self._on_run_abort)
|
self.run_bar.abortRequested.connect(self._on_run_abort)
|
||||||
self.sequence_panel.exportRequested.connect(self._on_export_sequence)
|
self.sequence_panel.exportRequested.connect(self._on_export_sequence)
|
||||||
self.sequence_panel.importRequested.connect(self._on_import_sequence)
|
self.sequence_panel.importRequested.connect(self._on_import_sequence)
|
||||||
|
# The array tool proposes points, the map shows them, the table keeps them.
|
||||||
|
self.sequence_panel.pointsChanged.connect(self._refresh_sequence_overlay)
|
||||||
|
tool = self.sequence_panel.array_tool
|
||||||
|
tool.previewChanged.connect(self._on_array_preview)
|
||||||
|
tool.addRequested.connect(self._on_array_add)
|
||||||
|
tool.centreFromPickRequested.connect(self._on_array_centre_from_pick)
|
||||||
|
self.finger_panel.viewStyleChanged.connect(self._on_view_style_changed)
|
||||||
|
self.finger_panel.set_view_style(
|
||||||
|
str(self.prefs.get("finger_view_background") or "gray"),
|
||||||
|
str(self.prefs.get("finger_marker_colour") or "yellow"),
|
||||||
|
)
|
||||||
|
self._refresh_array_preview()
|
||||||
|
self.sequence_panel.addPickedRequested.connect(self._on_add_picked_to_sequence)
|
||||||
|
self.sequence_panel.sendToCaptureRequested.connect(self._on_send_sequence_to_capture)
|
||||||
self.finger_panel.loadRequested.connect(self._on_load_bundle)
|
self.finger_panel.loadRequested.connect(self._on_load_bundle)
|
||||||
self.finger_panel.pickRequested.connect(self._on_pick_point)
|
self.finger_panel.pickRequested.connect(self._on_pick_point)
|
||||||
self.finger_panel.stackChanged.connect(self._on_finger_stack_changed)
|
self.finger_panel.stackChanged.connect(self._on_finger_stack_changed)
|
||||||
|
|
||||||
# ----------------------------------------------------------- device setup
|
# ----------------------------------------------------------- device setup
|
||||||
|
|
||||||
|
def _place_shared_views(self, key: str) -> None:
|
||||||
|
"""Show the surface picker and 3D preview on whichever of the two pages is open.
|
||||||
|
|
||||||
|
One set of widgets, so a point picked on either page, the loaded bundle and
|
||||||
|
the simulation are the same state; only the parent changes with the tab.
|
||||||
|
"""
|
||||||
|
if key == "sequence":
|
||||||
|
picker, preview = self.finger_panel.release_views()
|
||||||
|
self.sequence_panel.adopt_views(picker, preview)
|
||||||
|
elif self.sequence_panel.release_views() or self.finger_panel.views_lent:
|
||||||
|
self.finger_panel.restore_views()
|
||||||
|
|
||||||
def _show_device_setup(self, tab: str | None = None) -> None:
|
def _show_device_setup(self, tab: str | None = None) -> None:
|
||||||
"""Open the connection/bus dialog, optionally on one of its tabs."""
|
"""Open the connection/bus dialog, optionally on one of its tabs."""
|
||||||
self.device_setup.open_for(tab)
|
self.device_setup.open_for(tab)
|
||||||
@@ -381,6 +455,60 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
problem = self.poller.service.roles_problem()
|
problem = self.poller.service.roles_problem()
|
||||||
if problem:
|
if problem:
|
||||||
self.finger_panel.set_move_status(f"✗ {problem}")
|
self.finger_panel.set_move_status(f"✗ {problem}")
|
||||||
|
self._refresh_unit_report()
|
||||||
|
|
||||||
|
def _refresh_unit_report(self) -> None:
|
||||||
|
"""Feed the unit table and the axis page's one-line check from one computation.
|
||||||
|
|
||||||
|
Both read the same rows, so the line on the axis page and the table in the
|
||||||
|
device setup cannot disagree. Nothing here writes to the controller.
|
||||||
|
"""
|
||||||
|
panel = getattr(self, "axis_panel", None)
|
||||||
|
if panel is None:
|
||||||
|
return
|
||||||
|
snapshot = panel.snapshot
|
||||||
|
rows = build_unit_rows(
|
||||||
|
self.device_setup.roles_page.roles(), panel.expected_units, snapshot
|
||||||
|
)
|
||||||
|
self.device_setup.units_page.set_rows(rows, snapshot.connected)
|
||||||
|
panel.set_unit_rows(rows, snapshot.connected)
|
||||||
|
|
||||||
|
def _on_units_applied(self, changes: dict) -> None:
|
||||||
|
"""Confirmed unit changes go out through the ordinary axis-parameter path."""
|
||||||
|
self.axis_panel.apply_units(changes)
|
||||||
|
self._refresh_unit_report()
|
||||||
|
|
||||||
|
def _capture_bus_problem(self) -> str:
|
||||||
|
"""Why the capture page's bus check fails, or ``""`` when it passes.
|
||||||
|
|
||||||
|
The capture page may only start on a connected controller with an
|
||||||
|
initialised bus and a sound axis mapping; the page renders this answer and
|
||||||
|
does not read the service itself.
|
||||||
|
"""
|
||||||
|
service = self.poller.service
|
||||||
|
if not service.connected:
|
||||||
|
return "the controller is not connected"
|
||||||
|
if not service.bus.initialized:
|
||||||
|
return "the bus is not initialised"
|
||||||
|
return service.roles_problem()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _press_axis(self) -> PressAxis:
|
||||||
|
"""How the press axis's coordinate relates to a contact height.
|
||||||
|
|
||||||
|
A bench measurement, not a derived quantity: the geometric height a pose
|
||||||
|
produces says nothing about what the controller will read when the press
|
||||||
|
face reaches it. Until the operator has measured that relation the press
|
||||||
|
role is held, so an uncalibrated machine cannot be commanded to a height
|
||||||
|
that means nothing to it.
|
||||||
|
"""
|
||||||
|
return PressAxis.from_settings(self.prefs.get("press_axis"))
|
||||||
|
|
||||||
|
def _on_press_axis_changed(self) -> None:
|
||||||
|
"""Apply a press-axis edit to the panel that turns heights into targets."""
|
||||||
|
press = self.device_setup.press_page.press()
|
||||||
|
self.prefs["press_axis"] = dict(press)
|
||||||
|
self.finger_panel.set_press(self._press_axis)
|
||||||
|
|
||||||
# ---------------------------------------------------------- finger debug
|
# ---------------------------------------------------------- finger debug
|
||||||
|
|
||||||
@@ -391,6 +519,8 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
export, a missing file or an unreadable mesh must leave the rest of the
|
export, a missing file or an unreadable mesh must leave the rest of the
|
||||||
app alone and report the reason.
|
app alone and report the reason.
|
||||||
"""
|
"""
|
||||||
|
if self.calibration_panel.session_open:
|
||||||
|
raise ValueError(t("calibration.close_before_mapping"))
|
||||||
bundle = load_bundle(
|
bundle = load_bundle(
|
||||||
path,
|
path,
|
||||||
# The design profile declares these for this revision; a mismatch
|
# The design profile declares these for this revision; a mismatch
|
||||||
@@ -401,7 +531,11 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
texture_path, texture = self._resolve_texture(bundle)
|
texture_path, texture = self._resolve_texture(bundle)
|
||||||
flat = rasterise(bundle, flat_size, texture=texture)
|
flat = rasterise(bundle, flat_size, texture=texture)
|
||||||
self.finger_panel.set_bundle(bundle, flat)
|
self.finger_panel.set_bundle(bundle, flat)
|
||||||
|
self.calibration_panel.set_flat(flat, str(path))
|
||||||
self.finger_panel.set_texture_state(textured=flat.textured, path=str(texture_path or ""))
|
self.finger_panel.set_texture_state(textured=flat.textured, path=str(texture_path or ""))
|
||||||
|
# A new surface changes which candidates are usable and where they sit.
|
||||||
|
self._refresh_array_preview()
|
||||||
|
self._refresh_sequence_overlay()
|
||||||
self.prefs["finger_bundle_path"] = str(path)
|
self.prefs["finger_bundle_path"] = str(path)
|
||||||
info = bundle.describe()
|
info = bundle.describe()
|
||||||
self._report_sequence(
|
self._report_sequence(
|
||||||
@@ -474,16 +608,124 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
# A UV gap or no bundle: the panel says which; do not imply success.
|
# A UV gap or no bundle: the panel says which; do not imply success.
|
||||||
self.statusBar().showMessage(self.finger_panel.status_label.text())
|
self.statusBar().showMessage(self.finger_panel.status_label.text())
|
||||||
return
|
return
|
||||||
self._report_sequence(
|
# Success is shown by the picker's own status line (see handle_pick); the
|
||||||
t(
|
# strip at the top is for the platform and for actions, not for a pick.
|
||||||
"finger.picked",
|
|
||||||
residual=f"{row['residual']:.3f}",
|
# ----------------------------------------------------------- array and map
|
||||||
yaw=f"{row['yaw']:.2f}",
|
|
||||||
pitch=f"{row['pitch']:.2f}",
|
def _map_origin(self) -> tuple[int, int] | None:
|
||||||
),
|
flat = self.finger_panel.view.flat
|
||||||
ok=row["residual"] <= 0.5,
|
if flat is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return uv_origin(flat)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _refresh_sequence_overlay(self) -> None:
|
||||||
|
"""Draw the sequence's points, in order, on the 2D map."""
|
||||||
|
view = self.finger_panel.view
|
||||||
|
origin = self._map_origin()
|
||||||
|
if origin is None:
|
||||||
|
view.set_sequence_points([])
|
||||||
|
return
|
||||||
|
view.set_sequence_points(
|
||||||
|
[
|
||||||
|
(origin[0] + int(round(u)), origin[1] - int(round(v)), str(number), selected)
|
||||||
|
for number, u, v, selected in self.sequence_panel.map_points()
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _refresh_array_preview(self) -> None:
|
||||||
|
tool = self.sequence_panel.array_tool
|
||||||
|
try:
|
||||||
|
points = tool.candidates()
|
||||||
|
except ValueError:
|
||||||
|
points = []
|
||||||
|
self._on_array_preview(points, tool.margin_px)
|
||||||
|
|
||||||
|
def _on_array_preview(self, points: object, margin: int) -> None:
|
||||||
|
"""Check the tool's candidates against the surface and draw the verdict."""
|
||||||
|
from ..array_plan import check_candidates
|
||||||
|
|
||||||
|
tool = self.sequence_panel.array_tool
|
||||||
|
view = self.finger_panel.view
|
||||||
|
try:
|
||||||
|
checked = check_candidates(view.flat, list(points), margin) # type: ignore[call-overload]
|
||||||
|
except ValueError:
|
||||||
|
checked = []
|
||||||
|
tool.set_checked(checked)
|
||||||
|
origin = self._map_origin()
|
||||||
|
if origin is None or not tool.previewing():
|
||||||
|
view.set_candidates([])
|
||||||
|
return
|
||||||
|
view.set_candidates(
|
||||||
|
[(origin[0] + c.u, origin[1] - c.v, c.valid) for c in checked]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_array_add(self, candidates: object) -> None:
|
||||||
|
"""Add the usable candidates, each solved on the surface, skipping repeats."""
|
||||||
|
origin = self._map_origin()
|
||||||
|
if origin is None:
|
||||||
|
return
|
||||||
|
added = repeated = failed = 0
|
||||||
|
for candidate in list(candidates): # type: ignore[call-overload]
|
||||||
|
uv = (float(candidate.u), float(candidate.v))
|
||||||
|
if any(
|
||||||
|
existing is not None
|
||||||
|
and abs(existing[0] - uv[0]) < 0.5
|
||||||
|
and abs(existing[1] - uv[1]) < 0.5
|
||||||
|
for existing in (
|
||||||
|
self.sequence_panel.uv_of_row(i)
|
||||||
|
for i in range(self.sequence_panel.point_row_count())
|
||||||
|
)
|
||||||
|
):
|
||||||
|
repeated += 1
|
||||||
|
continue
|
||||||
|
row = self.finger_panel.solve_pixel_row(origin[0] + candidate.u, origin[1] - candidate.v)
|
||||||
|
if row is None:
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
self.sequence_panel.append_point(
|
||||||
|
row["X"], row["Y"], row["Z"], uv=(row["u_px"], row["v_px"])
|
||||||
|
)
|
||||||
|
added += 1
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
t("array.added", added=added, repeated=repeated, failed=failed)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_array_centre_from_pick(self) -> None:
|
||||||
|
picked = self.finger_panel.points()
|
||||||
|
if not picked:
|
||||||
|
self.statusBar().showMessage(t("sequence.add_picked_none"))
|
||||||
|
return
|
||||||
|
self.sequence_panel.array_tool.set_centre(picked[0]["u_px"], picked[0]["v_px"])
|
||||||
|
|
||||||
|
def _on_view_style_changed(self, background: str, colour: str) -> None:
|
||||||
|
self.prefs["finger_view_background"] = background
|
||||||
|
self.prefs["finger_marker_colour"] = colour
|
||||||
|
|
||||||
|
def _on_add_picked_to_sequence(self) -> None:
|
||||||
|
"""Append the point picked on the 2D map to the sequence, with its U/V."""
|
||||||
|
picked = self.finger_panel.points()
|
||||||
|
if not picked:
|
||||||
|
self.statusBar().showMessage(t("sequence.add_picked_none"))
|
||||||
|
return
|
||||||
|
row = picked[0]
|
||||||
|
uv = (float(row["u_px"]), float(row["v_px"]))
|
||||||
|
for index in range(self.sequence_panel.point_row_count()):
|
||||||
|
existing = self.sequence_panel.uv_of_row(index)
|
||||||
|
if existing and abs(existing[0] - uv[0]) < 0.5 and abs(existing[1] - uv[1]) < 0.5:
|
||||||
|
self.statusBar().showMessage(t("sequence.add_picked_duplicate", row=index + 1))
|
||||||
|
return
|
||||||
|
self.sequence_panel.append_point(row["X"], row["Y"], row["Z"], uv=uv)
|
||||||
|
|
||||||
|
def _on_send_sequence_to_capture(self, uvs: object) -> None:
|
||||||
|
"""Queue the selected sequence points on the capture page, and show it."""
|
||||||
|
added, _refused = self.calibration_panel.add_uv_points(list(uvs)) # type: ignore[arg-type]
|
||||||
|
if added:
|
||||||
|
self.workspace.set_current_key("sensor")
|
||||||
|
|
||||||
def _on_finger_stack_changed(self) -> None:
|
def _on_finger_stack_changed(self) -> None:
|
||||||
"""The mounting pose, the tool position or the limits moved.
|
"""The mounting pose, the tool position or the limits moved.
|
||||||
|
|
||||||
@@ -497,6 +739,10 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self.prefs["finger_sensor_offset_mm"] = list(self.finger_panel.sensor_offset_mm)
|
self.prefs["finger_sensor_offset_mm"] = list(self.finger_panel.sensor_offset_mm)
|
||||||
self.prefs["finger_sensor_yaw_deg"] = self.finger_panel.sensor_yaw_deg
|
self.prefs["finger_sensor_yaw_deg"] = self.finger_panel.sensor_yaw_deg
|
||||||
self.prefs["finger_tool_xy_mm"] = list(self.finger_panel.tool_xy_mm)
|
self.prefs["finger_tool_xy_mm"] = list(self.finger_panel.tool_xy_mm)
|
||||||
|
# The queue's poses were solved with the machinery that just changed, so the
|
||||||
|
# capture page re-solves them: writes back while idle, display-only when the
|
||||||
|
# queue is frozen between trials.
|
||||||
|
self.calibration_panel.resolve_queue()
|
||||||
|
|
||||||
# ---------------------------------------------------------------- run bar
|
# ---------------------------------------------------------------- run bar
|
||||||
|
|
||||||
@@ -776,8 +1022,11 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
# hold the worker thread and a stop would have to wait for the move it is
|
# hold the worker thread and a stop would have to wait for the move it is
|
||||||
# meant to interrupt.
|
# meant to interrupt.
|
||||||
self.mover = PoseMover(self.poller.service, self.poller.submit, self)
|
self.mover = PoseMover(self.poller.service, self.poller.submit, self)
|
||||||
|
self.poller.commandFinished.connect(self.mover.on_command_finished)
|
||||||
|
self.poller.commandFailed.connect(self.mover.on_command_failed)
|
||||||
self.mover.failed.connect(self._on_move_failed)
|
self.mover.failed.connect(self._on_move_failed)
|
||||||
self.mover.finished.connect(self._on_move_finished)
|
self.mover.finished.connect(self._on_move_finished)
|
||||||
|
self.mover.cancelled.connect(self._on_move_cancelled)
|
||||||
|
|
||||||
# Ages the poll-health chip even when no snapshot arrives. Runs on the
|
# Ages the poll-health chip even when no snapshot arrives. Runs on the
|
||||||
# GUI thread and only reads a timestamp — the controller is untouched —
|
# GUI thread and only reads a timestamp — the controller is untouched —
|
||||||
@@ -815,6 +1064,36 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
"""Signal handler for the poller's periodic state read."""
|
"""Signal handler for the poller's periodic state read."""
|
||||||
self.update_snapshot(snapshot)
|
self.update_snapshot(snapshot)
|
||||||
|
|
||||||
|
def _report_point_match(self, snapshot: Snapshot) -> None:
|
||||||
|
"""Selected pose versus the live axes. Offsets are actual minus selected."""
|
||||||
|
if not snapshot.connected:
|
||||||
|
self.finger_panel.set_actual_pose(None)
|
||||||
|
return
|
||||||
|
roles = self.prefs.get("axis_roles") or {}
|
||||||
|
|
||||||
|
def reading(role: str, rotary: bool) -> float | None:
|
||||||
|
axis = roles.get(role)
|
||||||
|
if axis is None:
|
||||||
|
return None
|
||||||
|
state = snapshot.axis(int(axis))
|
||||||
|
if state is None or not state.read_ok:
|
||||||
|
return None
|
||||||
|
if rotary:
|
||||||
|
return user_units_to_deg(int(axis), state.dpos, units=state.units)
|
||||||
|
units = state.units or float(self.prefs.get("units_per_mm") or DEFAULT_UNITS)
|
||||||
|
return user_units_to_mm(state.dpos, units=units)
|
||||||
|
|
||||||
|
values = (
|
||||||
|
reading("x", False),
|
||||||
|
reading("y", False),
|
||||||
|
reading("yaw", True),
|
||||||
|
reading("pitch", True),
|
||||||
|
)
|
||||||
|
if any(value is None or not math.isfinite(value) for value in values):
|
||||||
|
self.finger_panel.set_actual_pose(None)
|
||||||
|
return
|
||||||
|
self.finger_panel.set_actual_pose(tuple(float(value) for value in values)) # type: ignore[arg-type]
|
||||||
|
|
||||||
def update_snapshot(self, snapshot: Snapshot) -> None:
|
def update_snapshot(self, snapshot: Snapshot) -> None:
|
||||||
self._last_snapshot = snapshot
|
self._last_snapshot = snapshot
|
||||||
self.connection_panel.set_connected(
|
self.connection_panel.set_connected(
|
||||||
@@ -828,6 +1107,7 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self.bus_data_panel.set_connected(snapshot.connected)
|
self.bus_data_panel.set_connected(snapshot.connected)
|
||||||
self.bus_data_panel.set_bus_type(snapshot.bus.bus_type)
|
self.bus_data_panel.set_bus_type(snapshot.bus.bus_type)
|
||||||
self.axis_panel.update_snapshot(snapshot)
|
self.axis_panel.update_snapshot(snapshot)
|
||||||
|
self._refresh_unit_report()
|
||||||
self.axis_readout.update_snapshot(snapshot)
|
self.axis_readout.update_snapshot(snapshot)
|
||||||
# Progress the pose move. It waits on the axis readings in this same
|
# Progress the pose move. It waits on the axis readings in this same
|
||||||
# snapshot, so it costs the poll nothing extra and cannot run ahead of the
|
# snapshot, so it costs the poll nothing extra and cannot run ahead of the
|
||||||
@@ -840,6 +1120,7 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self.finger_panel.set_move_available(
|
self.finger_panel.set_move_available(
|
||||||
self._move_available(), moving=self.mover.running
|
self._move_available(), moving=self.mover.running
|
||||||
)
|
)
|
||||||
|
self._report_point_match(snapshot)
|
||||||
# The strip is outside the tabs, so it is fed on every poll regardless
|
# The strip is outside the tabs, so it is fed on every poll regardless
|
||||||
# of which tab is showing.
|
# of which tab is showing.
|
||||||
self.status_strip.set_snapshot(snapshot)
|
self.status_strip.set_snapshot(snapshot)
|
||||||
@@ -1013,10 +1294,13 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self._pending[token] = ""
|
self._pending[token] = ""
|
||||||
|
|
||||||
def _on_jog(self, axis: int, forward: bool) -> None:
|
def _on_jog(self, axis: int, forward: bool) -> None:
|
||||||
self.poller.submit(lambda service: service.jog(axis, forward))
|
self._capture_platform_moved()
|
||||||
|
token = self.poller.submit(lambda service: service.jog(axis, forward))
|
||||||
|
self._pending[token] = ""
|
||||||
|
|
||||||
def _on_stop(self, axis: int) -> None:
|
def _on_stop(self, axis: int) -> None:
|
||||||
self.poller.submit(lambda service: service.stop(axis))
|
token = self.poller.submit(lambda service: service.stop(axis))
|
||||||
|
self._pending[token] = ""
|
||||||
|
|
||||||
def _on_stop_all(self) -> None:
|
def _on_stop_all(self) -> None:
|
||||||
def _stop_all(service: MotionService) -> object:
|
def _stop_all(service: MotionService) -> object:
|
||||||
@@ -1043,10 +1327,12 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
def _on_execute_pose(self) -> None:
|
def _on_execute_pose(self) -> None:
|
||||||
"""Move the platform to the solved pose of the finger screen's selection.
|
"""Move the platform to the solved pose of the finger screen's selection.
|
||||||
|
|
||||||
Stages and table only: the press axis is deliberately not commanded, so this
|
Yaw, pitch and table X/Y start together. The press axis joins them only
|
||||||
is workflow P6's dry run for one point — it cannot reach the sensor. The
|
when the bench has measured how its coordinate relates to a contact
|
||||||
numbers shown in the confirmation are the ones the preview drew, so the
|
height; otherwise the panel hands back that role as held, so the move
|
||||||
operator checks the simulation against the machine, not a summary of it.
|
carries the roles it can justify and says which one it left alone. The
|
||||||
|
confirmation shows those drive targets. Table X carries the motor sign;
|
||||||
|
the preview keeps the model slide, which is what puts the contact on the tool.
|
||||||
"""
|
"""
|
||||||
solve = self.finger_panel.solved_pose()
|
solve = self.finger_panel.solved_pose()
|
||||||
if solve is None:
|
if solve is None:
|
||||||
@@ -1058,21 +1344,80 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
targets,
|
targets,
|
||||||
title=t("move.execute"),
|
title=t("move.execute"),
|
||||||
hold=held,
|
hold=held,
|
||||||
|
together=True,
|
||||||
)
|
)
|
||||||
self._start_move(plan, t("move.execute"))
|
self._start_move(plan, t("move.execute"))
|
||||||
|
|
||||||
|
def _on_align_multipoint(self, targets: dict) -> None:
|
||||||
|
"""Same five-axis move as the finger screen, for the current capture point.
|
||||||
|
|
||||||
|
The calibration page hands over geometry, not controller coordinates, so
|
||||||
|
the press-axis relation is applied here as well — one place decides which
|
||||||
|
roles a solved point may command, whichever screen asked for the move.
|
||||||
|
|
||||||
|
The page is told the outcome: only a move that finished may mark the point
|
||||||
|
aligned, so a refused, cancelled or failed move leaves "start capture"
|
||||||
|
unclickable. That report is why ``_capture_alignment`` exists: a move the
|
||||||
|
finger screen asked for must never mark a capture point aligned.
|
||||||
|
"""
|
||||||
|
targets, held = press_targets(targets, self._press_axis)
|
||||||
|
plan = plan_pose_move(
|
||||||
|
self.poller.service,
|
||||||
|
targets,
|
||||||
|
title=t("multipoint.align"),
|
||||||
|
hold=held,
|
||||||
|
together=True,
|
||||||
|
)
|
||||||
|
self._capture_alignment = True
|
||||||
|
self._start_move(
|
||||||
|
plan,
|
||||||
|
t("multipoint.align"),
|
||||||
|
report=self.calibration_panel.set_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _capture_platform_moved(self) -> None:
|
||||||
|
"""A move the capture page did not ask for: its "aligned" no longer holds."""
|
||||||
|
self.calibration_panel.alignment_invalidated()
|
||||||
|
|
||||||
|
def _capture_move_outcome(self, ok: bool, reason: str = "") -> None:
|
||||||
|
"""Tell the capture page how the move it asked for ended, once."""
|
||||||
|
if not self._capture_alignment:
|
||||||
|
return
|
||||||
|
self._capture_alignment = False
|
||||||
|
if ok:
|
||||||
|
self.calibration_panel.alignment_succeeded()
|
||||||
|
else:
|
||||||
|
self.calibration_panel.alignment_failed(reason)
|
||||||
|
|
||||||
def _on_return_to_initial(self) -> None:
|
def _on_return_to_initial(self) -> None:
|
||||||
"""Every drive back to zero, press axis first."""
|
"""Yaw, pitch and Z back to zero. Table X/Y park at the stored initial."""
|
||||||
plan = plan_return_to_initial(self.poller.service)
|
|
||||||
|
def initial(key: str) -> float:
|
||||||
|
try:
|
||||||
|
return float(self.prefs.get(key) or 0.0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
plan = plan_return_to_initial(
|
||||||
|
self.poller.service,
|
||||||
|
x_mm=initial("table_initial_x_mm"),
|
||||||
|
y_mm=initial("table_initial_y_mm"),
|
||||||
|
)
|
||||||
self._start_move(plan, t("move.return_initial"))
|
self._start_move(plan, t("move.return_initial"))
|
||||||
|
|
||||||
def _start_move(self, plan, title: str) -> None:
|
def _start_move(self, plan, title: str, *, report=None) -> None:
|
||||||
|
def say(text: str) -> None:
|
||||||
|
if report is not None:
|
||||||
|
report(text)
|
||||||
|
|
||||||
service = self.poller.service
|
service = self.poller.service
|
||||||
refusal = service.check_axes_move(
|
refusal = service.check_axes_move(
|
||||||
{step.role: step.target for step in plan.steps}
|
{step.role: step.target for step in plan.steps}
|
||||||
)
|
)
|
||||||
if refusal is not None:
|
if refusal is not None:
|
||||||
|
say(refusal.message)
|
||||||
self._refuse_move(refusal.message)
|
self._refuse_move(refusal.message)
|
||||||
|
self._capture_move_outcome(False, refusal.message)
|
||||||
return
|
return
|
||||||
# Confirm before the platform moves, with the numbers rather than a summary of
|
# Confirm before the platform moves, with the numbers rather than a summary of
|
||||||
# them: this is the operator's chance to notice that the simulation and the
|
# them: this is the operator's chance to notice that the simulation and the
|
||||||
@@ -1080,8 +1425,15 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
# cannot be turned off is one that gets clicked through unread.
|
# cannot be turned off is one that gets clicked through unread.
|
||||||
if self._confirm_moves:
|
if self._confirm_moves:
|
||||||
steps = "\n".join(
|
steps = "\n".join(
|
||||||
|
(
|
||||||
|
f" {step.role} → {step.target:+.4f} {step.unit}"
|
||||||
|
f" (驱动器 {step.axis})"
|
||||||
|
if plan.together
|
||||||
|
else (
|
||||||
f" {index + 1}. {step.role} → {step.target:+.4f} {step.unit}"
|
f" {index + 1}. {step.role} → {step.target:+.4f} {step.unit}"
|
||||||
f" (驱动器 {step.axis})"
|
f" (驱动器 {step.axis})"
|
||||||
|
)
|
||||||
|
)
|
||||||
for index, step in enumerate(plan.steps)
|
for index, step in enumerate(plan.steps)
|
||||||
)
|
)
|
||||||
if plan.held:
|
if plan.held:
|
||||||
@@ -1099,11 +1451,18 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
QMessageBox.StandardButton.No,
|
QMessageBox.StandardButton.No,
|
||||||
)
|
)
|
||||||
if answer != QMessageBox.StandardButton.Yes:
|
if answer != QMessageBox.StandardButton.Yes:
|
||||||
|
say(t("move.cancelled"))
|
||||||
self.finger_panel.set_move_status(t("move.cancelled"))
|
self.finger_panel.set_move_status(t("move.cancelled"))
|
||||||
|
self._capture_move_outcome(False, t("move.cancelled"))
|
||||||
return
|
return
|
||||||
if not self.mover.start(plan).ok:
|
if not self.mover.start(plan).ok:
|
||||||
self._refuse_move(self.mover.state.failure or t("move.busy"))
|
reason = self.mover.state.failure or t("move.busy")
|
||||||
|
say(reason)
|
||||||
|
self._refuse_move(reason)
|
||||||
|
self._capture_move_outcome(False, reason)
|
||||||
return
|
return
|
||||||
|
if not self._capture_alignment:
|
||||||
|
self._capture_platform_moved()
|
||||||
self.finger_panel.set_move_status(plan.describe())
|
self.finger_panel.set_move_status(plan.describe())
|
||||||
self.status_strip.set_action_result(f"{title}: {plan.describe()}", True)
|
self.status_strip.set_action_result(f"{title}: {plan.describe()}", True)
|
||||||
|
|
||||||
@@ -1120,6 +1479,7 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
if plan is not None:
|
if plan is not None:
|
||||||
self.finger_panel.set_move_status(f"✓ {plan.title or t('move.done')}")
|
self.finger_panel.set_move_status(f"✓ {plan.title or t('move.done')}")
|
||||||
self.status_strip.set_action_result(t("move.done"), True)
|
self.status_strip.set_action_result(t("move.done"), True)
|
||||||
|
self._capture_move_outcome(True)
|
||||||
|
|
||||||
def _on_move_failed(self, message: str) -> None:
|
def _on_move_failed(self, message: str) -> None:
|
||||||
# A mover that gave up leaves an axis wherever it stopped: stop the rest
|
# A mover that gave up leaves an axis wherever it stopped: stop the rest
|
||||||
@@ -1127,15 +1487,25 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self.finger_panel.set_move_status(f"✗ {message}")
|
self.finger_panel.set_move_status(f"✗ {message}")
|
||||||
self.status_strip.set_action_result(f"{t('move.failed')}: {message}", False)
|
self.status_strip.set_action_result(f"{t('move.failed')}: {message}", False)
|
||||||
self.statusBar().showMessage(f"{t('move.failed')}: {message}")
|
self.statusBar().showMessage(f"{t('move.failed')}: {message}")
|
||||||
|
self._capture_move_outcome(False, message)
|
||||||
self.poller.submit(lambda service: service.stop_all())
|
self.poller.submit(lambda service: service.stop_all())
|
||||||
|
|
||||||
|
def _on_move_cancelled(self) -> None:
|
||||||
|
"""The operator stopped a move: the axes stay where they stopped."""
|
||||||
|
self.finger_panel.set_move_status(t("move.cancelled"))
|
||||||
|
self.status_strip.set_action_result(t("move.cancelled"), False)
|
||||||
|
self._capture_move_outcome(False, t("move.cancelled"))
|
||||||
|
|
||||||
def _on_move_abs(self, axis: int, target: float) -> None:
|
def _on_move_abs(self, axis: int, target: float) -> None:
|
||||||
|
self._capture_platform_moved()
|
||||||
self.poller.submit(lambda service: service.move_abs(axis, target))
|
self.poller.submit(lambda service: service.move_abs(axis, target))
|
||||||
|
|
||||||
def _on_move_rel(self, axis: int, distance: float) -> None:
|
def _on_move_rel(self, axis: int, distance: float) -> None:
|
||||||
|
self._capture_platform_moved()
|
||||||
self.poller.submit(lambda service: service.move_rel(axis, distance))
|
self.poller.submit(lambda service: service.move_rel(axis, distance))
|
||||||
|
|
||||||
def _on_home(self, axis: int) -> None:
|
def _on_home(self, axis: int) -> None:
|
||||||
|
self._capture_platform_moved()
|
||||||
self.poller.submit(lambda service: service.home(axis))
|
self.poller.submit(lambda service: service.home(axis))
|
||||||
|
|
||||||
def _on_clear_alarm(self, axis: int) -> None:
|
def _on_clear_alarm(self, axis: int) -> None:
|
||||||
@@ -1180,7 +1550,9 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
def _apply_theme(self) -> None:
|
def _apply_theme(self) -> None:
|
||||||
app = QApplication.instance()
|
app = QApplication.instance()
|
||||||
if app is not None:
|
if app is not None:
|
||||||
app.setStyleSheet(build_qss(self._theme))
|
stylesheet = build_qss(self._theme)
|
||||||
|
if app.styleSheet() != stylesheet:
|
||||||
|
app.setStyleSheet(stylesheet)
|
||||||
self.brand_bar.set_theme(self._theme)
|
self.brand_bar.set_theme(self._theme)
|
||||||
self.status_strip.set_theme(self._theme)
|
self.status_strip.set_theme(self._theme)
|
||||||
self.run_bar.set_theme(self._theme)
|
self.run_bar.set_theme(self._theme)
|
||||||
@@ -1213,20 +1585,10 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
def _on_reveal_config(self) -> None:
|
def _on_reveal_config(self) -> None:
|
||||||
from orisys_app_shared.paths import config_dir
|
from orisys_app_shared.paths import config_dir
|
||||||
|
|
||||||
self._reveal(config_dir())
|
reveal(config_dir())
|
||||||
|
|
||||||
def _on_reveal_outputs(self) -> None:
|
def _on_reveal_outputs(self) -> None:
|
||||||
self._reveal(outputs_dir())
|
reveal(outputs_dir())
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _reveal(path: Path) -> None:
|
|
||||||
try:
|
|
||||||
if sys.platform == "win32":
|
|
||||||
subprocess.Popen(["explorer", str(path)])
|
|
||||||
else: # pragma: no cover - the platform is Windows
|
|
||||||
subprocess.Popen(["xdg-open", str(path)])
|
|
||||||
except OSError:
|
|
||||||
logger.exception("Could not open %s", path)
|
|
||||||
|
|
||||||
def _on_about(self) -> None:
|
def _on_about(self) -> None:
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
@@ -1264,6 +1626,8 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self.axis_readout.retranslate()
|
self.axis_readout.retranslate()
|
||||||
self.sequence_panel.retranslate()
|
self.sequence_panel.retranslate()
|
||||||
self.finger_panel.retranslate()
|
self.finger_panel.retranslate()
|
||||||
|
self.finger_source_panel.retranslate()
|
||||||
|
self.calibration_panel.retranslate()
|
||||||
self.workspace.retranslate()
|
self.workspace.retranslate()
|
||||||
|
|
||||||
self.menu_file.setTitle(t("menu.file"))
|
self.menu_file.setTitle(t("menu.file"))
|
||||||
@@ -1344,6 +1708,14 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
# The sequence profile is the P5 design artefact: whatever is on screen is
|
# The sequence profile is the P5 design artefact: whatever is on screen is
|
||||||
# what a later run would use, so it is persisted with everything else.
|
# what a later run would use, so it is persisted with everything else.
|
||||||
self.prefs["sequence_profile"] = self.sequence_panel.profile()
|
self.prefs["sequence_profile"] = self.sequence_panel.profile()
|
||||||
|
self.prefs["finger_video_path"] = (
|
||||||
|
self.finger_source_panel.video_path.text().strip()
|
||||||
|
)
|
||||||
|
self.prefs["finger_config_ref"] = (
|
||||||
|
self.finger_source_panel.config_ref.text().strip()
|
||||||
|
)
|
||||||
|
self.prefs["finger_source_kind"] = self.finger_source_panel.source_kind.currentData()
|
||||||
|
self.prefs["finger_camera_index"] = self.finger_source_panel.camera_index.value()
|
||||||
try:
|
try:
|
||||||
save_preferences(self.prefs)
|
save_preferences(self.prefs)
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -1351,6 +1723,8 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
|
|
||||||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt override
|
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt override
|
||||||
"""Stop the platform, close the controller, then persist and leave."""
|
"""Stop the platform, close the controller, then persist and leave."""
|
||||||
|
self.calibration_panel.shutdown()
|
||||||
|
self.finger_source_panel.shutdown()
|
||||||
logger.info("Closing: stopping axes and shutting the poller down")
|
logger.info("Closing: stopping axes and shutting the poller down")
|
||||||
try:
|
try:
|
||||||
self.poller.submit(lambda service: service.stop_all())
|
self.poller.submit(lambda service: service.stop_all())
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ One tab per task the operator does on the platform, with the motion status strip
|
|||||||
living *outside* this widget so a tab change cannot hide the state that decides
|
living *outside* this widget so a tab change cannot hide the state that decides
|
||||||
whether it is safe to act.
|
whether it is safe to act.
|
||||||
|
|
||||||
Pages that are not built yet are present as labelled placeholders rather than
|
Calibration records share one page for coverage, sensitivity and window review.
|
||||||
missing: the layout is being reviewed for operator use, and a tab that appears
|
|
||||||
only later would change the layout under review. Each placeholder names what
|
|
||||||
will fill it, so nobody mistakes an empty page for a broken one.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -23,25 +20,23 @@ from PySide6.QtWidgets import (
|
|||||||
|
|
||||||
from .axis_panel import AxisPanel
|
from .axis_panel import AxisPanel
|
||||||
from .sequence_panel import SequencePanel
|
from .sequence_panel import SequencePanel
|
||||||
|
from .record_overview_dialog import RecordOverviewPage
|
||||||
|
|
||||||
#: Tab identity -> locale key for its label. Order is the display order, and it
|
#: Tab identity -> locale key for its label. Order is the display order, and it
|
||||||
#: follows the work: commission the axes, describe the sequence, watch the
|
#: follows the work: commission the axes, set up the geometry and mounting, design
|
||||||
#: sensor, look at the surface, then record calibration points.
|
#: the sequence against it, watch the sensor, capture data, then review records.
|
||||||
TABS: tuple[tuple[str, str], ...] = (
|
TABS: tuple[tuple[str, str], ...] = (
|
||||||
("axes", "workspace.tab_axes"),
|
("axes", "workspace.tab_axes"),
|
||||||
("sequence", "workspace.tab_sequence"),
|
|
||||||
("finger", "workspace.tab_finger"),
|
("finger", "workspace.tab_finger"),
|
||||||
|
("sequence", "workspace.tab_sequence"),
|
||||||
("sensor", "workspace.tab_sensor"),
|
("sensor", "workspace.tab_sensor"),
|
||||||
("surface", "workspace.tab_surface"),
|
("records", "workspace.tab_records"),
|
||||||
("points", "workspace.tab_points"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
#: Tabs whose content does not exist yet, with the locale key that explains what
|
#: Tabs whose content does not exist yet, with the locale key that explains what
|
||||||
#: will appear there and when.
|
#: will appear there and when.
|
||||||
_PLACEHOLDERS: dict[str, str] = {
|
_PLACEHOLDERS: dict[str, str] = {
|
||||||
"sensor": "workspace.reserved_sensor",
|
"sensor": "workspace.reserved_sensor",
|
||||||
"surface": "workspace.reserved_surface",
|
|
||||||
"points": "workspace.reserved_points",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -80,6 +75,7 @@ class WorkspaceTabs(QTabWidget):
|
|||||||
sequence_panel: SequencePanel,
|
sequence_panel: SequencePanel,
|
||||||
finger_panel: QWidget | None = None,
|
finger_panel: QWidget | None = None,
|
||||||
parent: QWidget | None = None,
|
parent: QWidget | None = None,
|
||||||
|
sensor_panel: QWidget | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setObjectName("workspaceTabs")
|
self.setObjectName("workspaceTabs")
|
||||||
@@ -90,7 +86,12 @@ class WorkspaceTabs(QTabWidget):
|
|||||||
self._pages["sequence"] = sequence_panel
|
self._pages["sequence"] = sequence_panel
|
||||||
if finger_panel is not None:
|
if finger_panel is not None:
|
||||||
self._pages["finger"] = finger_panel
|
self._pages["finger"] = finger_panel
|
||||||
|
if sensor_panel is not None:
|
||||||
|
self._pages["sensor"] = sensor_panel
|
||||||
|
self._pages["records"] = RecordOverviewPage(self)
|
||||||
for key, locale_key in _PLACEHOLDERS.items():
|
for key, locale_key in _PLACEHOLDERS.items():
|
||||||
|
if key in self._pages:
|
||||||
|
continue
|
||||||
page = PlaceholderPage(locale_key, self)
|
page = PlaceholderPage(locale_key, self)
|
||||||
self._pages[key] = page
|
self._pages[key] = page
|
||||||
self._placeholder_keys[key] = locale_key
|
self._placeholder_keys[key] = locale_key
|
||||||
@@ -129,6 +130,7 @@ class WorkspaceTabs(QTabWidget):
|
|||||||
page = self._pages[key]
|
page = self._pages[key]
|
||||||
if isinstance(page, PlaceholderPage):
|
if isinstance(page, PlaceholderPage):
|
||||||
page.retranslate()
|
page.retranslate()
|
||||||
|
self._pages["records"].retranslate()
|
||||||
|
|
||||||
def _on_current_changed(self, index: int) -> None:
|
def _on_current_changed(self, index: int) -> None:
|
||||||
keys = self.page_keys()
|
keys = self.page_keys()
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"""Find a recorded hold's picture in the video that already exists.
|
||||||
|
|
||||||
|
The operator picks a reviewed window and expects the frames of *that* hold. The
|
||||||
|
position is decided by frame identity — acquisition time plus the recorded
|
||||||
|
segment and position — never by converting the player's second counter, and
|
||||||
|
never by assuming the container runs at the acquisition rate.
|
||||||
|
|
||||||
|
Read-only: no calibration is recomputed, no frames are invented and a missing
|
||||||
|
segment stays a reason. The decoder is released by :meth:`WindowVideo.close`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from orisys_app_shared.record import RecordingReader
|
||||||
|
|
||||||
|
from .frames_index import (
|
||||||
|
FrameRow,
|
||||||
|
read_frame_index,
|
||||||
|
reference_holds,
|
||||||
|
resolve_capture_directory,
|
||||||
|
unique_frames,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Why a window has no picture. Tokens, localised by the dialog.
|
||||||
|
NO_TRIAL = "trial_not_found"
|
||||||
|
NO_WINDOW = "window_not_found"
|
||||||
|
NO_FRAME_SOURCE = "no_frame_source"
|
||||||
|
NO_VIDEO_FRAME = "no_video_frame"
|
||||||
|
HOLD_NOT_FOUND = "hold_not_found"
|
||||||
|
HOLD_NOT_COMPLETE = "hold_not_complete"
|
||||||
|
DECODE_FAILED = "video_undecodable"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Location:
|
||||||
|
"""One identified picture position, in recording coordinates."""
|
||||||
|
|
||||||
|
trial_id: str
|
||||||
|
step_index: int
|
||||||
|
hold_id: int | None
|
||||||
|
frame_id: int
|
||||||
|
t_rel_s: float
|
||||||
|
t_host_mono_ns: int
|
||||||
|
segment: int
|
||||||
|
segment_frame: int
|
||||||
|
capture_directory: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"trial_id": self.trial_id,
|
||||||
|
"step_index": self.step_index,
|
||||||
|
"hold_id": self.hold_id,
|
||||||
|
"frame_id": self.frame_id,
|
||||||
|
"t_rel_s": self.t_rel_s,
|
||||||
|
"t_host_mono_ns": self.t_host_mono_ns,
|
||||||
|
"segment": self.segment,
|
||||||
|
"segment_frame": self.segment_frame,
|
||||||
|
"capture_directory": self.capture_directory,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _window(review: dict, *, trial_id: str, step_index: int) -> dict | None:
|
||||||
|
for site in review.get("sites") or []:
|
||||||
|
for window in site.get("windows") or []:
|
||||||
|
if window["trial_id"] == trial_id and int(window["step_index"]) == int(step_index):
|
||||||
|
return window
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _trial(review: dict, trial_id: str) -> dict | None:
|
||||||
|
for trial in review.get("trials") or []:
|
||||||
|
if trial["trial_id"] == trial_id:
|
||||||
|
return trial
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def trial_is_closed(root: Path, trial: dict | None) -> bool:
|
||||||
|
"""Closed failed evidence is replayable; a live writer's container is not."""
|
||||||
|
if trial is None or trial.get("capture_status") in {"idle", "starting", "recording", "closing"}:
|
||||||
|
return False
|
||||||
|
if (trial.get("capture_report") or {}).get("completed") is True:
|
||||||
|
return True
|
||||||
|
if trial.get("status") == "complete":
|
||||||
|
return True
|
||||||
|
directory = resolve_capture_directory(root, trial_directory=str(trial.get("directory") or ""))
|
||||||
|
if directory is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
metadata = json.loads((directory / "session.json").read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return False
|
||||||
|
return bool(metadata.get("ended_at"))
|
||||||
|
|
||||||
|
|
||||||
|
def in_window_frames(
|
||||||
|
review: dict, *, trial_id: str, step_index: int
|
||||||
|
) -> list[FrameRow] | str:
|
||||||
|
"""The in-video frame rows inside one hold, in time order. A token on failure."""
|
||||||
|
root = Path(review["source_directory"])
|
||||||
|
trial = _trial(review, trial_id)
|
||||||
|
if trial is None:
|
||||||
|
return NO_TRIAL
|
||||||
|
window = _window(review, trial_id=trial_id, step_index=step_index)
|
||||||
|
if window is None:
|
||||||
|
return NO_WINDOW
|
||||||
|
directory = resolve_capture_directory(
|
||||||
|
root,
|
||||||
|
trial_directory=str(trial.get("directory") or ""),
|
||||||
|
frame_source=str(window.get("frame_source") or ""),
|
||||||
|
)
|
||||||
|
if directory is None:
|
||||||
|
return NO_FRAME_SOURCE
|
||||||
|
start = int(window.get("t_start_host_mono_ns", window["hold_start_host_mono_ns"]))
|
||||||
|
end = int(window.get("t_end_host_mono_ns", window["hold_end_host_mono_ns"]))
|
||||||
|
unique, _duplicated = unique_frames(read_frame_index(directory))
|
||||||
|
frames = [
|
||||||
|
frame for frame in unique.values()
|
||||||
|
if frame.in_video is True
|
||||||
|
and frame.segment > 0
|
||||||
|
and frame.segment_frame > 0
|
||||||
|
and frame.t_rel_s is not None
|
||||||
|
and frame.t_host_mono_ns is not None
|
||||||
|
and start <= frame.t_host_mono_ns < end
|
||||||
|
]
|
||||||
|
for frame in frames:
|
||||||
|
path = directory / f"raw_{frame.segment:04d}.mp4"
|
||||||
|
if not path.is_file() or path.stat().st_size == 0:
|
||||||
|
return NO_FRAME_SOURCE
|
||||||
|
return sorted(frames, key=lambda frame: (frame.t_host_mono_ns, frame.segment, frame.segment_frame))
|
||||||
|
|
||||||
|
|
||||||
|
def _hold(root: Path, trial: dict, hold_id: int) -> dict | None:
|
||||||
|
for row in reference_holds(root / str(trial.get("directory") or "")):
|
||||||
|
if row["hold_id"] == int(hold_id):
|
||||||
|
return row
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def locate_window(
|
||||||
|
review: dict, *, trial_id: str, step_index: int, hold_id: int | None = None
|
||||||
|
) -> Location | dict:
|
||||||
|
"""The first valid in-video frame of the hold, or a reason it is missing.
|
||||||
|
|
||||||
|
`hold_id` names one actual attempt from `reference.csv`; a discarded
|
||||||
|
attempt is refused instead of quietly showing the completed one.
|
||||||
|
"""
|
||||||
|
trial = _trial(review, trial_id)
|
||||||
|
if trial is None:
|
||||||
|
return {"reason": NO_TRIAL}
|
||||||
|
if hold_id is not None:
|
||||||
|
hold = _hold(Path(review["source_directory"]), trial, hold_id)
|
||||||
|
if hold is None:
|
||||||
|
return {"reason": HOLD_NOT_FOUND}
|
||||||
|
if not hold["complete"]:
|
||||||
|
return {"reason": HOLD_NOT_COMPLETE}
|
||||||
|
if hold["step_index"] != int(step_index):
|
||||||
|
return {"reason": NO_WINDOW}
|
||||||
|
window = _window(review, trial_id=trial_id, step_index=step_index)
|
||||||
|
if window is None:
|
||||||
|
return {"reason": NO_WINDOW}
|
||||||
|
frames = in_window_frames(review, trial_id=trial_id, step_index=step_index)
|
||||||
|
if isinstance(frames, str):
|
||||||
|
return {"reason": frames}
|
||||||
|
if not frames:
|
||||||
|
return {"reason": NO_VIDEO_FRAME}
|
||||||
|
# Only a frame that also produced a valid sample is evidence of the hold.
|
||||||
|
valid_ids = {
|
||||||
|
int(frame_id) for _stamp, frame_id, _value in window.get("trace") or []
|
||||||
|
}
|
||||||
|
root = Path(review["source_directory"])
|
||||||
|
trial = _trial(review, trial_id)
|
||||||
|
for frame in frames:
|
||||||
|
if valid_ids and frame.frame_id not in valid_ids:
|
||||||
|
continue
|
||||||
|
directory = resolve_capture_directory(
|
||||||
|
root,
|
||||||
|
trial_directory=str(trial.get("directory") or ""),
|
||||||
|
frame_source=str(window.get("frame_source") or ""),
|
||||||
|
)
|
||||||
|
return Location(
|
||||||
|
trial_id=trial_id,
|
||||||
|
step_index=int(step_index),
|
||||||
|
hold_id=None if hold_id is None else int(hold_id),
|
||||||
|
frame_id=frame.frame_id,
|
||||||
|
t_rel_s=float(frame.t_rel_s),
|
||||||
|
t_host_mono_ns=int(frame.t_host_mono_ns),
|
||||||
|
segment=frame.segment,
|
||||||
|
segment_frame=frame.segment_frame,
|
||||||
|
capture_directory=str(directory.relative_to(root)) if directory else "",
|
||||||
|
)
|
||||||
|
return {"reason": NO_VIDEO_FRAME}
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_direct(directory: Path, *, segment: int, segment_frame: int):
|
||||||
|
"""Fallback decode by segment position, for an index that lies about time."""
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
path = directory / f"raw_{segment:04d}.mp4"
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
capture = cv2.VideoCapture(str(path))
|
||||||
|
try:
|
||||||
|
if not capture.isOpened():
|
||||||
|
return None
|
||||||
|
capture.set(cv2.CAP_PROP_POS_FRAMES, max(0, int(segment_frame) - 1))
|
||||||
|
ok, image = capture.read()
|
||||||
|
return image if ok else None
|
||||||
|
finally:
|
||||||
|
capture.release()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WindowVideo:
|
||||||
|
"""Decode the frames of one hold, stepping while the operator plays or pauses."""
|
||||||
|
|
||||||
|
location: Location
|
||||||
|
reader: RecordingReader
|
||||||
|
frames: list[FrameRow]
|
||||||
|
#: Index of the frame currently decoded, or -1 when the decoder was reset.
|
||||||
|
position: int = -1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def frame_count(self) -> int:
|
||||||
|
return len(self.frames)
|
||||||
|
|
||||||
|
def frame(self, index: int) -> np.ndarray | None:
|
||||||
|
"""Image for ``frames[index]``, or ``None`` when that frame has no picture."""
|
||||||
|
index = max(0, min(int(index), self.frame_count - 1))
|
||||||
|
row = self.frames[index]
|
||||||
|
try:
|
||||||
|
image, served = self.reader.video_frame_at(row.t_rel_s)
|
||||||
|
except Exception: # noqa: BLE001 - a broken decode must not crash playback
|
||||||
|
image, served = None, None
|
||||||
|
if image is None or served is None or served.frame_id != row.frame_id:
|
||||||
|
# The time lookup is "at or before": a repeated timestamp can answer
|
||||||
|
# with a neighbour. Seek the recorded position instead of showing it.
|
||||||
|
image = _decode_direct(
|
||||||
|
self.reader.directory, segment=row.segment, segment_frame=row.segment_frame
|
||||||
|
)
|
||||||
|
if image is None:
|
||||||
|
self.position = -1
|
||||||
|
return None
|
||||||
|
self.position = index
|
||||||
|
return image
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
self.reader.close()
|
||||||
|
self.position = -1
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.release()
|
||||||
|
|
||||||
|
def __enter__(self) -> "WindowVideo":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc_info) -> None:
|
||||||
|
self.release()
|
||||||
|
|
||||||
|
|
||||||
|
def open_window_video(review: dict, *, trial_id: str, step_index: int) -> WindowVideo:
|
||||||
|
"""Open the decoder for one hold. Raises ``ValueError`` carrying the reason token."""
|
||||||
|
location = locate_window(review, trial_id=trial_id, step_index=step_index)
|
||||||
|
if not isinstance(location, Location):
|
||||||
|
raise ValueError(location["reason"])
|
||||||
|
frames = in_window_frames(review, trial_id=trial_id, step_index=step_index)
|
||||||
|
if isinstance(frames, str):
|
||||||
|
raise ValueError(frames)
|
||||||
|
root = Path(review["source_directory"])
|
||||||
|
directory = (root / location.capture_directory).resolve()
|
||||||
|
reader = RecordingReader(directory)
|
||||||
|
return WindowVideo(location=location, reader=reader, frames=frames)
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
"""Synthetic recording evidence for the acquisition, summary and review tests.
|
||||||
|
|
||||||
|
Two rules govern this module:
|
||||||
|
|
||||||
|
* a fixture writes the **shared recorder's real headers**, so a reader is tested
|
||||||
|
against the files it will meet rather than against a convenient subset;
|
||||||
|
* a video fixture is a **real decodable clip**. A file of fake MP4 bytes proves
|
||||||
|
nothing about seeking, decoding or frame identity.
|
||||||
|
|
||||||
|
Nothing here touches the app's preference file, its ``outputs/`` directory or a
|
||||||
|
real camera. Fixtures are built inside the test's ``tmp_path``; the run's
|
||||||
|
``--basetemp`` points at the task scratch directory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from orisys_app_shared.acquisition.models import SdkPacket
|
||||||
|
from orisys_app_shared.record import FORCES_HEADER, FRAMES_HEADER
|
||||||
|
|
||||||
|
#: One working day in nanoseconds: holds inside a fixture are this far apart.
|
||||||
|
HOLD_STRIDE_NS = 10_000_000_000
|
||||||
|
#: A fixed, arbitrary session-relative origin for synthetic host timestamps.
|
||||||
|
BASE_NS = 1_700_000_000_000_000_000
|
||||||
|
|
||||||
|
FRAMES_FIELDS = list(FRAMES_HEADER)
|
||||||
|
FORCES_FIELDS = list(FORCES_HEADER)
|
||||||
|
|
||||||
|
|
||||||
|
def write_csv(path: Path, fields: list[str], rows: list[dict]) -> Path:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("w", newline="", encoding="utf-8") as handle:
|
||||||
|
writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore")
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def read_csv(path: Path) -> list[dict]:
|
||||||
|
with path.open(encoding="utf-8-sig", newline="") as handle:
|
||||||
|
return list(csv.DictReader(handle))
|
||||||
|
|
||||||
|
|
||||||
|
def frame_row(
|
||||||
|
frame_id: int,
|
||||||
|
t_host_mono_ns: int,
|
||||||
|
*,
|
||||||
|
segment: int = 1,
|
||||||
|
segment_frame: int | None = None,
|
||||||
|
in_video: int = 1,
|
||||||
|
dropped: int = 0,
|
||||||
|
time_basis: str = "host_camera",
|
||||||
|
t_rel_s: float = 0.0,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"frame_id": frame_id,
|
||||||
|
"source_frame_id": frame_id,
|
||||||
|
"t_rel_s": repr(float(t_rel_s)),
|
||||||
|
"t_host_mono_ns": int(t_host_mono_ns),
|
||||||
|
"t_host_utc_ns": int(t_host_mono_ns),
|
||||||
|
"acquired_at": "2026-10-17T00:00:00.000+00:00",
|
||||||
|
"time_basis": time_basis,
|
||||||
|
"media_time_ms": "",
|
||||||
|
"segment": segment if in_video else 0,
|
||||||
|
"segment_frame": (segment_frame if segment_frame is not None else frame_id)
|
||||||
|
if in_video
|
||||||
|
else 0,
|
||||||
|
"in_video": in_video,
|
||||||
|
"dropped": dropped,
|
||||||
|
"capture_dropped": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def force_row(
|
||||||
|
frame_id: int,
|
||||||
|
*,
|
||||||
|
fn: float | None,
|
||||||
|
fx: float | None = 0.0,
|
||||||
|
fy: float | None = 0.0,
|
||||||
|
valid: int = 1,
|
||||||
|
session_id: int = 7,
|
||||||
|
t_rel_s: float = 0.0,
|
||||||
|
) -> dict:
|
||||||
|
def cell(value):
|
||||||
|
return "" if value is None else repr(float(value))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"frame_id": frame_id,
|
||||||
|
"t_rel_s": repr(float(t_rel_s)),
|
||||||
|
"FN_raw": cell(fn),
|
||||||
|
"FX_raw": cell(fx),
|
||||||
|
"FY_raw": cell(fy),
|
||||||
|
"FN_N": "",
|
||||||
|
"FX_N": "",
|
||||||
|
"FY_N": "",
|
||||||
|
"valid": valid,
|
||||||
|
"calibration_id": "",
|
||||||
|
"FN_output_raw": "",
|
||||||
|
"FX_output_raw": "",
|
||||||
|
"FY_output_raw": "",
|
||||||
|
"FN_output_N": "",
|
||||||
|
"FX_output_N": "",
|
||||||
|
"FY_output_N": "",
|
||||||
|
"Fmag_raw": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_video(path: Path, *, frames: int = 6, fps: float = 10.0) -> Path:
|
||||||
|
"""A real, short, decodable clip whose frame number is visible in the picture.
|
||||||
|
|
||||||
|
Frame ``i`` carries a white 8x8 marker at column ``4 + (i % 6) * 8``. A test
|
||||||
|
can therefore ask the decoder which frame it got instead of trusting a seek.
|
||||||
|
"""
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
size = (64, 48) # width, height
|
||||||
|
writer = cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*"mp4v"), float(fps), size)
|
||||||
|
if not writer.isOpened():
|
||||||
|
raise RuntimeError("the MP4 writer is unavailable in this environment")
|
||||||
|
try:
|
||||||
|
for index in range(frames):
|
||||||
|
image = np.zeros((size[1], size[0], 3), dtype=np.uint8)
|
||||||
|
column = 4 + (index % 6) * 8
|
||||||
|
image[4:12, column : column + 8] = 255
|
||||||
|
writer.write(image)
|
||||||
|
finally:
|
||||||
|
writer.release()
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def decoded_frame_number(path: Path, *, segment_frame: int | None = None) -> int:
|
||||||
|
"""Decode one frame of a :func:`write_video` clip and read its marker column."""
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
capture = cv2.VideoCapture(str(path))
|
||||||
|
try:
|
||||||
|
if segment_frame is not None:
|
||||||
|
capture.set(cv2.CAP_PROP_POS_FRAMES, max(0, int(segment_frame) - 1))
|
||||||
|
ok, image = capture.read()
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError(f"could not decode {path.name}")
|
||||||
|
column = int(image[4:12].sum(axis=(0, 2)).argmax())
|
||||||
|
return (column - 4) // 8
|
||||||
|
finally:
|
||||||
|
capture.release()
|
||||||
|
|
||||||
|
|
||||||
|
def write_capture(
|
||||||
|
directory: Path,
|
||||||
|
frames: list[dict],
|
||||||
|
forces: list[dict],
|
||||||
|
*,
|
||||||
|
fields: list[str] | None = None,
|
||||||
|
video_segments: int = 1,
|
||||||
|
video_frames: int = 6,
|
||||||
|
) -> Path:
|
||||||
|
"""Write ``frames.csv``, ``forces.csv`` and one real MP4 per video segment."""
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
write_csv(directory / "frames.csv", fields or FRAMES_FIELDS, frames)
|
||||||
|
write_csv(directory / "forces.csv", FORCES_FIELDS, forces)
|
||||||
|
for segment in range(1, video_segments + 1):
|
||||||
|
write_video(directory / f"raw_{segment:04d}.mp4", frames=video_frames)
|
||||||
|
write_json_file(
|
||||||
|
directory / "session.json",
|
||||||
|
{
|
||||||
|
"format_version": "1.4",
|
||||||
|
"session_id": forces[0]["session_id"] if forces else 7,
|
||||||
|
"files": {"frames": "frames.csv", "forces": "forces.csv", "video": "raw_%04d.mp4"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return directory
|
||||||
|
|
||||||
|
|
||||||
|
def write_json_file(path: Path, payload: dict) -> Path:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Hold:
|
||||||
|
"""One declared reference window as the campaign writes it."""
|
||||||
|
|
||||||
|
step_index: int
|
||||||
|
branch: str
|
||||||
|
target_load_n: float
|
||||||
|
reference_n: float
|
||||||
|
start_ns: int
|
||||||
|
end_ns: int
|
||||||
|
complete: int = 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stability_verified(self) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def reference_row(hold: Hold, *, site_id: str, repeat_index: int, repeat_count: int) -> dict:
|
||||||
|
row = {name: "" for name in (
|
||||||
|
"step_index", "branch", "target_load_n", "t_start_host_mono_ns",
|
||||||
|
"t_end_host_mono_ns", "F_ref_normal_N", "site_id", "x", "y", "z",
|
||||||
|
"coordinate_frame", "repeat_index", "repeat_count", "hold_complete",
|
||||||
|
"reference_source", "fx_N", "fy_N", "fz_N", "force_to_tool_rotation",
|
||||||
|
"nx", "ny", "nz", "pose_x_mm", "pose_y_mm", "pose_yaw_deg",
|
||||||
|
"pose_pitch_deg", "pose_z_mm", "normal_aligned_to",
|
||||||
|
)}
|
||||||
|
row.update({
|
||||||
|
"step_index": hold.step_index,
|
||||||
|
"branch": hold.branch,
|
||||||
|
"target_load_n": hold.target_load_n,
|
||||||
|
"t_start_host_mono_ns": hold.start_ns,
|
||||||
|
"t_end_host_mono_ns": hold.end_ns,
|
||||||
|
"F_ref_normal_N": hold.reference_n,
|
||||||
|
"site_id": site_id,
|
||||||
|
"x": 0.0, "y": 0.0, "z": 0.0,
|
||||||
|
"coordinate_frame": "sensor",
|
||||||
|
"repeat_index": repeat_index,
|
||||||
|
"repeat_count": repeat_count,
|
||||||
|
"hold_complete": hold.complete,
|
||||||
|
"reference_source": "weight",
|
||||||
|
})
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
REFERENCE_FIELDS = list(reference_row(
|
||||||
|
Hold(0, "baseline", 0.0, 0.0, 0, 1), site_id="", repeat_index=1, repeat_count=1,
|
||||||
|
).keys())
|
||||||
|
|
||||||
|
|
||||||
|
def holds_for_levels(
|
||||||
|
levels: list[float], *, first_start_ns: int = BASE_NS, hold_ns: int = 4_000_000_000,
|
||||||
|
) -> list[Hold]:
|
||||||
|
"""baseline, one load per level, then unload back to zero."""
|
||||||
|
steps: list[tuple[str, float]] = [("baseline", 0.0)]
|
||||||
|
steps += [("load", float(level)) for level in levels]
|
||||||
|
steps += [("unload", value) for value in [*reversed(levels[:-1]), 0.0]]
|
||||||
|
holds = []
|
||||||
|
for index, (branch, target) in enumerate(steps):
|
||||||
|
start = first_start_ns + index * HOLD_STRIDE_NS
|
||||||
|
holds.append(Hold(index, branch, target, target, start, start + hold_ns))
|
||||||
|
return holds
|
||||||
|
|
||||||
|
|
||||||
|
def hold_samples(
|
||||||
|
hold: Hold,
|
||||||
|
*,
|
||||||
|
count: int = 3,
|
||||||
|
first_frame_id: int,
|
||||||
|
raw_base: float,
|
||||||
|
raw_per_n: float,
|
||||||
|
raw_per_axis: dict[str, float] | None = None,
|
||||||
|
) -> tuple[list[dict], list[dict]]:
|
||||||
|
"""Evenly spaced samples inside a hold, raw linear in the declared force."""
|
||||||
|
frames: list[dict] = []
|
||||||
|
forces: list[dict] = []
|
||||||
|
span = (hold.end_ns - hold.start_ns) // (count + 1)
|
||||||
|
fn = raw_base + raw_per_n * hold.reference_n
|
||||||
|
for offset in range(count):
|
||||||
|
frame_id = first_frame_id + offset
|
||||||
|
stamp = hold.start_ns + span * (offset + 1)
|
||||||
|
frames.append(frame_row(frame_id, stamp, t_rel_s=(stamp - BASE_NS) / 1e9))
|
||||||
|
values = {"fn": fn}
|
||||||
|
if raw_per_axis:
|
||||||
|
values.update({
|
||||||
|
"fx": raw_per_axis["FX"] * hold.reference_n,
|
||||||
|
"fy": raw_per_axis["FY"] * hold.reference_n,
|
||||||
|
})
|
||||||
|
forces.append(force_row(frame_id, **values))
|
||||||
|
return frames, forces
|
||||||
|
|
||||||
|
|
||||||
|
def _write_multipoint_trial(
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
session_id: str,
|
||||||
|
site_id: str,
|
||||||
|
repeat_index: int,
|
||||||
|
repeat_count: int,
|
||||||
|
holds: list[Hold],
|
||||||
|
with_capture: bool,
|
||||||
|
video: bool,
|
||||||
|
raw_base: float,
|
||||||
|
raw_per_n: float,
|
||||||
|
samples_per_hold: int,
|
||||||
|
frame_start: int,
|
||||||
|
capture_status: str | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
write_csv(
|
||||||
|
path / "reference.csv",
|
||||||
|
REFERENCE_FIELDS,
|
||||||
|
[reference_row(hold, site_id=site_id, repeat_index=repeat_index,
|
||||||
|
repeat_count=repeat_count) for hold in holds],
|
||||||
|
)
|
||||||
|
write_csv(
|
||||||
|
path / "events.csv",
|
||||||
|
["t_host_mono_ns", "t_host_utc_ns", "kind", "detail"],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
manifest = {
|
||||||
|
"trial_id": f"{site_id}-repeat-{repeat_index:03d}",
|
||||||
|
"site_id": site_id,
|
||||||
|
"status": "complete",
|
||||||
|
"uv_px": {"u": 128, "v": 64},
|
||||||
|
"images": {"captured": with_capture,
|
||||||
|
"reason": "" if with_capture else "camera not attached yet"},
|
||||||
|
}
|
||||||
|
if capture_status is not None:
|
||||||
|
manifest["capture_status"] = capture_status
|
||||||
|
write_json_file(path / "session.json", manifest)
|
||||||
|
if not with_capture:
|
||||||
|
return []
|
||||||
|
frames: list[dict] = []
|
||||||
|
forces: list[dict] = []
|
||||||
|
for hold in holds:
|
||||||
|
hold_frames, hold_forces = hold_samples(
|
||||||
|
hold,
|
||||||
|
count=samples_per_hold,
|
||||||
|
first_frame_id=frame_start + len(frames),
|
||||||
|
raw_base=raw_base,
|
||||||
|
raw_per_n=raw_per_n,
|
||||||
|
)
|
||||||
|
frames.extend(hold_frames)
|
||||||
|
forces.extend(hold_forces)
|
||||||
|
if not video:
|
||||||
|
# A data-only recording: the rows exist, no frame was written to a video.
|
||||||
|
frames = [
|
||||||
|
{**row, "segment": 0, "segment_frame": 0, "in_video": 0} for row in frames
|
||||||
|
]
|
||||||
|
write_capture(path / "capture", frames, forces, fields=FRAMES_FIELDS,
|
||||||
|
video_segments=1 if video else 0)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def multipoint_session(
|
||||||
|
root: Path,
|
||||||
|
*,
|
||||||
|
sites: tuple[str, ...] = ("P1",),
|
||||||
|
repeats: int = 1,
|
||||||
|
levels: tuple[float, ...] = (5.0,),
|
||||||
|
with_capture: bool = True,
|
||||||
|
video: bool = True,
|
||||||
|
raw_base: float = 100.0,
|
||||||
|
raw_per_n: float = 40.0,
|
||||||
|
samples_per_hold: int = 3,
|
||||||
|
simulate: bool = False,
|
||||||
|
trial_status: str = "complete",
|
||||||
|
extra_holds: list[Hold] | None = None,
|
||||||
|
recording_block: dict | None = None,
|
||||||
|
capture_status: str | None = None,
|
||||||
|
) -> Path:
|
||||||
|
"""A complete multi-point session directory through the review contract.
|
||||||
|
|
||||||
|
``video=False`` writes a data-only recording: frame rows exist with
|
||||||
|
``in_video=0`` and no MP4 is created. ``recording_block`` writes the root
|
||||||
|
manifest's ``recording`` block; ``None`` keeps the fixture shaped like a
|
||||||
|
session recorded before that block existed. ``capture_status`` puts the
|
||||||
|
lifecycle into the trial records, as the campaign does.
|
||||||
|
"""
|
||||||
|
session_id = f"finger-SN1_{root.name}_test0001"
|
||||||
|
trials: list[dict] = []
|
||||||
|
completion: list[dict] = []
|
||||||
|
site_records = []
|
||||||
|
for site_id in sites:
|
||||||
|
site_records.append({
|
||||||
|
"site_id": site_id,
|
||||||
|
"position": {"x": 1.0, "y": 2.0, "z": 3.0},
|
||||||
|
"position_frame": "sensor",
|
||||||
|
"uv_px": {"u": 128, "v": 64},
|
||||||
|
"levels_n": list(levels),
|
||||||
|
"hold_s": 4.0,
|
||||||
|
"repeats": repeats,
|
||||||
|
})
|
||||||
|
complete = 0
|
||||||
|
for repeat in range(1, repeats + 1):
|
||||||
|
site_dir = root / "sites" / site_id / f"repeat-{repeat:03d}"
|
||||||
|
holds = holds_for_levels(list(levels))
|
||||||
|
if extra_holds:
|
||||||
|
holds = [*holds, *extra_holds]
|
||||||
|
directory = f"sites/{site_id}/repeat-{repeat:03d}"
|
||||||
|
_write_multipoint_trial(
|
||||||
|
site_dir,
|
||||||
|
session_id=session_id,
|
||||||
|
site_id=site_id,
|
||||||
|
repeat_index=repeat,
|
||||||
|
repeat_count=repeats,
|
||||||
|
holds=holds,
|
||||||
|
with_capture=with_capture,
|
||||||
|
video=video,
|
||||||
|
raw_base=raw_base,
|
||||||
|
raw_per_n=raw_per_n,
|
||||||
|
samples_per_hold=samples_per_hold,
|
||||||
|
frame_start=1,
|
||||||
|
capture_status=capture_status,
|
||||||
|
)
|
||||||
|
trial_record = {
|
||||||
|
"trial_id": f"{site_id}-repeat-{repeat:03d}",
|
||||||
|
"site_id": site_id,
|
||||||
|
"repeat_index": repeat,
|
||||||
|
"directory": directory,
|
||||||
|
"status": trial_status,
|
||||||
|
"reason": "" if trial_status == "complete" else "stopped by the operator",
|
||||||
|
}
|
||||||
|
if capture_status is not None:
|
||||||
|
trial_record["capture_status"] = capture_status
|
||||||
|
trials.append(trial_record)
|
||||||
|
if trial_status == "complete":
|
||||||
|
complete += 1
|
||||||
|
completion.append({"site_id": site_id, "repeats_planned": repeats,
|
||||||
|
"repeats_complete": complete,
|
||||||
|
"complete": complete >= repeats})
|
||||||
|
manifest = {
|
||||||
|
"format_version": "autocal-multipoint-1.0",
|
||||||
|
"session_id": session_id,
|
||||||
|
"sensor_model": "finger",
|
||||||
|
"sensor_sn": "SN1",
|
||||||
|
"coordinate_frame": "sensor",
|
||||||
|
"max_force_n": 20.0,
|
||||||
|
"min_force_n": 0.0,
|
||||||
|
"simulate": simulate,
|
||||||
|
"status": "complete" if trial_status == "complete" else "incomplete",
|
||||||
|
"sites": site_records,
|
||||||
|
"trials": trials,
|
||||||
|
"completion": completion,
|
||||||
|
"started_at": "2026-10-17T00:00:00+00:00",
|
||||||
|
"sync": {"association": "A frame belongs to a hold when its t_host_mono_ns is in "
|
||||||
|
"[t_start_host_mono_ns, t_end_host_mono_ns). Rows are not paired."},
|
||||||
|
"platform_motion": False,
|
||||||
|
"automatic_force_control": False,
|
||||||
|
}
|
||||||
|
if recording_block is not None:
|
||||||
|
manifest["recording"] = recording_block
|
||||||
|
write_json_file(root / "session.json", manifest)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def single_session(
|
||||||
|
root: Path,
|
||||||
|
*,
|
||||||
|
sites: tuple[str, ...] = ("site-001",),
|
||||||
|
trials_per_site: int = 1,
|
||||||
|
levels: tuple[float, ...] = (5.0,),
|
||||||
|
with_capture: bool = True,
|
||||||
|
raw_base: float = 100.0,
|
||||||
|
raw_per_n: float = 40.0,
|
||||||
|
samples_per_hold: int = 3,
|
||||||
|
trial_status: str = "complete",
|
||||||
|
reference_alignment_possible: bool = True,
|
||||||
|
) -> Path:
|
||||||
|
"""A complete single-trial calibration session (``autocal-session-1.0``)."""
|
||||||
|
session_id = f"finger-SN1_{root.name}_test0001"
|
||||||
|
uv_definition = {
|
||||||
|
"unit": "px", "u_positive": "right", "v_positive": "up",
|
||||||
|
"origin_raster_xy_px": [0, 0], "raster_size_hw": [64, 64],
|
||||||
|
"mapping_sha256": "test-mapping",
|
||||||
|
}
|
||||||
|
site_records = [
|
||||||
|
{"site_id": site_id, "uv_xy_px": [10 + index, 20 + index],
|
||||||
|
"position_sensor_mm": [1.0, 2.0, 3.0]}
|
||||||
|
for index, site_id in enumerate(sites)
|
||||||
|
]
|
||||||
|
trials: list[dict] = []
|
||||||
|
for site_index, site_id in enumerate(sites):
|
||||||
|
for repeat in range(1, trials_per_site + 1):
|
||||||
|
trial_id = f"trial-{len(trials) + 1:03d}"
|
||||||
|
directory = f"trials/{trial_id}"
|
||||||
|
path = root / directory
|
||||||
|
holds = holds_for_levels(list(levels))
|
||||||
|
write_csv(path / "reference.csv", REFERENCE_FIELDS, [
|
||||||
|
reference_row(hold, site_id=site_id, repeat_index=repeat, repeat_count=1)
|
||||||
|
for hold in holds
|
||||||
|
])
|
||||||
|
frames: list[dict] = []
|
||||||
|
forces: list[dict] = []
|
||||||
|
if with_capture:
|
||||||
|
for hold in holds:
|
||||||
|
hold_frames, hold_forces = hold_samples(
|
||||||
|
hold, count=samples_per_hold,
|
||||||
|
first_frame_id=1 + len(frames),
|
||||||
|
raw_base=raw_base, raw_per_n=raw_per_n,
|
||||||
|
)
|
||||||
|
frames.extend(hold_frames)
|
||||||
|
forces.extend(hold_forces)
|
||||||
|
write_csv(path / "frames.csv", FRAMES_FIELDS, frames)
|
||||||
|
write_csv(path / "forces.csv", FORCES_FIELDS, forces)
|
||||||
|
write_json_file(path / "session.json", {
|
||||||
|
"trial_id": trial_id,
|
||||||
|
"site_id": site_id,
|
||||||
|
"session_id": 7,
|
||||||
|
"calibration_session_id": session_id,
|
||||||
|
"trial_status": trial_status,
|
||||||
|
"trial_reason": "" if trial_status == "complete" else "application closed",
|
||||||
|
"reference_alignment_possible": reference_alignment_possible,
|
||||||
|
"uv_definition": uv_definition,
|
||||||
|
"steps": [{"step_index": hold.step_index, "branch": hold.branch,
|
||||||
|
"target_load_n": hold.target_load_n} for hold in holds],
|
||||||
|
})
|
||||||
|
trials.append({
|
||||||
|
"trial_id": trial_id, "site_id": site_id, "repeat_index": repeat,
|
||||||
|
"directory": directory, "status": trial_status,
|
||||||
|
"reason": "" if trial_status == "complete" else "application closed",
|
||||||
|
})
|
||||||
|
write_json_file(root / "session.json", {
|
||||||
|
"format_version": "autocal-session-1.0",
|
||||||
|
"session_id": session_id,
|
||||||
|
"status": "complete" if trial_status == "complete" else "incomplete",
|
||||||
|
"sites": site_records,
|
||||||
|
"trials": trials,
|
||||||
|
"uv_definition": uv_definition,
|
||||||
|
"sensor_model": "finger",
|
||||||
|
"sensor_sn": "SN1",
|
||||||
|
"started_at": "2026-10-17T00:00:00+00:00",
|
||||||
|
})
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWorker:
|
||||||
|
"""A fingerprint worker stand-in whose session can be restarted mid-recording."""
|
||||||
|
|
||||||
|
def __init__(self, *, session_id: int = 1, frame_id: int = 1, time_basis: str = "host_camera",
|
||||||
|
clock=None):
|
||||||
|
self.session_id = int(session_id)
|
||||||
|
self.is_running = True
|
||||||
|
self.sinks: list = []
|
||||||
|
self.time_basis = time_basis
|
||||||
|
self._clock = clock
|
||||||
|
self._frame_id = int(frame_id)
|
||||||
|
self._started = self._now_ns()
|
||||||
|
self.packet = self._packet()
|
||||||
|
|
||||||
|
def _now_ns(self) -> int:
|
||||||
|
return time.perf_counter_ns() if self._clock is None else int(self._clock())
|
||||||
|
|
||||||
|
def _packet(self) -> SdkPacket:
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
now = self._now_ns()
|
||||||
|
return SdkPacket(
|
||||||
|
raw_frame=np.zeros((16, 16, 3), dtype=np.uint8),
|
||||||
|
processed_frame=np.zeros((16, 16, 3), dtype=np.uint8),
|
||||||
|
timestamp=time.time(),
|
||||||
|
fps=30,
|
||||||
|
process_ms=1,
|
||||||
|
session_id=self.session_id,
|
||||||
|
frame_id=self._frame_id,
|
||||||
|
mono_t=(now - self._started) / 1e9,
|
||||||
|
capture_mono_ns=now,
|
||||||
|
capture_utc_ns=time.time_ns(),
|
||||||
|
source_frame_id=self._frame_id,
|
||||||
|
time_basis=self.time_basis,
|
||||||
|
normal_force_raw=1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def advance(self) -> SdkPacket:
|
||||||
|
self._frame_id += 1
|
||||||
|
self.packet = self._packet()
|
||||||
|
return self.packet
|
||||||
|
|
||||||
|
def latest(self):
|
||||||
|
return self.packet
|
||||||
|
|
||||||
|
def add_sink(self, sink) -> None:
|
||||||
|
if sink not in self.sinks:
|
||||||
|
self.sinks.append(sink)
|
||||||
|
|
||||||
|
def remove_sink(self, sink) -> None:
|
||||||
|
if sink in self.sinks:
|
||||||
|
self.sinks.remove(sink)
|
||||||
|
|
||||||
|
def emit(self) -> None:
|
||||||
|
for sink in list(self.sinks):
|
||||||
|
sink(self.packet)
|
||||||
|
|
||||||
|
def restart(self) -> None:
|
||||||
|
"""A new acquisition session with fresh frame numbers, sinks still attached."""
|
||||||
|
self.session_id += 1
|
||||||
|
self._frame_id = 1
|
||||||
|
self._started = self._now_ns()
|
||||||
|
self.packet = self._packet()
|
||||||
|
|
||||||
|
def stop(self, *, wait: bool = True) -> None:
|
||||||
|
self.is_running = False
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Bounded event-loop waits for recording review tests."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from PySide6.QtCore import QCoreApplication
|
||||||
|
|
||||||
|
|
||||||
|
def wait_review(widget):
|
||||||
|
deadline = time.monotonic() + 10
|
||||||
|
while widget._job is not None and time.monotonic() < deadline:
|
||||||
|
QCoreApplication.processEvents()
|
||||||
|
# Release the GIL so the Python recording worker can finish its I/O.
|
||||||
|
time.sleep(.001)
|
||||||
|
assert widget._job is None, "background review did not finish"
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Candidate arrays on the flat map: the grid, the presets and the on-surface check."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from orisys_autocal import array_plan
|
||||||
|
from orisys_autocal.array_plan import (
|
||||||
|
NEAR_EDGE,
|
||||||
|
OFF_SURFACE,
|
||||||
|
PRESETS,
|
||||||
|
check_candidates,
|
||||||
|
grid_uv,
|
||||||
|
preset_uv,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _flat(width: int = 200, height: int = 200, hole: tuple[int, int, int, int] | None = None):
|
||||||
|
"""A fully covered raster, optionally with a rectangular hole (x0, y0, x1, y1)."""
|
||||||
|
covered = np.ones((height, width), dtype=np.int32)
|
||||||
|
if hole:
|
||||||
|
x0, y0, x1, y1 = hole
|
||||||
|
covered[y0:y1, x0:x1] = -1
|
||||||
|
return SimpleNamespace(face_index=covered, width=width, height=height)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_grid_is_centred_and_bottom_row_first():
|
||||||
|
points = grid_uv(2, 3, 10, 20, centre=(0, 100), serpentine=False)
|
||||||
|
assert points == [(-10, 90), (0, 90), (10, 90), (-10, 110), (0, 110), (10, 110)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_serpentine_walks_every_second_row_backwards():
|
||||||
|
points = grid_uv(2, 3, 10, 20, centre=(0, 100), serpentine=True)
|
||||||
|
assert points[3:] == [(10, 110), (0, 110), (-10, 110)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_empty_or_oversized_grid():
|
||||||
|
assert grid_uv(0, 3, 10, 10) == []
|
||||||
|
with pytest.raises(ValueError, match="more than"):
|
||||||
|
grid_uv(30, 30, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_presets_overlap_only_at_the_hardware_check_point():
|
||||||
|
labels = [label for key in PRESETS for label, _u, _v in PRESETS[key]]
|
||||||
|
assert sorted(label for label in set(labels) if labels.count(label) > 1) == ["C13"]
|
||||||
|
# The first-model grid is centred on the hardware-check point.
|
||||||
|
assert ("C13", 0, 470) in PRESETS["stage1"]
|
||||||
|
assert ("C13", 0, 470) in PRESETS["stage0"]
|
||||||
|
assert preset_uv("stage0") == [("C13", 0, 470)]
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
preset_uv("nope")
|
||||||
|
assert array_plan.PRESET_KEYS[0] == "stage0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_point_on_the_surface_is_valid_and_off_it_is_not():
|
||||||
|
flat = _flat(hole=(40, 40, 60, 60))
|
||||||
|
origin = (100, 150) # raster (x, y) = (100 + u, 150 - v)
|
||||||
|
result = check_candidates(flat, [(0, 100), (-50, 100)], origin=origin)
|
||||||
|
assert [c.valid for c in result] == [True, False]
|
||||||
|
assert result[1].reason == OFF_SURFACE # raster (50, 50) is in the hole
|
||||||
|
|
||||||
|
|
||||||
|
def test_outside_the_raster_is_off_surface_not_an_error():
|
||||||
|
result = check_candidates(_flat(), [(900, 900)], origin=(100, 150))
|
||||||
|
assert result[0].valid is False and result[0].reason == OFF_SURFACE
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_margin_rejects_points_too_close_to_a_hole_or_the_border():
|
||||||
|
flat = _flat(hole=(100, 60, 110, 70))
|
||||||
|
origin = (100, 150)
|
||||||
|
# raster (100, 75): five pixels below the hole's bottom edge.
|
||||||
|
near = check_candidates(flat, [(0, 75)], margin_px=8, origin=origin)[0]
|
||||||
|
far = check_candidates(flat, [(0, 75)], margin_px=3, origin=origin)[0]
|
||||||
|
assert (near.valid, near.reason) == (False, NEAR_EDGE)
|
||||||
|
assert far.valid
|
||||||
|
# The raster border counts as an edge.
|
||||||
|
assert check_candidates(flat, [(-98, 100)], margin_px=5, origin=origin)[0].reason == NEAR_EDGE
|
||||||
|
|
||||||
|
|
||||||
|
def test_labels_survive_the_check_and_no_surface_vouches_for_nothing():
|
||||||
|
flat = _flat()
|
||||||
|
kept = check_candidates(flat, [("C13", 0, 100)], origin=(100, 150))[0]
|
||||||
|
assert kept.label == "C13" and kept.valid
|
||||||
|
none = check_candidates(None, [("C13", 0, 100), (1, 2)])
|
||||||
|
assert [c.valid for c in none] == [False, False]
|
||||||
|
assert none[0].label == "C13" and none[1].label == ""
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""The array tool and the 2D map's look: what the operator sees and what the tool offers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from orisys_autocal.array_plan import Candidate
|
||||||
|
from orisys_autocal.ui.sensor_panel import FlatSurfaceView
|
||||||
|
from PySide6.QtGui import QColor
|
||||||
|
|
||||||
|
|
||||||
|
def _synthetic_flat(size: int = 64):
|
||||||
|
from orisys_autocal.sensor.uv_flat import FlatSurface
|
||||||
|
|
||||||
|
image = np.zeros((size, size, 3), dtype=np.uint8)
|
||||||
|
image[..., 2] = 200 # a plain blue map: any yellow on it is a mark
|
||||||
|
return FlatSurface(
|
||||||
|
size_hw=(size, size),
|
||||||
|
image_rgb=image,
|
||||||
|
positions_mm=np.zeros((size, size, 3)),
|
||||||
|
normals=np.tile(np.array([0.0, 0.0, 1.0]), (size, size, 1)),
|
||||||
|
face_index=np.zeros((size, size), dtype=np.int32),
|
||||||
|
uv_min=(0.0, 0.0),
|
||||||
|
uv_max=(1.0, 1.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_marks_stay_visible_when_the_map_is_scaled_down(qt_app):
|
||||||
|
view = FlatSurfaceView()
|
||||||
|
view.resize(32, 32) # half the map's size
|
||||||
|
view.set_flat(_synthetic_flat(64))
|
||||||
|
view.set_marker_colour("#ffd60a")
|
||||||
|
view.set_sequence_points([(32, 32, "", False)])
|
||||||
|
shown = view.pixmap().toImage()
|
||||||
|
centre = QColor(shown.pixel(shown.width() // 2, shown.height() // 2))
|
||||||
|
# The mark is drawn after the scale, so its colour is at the centre of the
|
||||||
|
# shown image instead of being smeared into the blue by the downscale.
|
||||||
|
assert centre.red() > 220 and centre.green() > 180 and centre.blue() < 80
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_background_can_be_changed_and_marks_survive_it(qt_app):
|
||||||
|
view = FlatSurfaceView()
|
||||||
|
view.resize(64, 64)
|
||||||
|
view.set_flat(_synthetic_flat(64))
|
||||||
|
before = QColor(view.pixmap().toImage().pixel(2, 2))
|
||||||
|
for mode in ("gray", "dim", "outline"):
|
||||||
|
view.set_background(mode)
|
||||||
|
after = QColor(view.pixmap().toImage().pixel(2, 2))
|
||||||
|
assert after != before, mode
|
||||||
|
assert view.background == mode
|
||||||
|
view.set_background("nonsense")
|
||||||
|
assert view.background == "original"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unusable_candidates_are_drawn_apart_from_usable_ones(qt_app):
|
||||||
|
view = FlatSurfaceView()
|
||||||
|
view.resize(64, 64)
|
||||||
|
view.set_flat(_synthetic_flat(64))
|
||||||
|
view.set_marker_colour("#ffd60a")
|
||||||
|
view.set_candidates([(32, 32, False)])
|
||||||
|
shown = view.pixmap().toImage()
|
||||||
|
red_pixels = sum(
|
||||||
|
1
|
||||||
|
for x in range(24, 41)
|
||||||
|
for y in range(24, 41)
|
||||||
|
if QColor(shown.pixel(x, y)).red() > 200 and QColor(shown.pixel(x, y)).green() < 110
|
||||||
|
)
|
||||||
|
assert red_pixels > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_look_is_chosen_on_the_page_and_reported(make_window):
|
||||||
|
window = make_window()
|
||||||
|
panel = window.finger_panel
|
||||||
|
seen: list[tuple[str, str]] = []
|
||||||
|
panel.viewStyleChanged.connect(lambda mode, colour: seen.append((mode, colour)))
|
||||||
|
panel.bg_combo.setCurrentIndex(panel.bg_combo.findData("dim"))
|
||||||
|
panel.colour_combo.setCurrentIndex(panel.colour_combo.findData("white"))
|
||||||
|
assert panel.view_style() == ("dim", "white")
|
||||||
|
assert seen[-1] == ("dim", "white")
|
||||||
|
assert window.prefs["finger_view_background"] == "dim"
|
||||||
|
assert panel.view.marker_colour == QColor("#ffffff")
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_grid_has_rows_times_columns_candidates_and_follows_its_fields(make_window):
|
||||||
|
window = make_window()
|
||||||
|
tool = window.sequence_panel.array_tool
|
||||||
|
assert len(tool.candidates()) == 9
|
||||||
|
tool.spin("rows").setValue(2)
|
||||||
|
tool.spin("cols").setValue(5)
|
||||||
|
assert len(tool.candidates()) == 10
|
||||||
|
tool.mode_combo.setCurrentIndex(tool.mode_combo.findData("preset"))
|
||||||
|
assert tool.mode == "preset"
|
||||||
|
tool.preset_combo.setCurrentIndex(tool.preset_combo.findData("stage0"))
|
||||||
|
assert tool.candidates() == [("C13", 0, 470)]
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_usable_candidates_can_be_added(make_window):
|
||||||
|
window = make_window()
|
||||||
|
tool = window.sequence_panel.array_tool
|
||||||
|
added: list = []
|
||||||
|
tool.addRequested.connect(added.append)
|
||||||
|
tool.set_checked([Candidate(0, 0, False, "off_surface")])
|
||||||
|
assert not tool.add_button.isEnabled()
|
||||||
|
tool.set_checked([Candidate(0, 0, True), Candidate(5, 5, False, "near_edge")])
|
||||||
|
assert tool.add_button.isEnabled()
|
||||||
|
assert tool.add_button.text() == t("array.btn_add", count=1)
|
||||||
|
tool.add_button.click()
|
||||||
|
assert [c.u for c in added[0]] == [0]
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_adding_candidates_skips_a_point_already_in_the_sequence(make_window, monkeypatch):
|
||||||
|
window = make_window()
|
||||||
|
panel = window.sequence_panel
|
||||||
|
monkeypatch.setattr(window, "_map_origin", lambda: (100, 150))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
window.finger_panel,
|
||||||
|
"solve_pixel_row",
|
||||||
|
lambda x, y: {"X": 1.0, "Y": 2.0, "Z": 3.0, "u_px": x - 100.0, "v_px": 150.0 - y},
|
||||||
|
)
|
||||||
|
before = panel.point_row_count()
|
||||||
|
window._on_array_add([Candidate(10, 20), Candidate(30, 40)]) # noqa: SLF001
|
||||||
|
window._on_array_add([Candidate(10, 20), Candidate(50, 60)]) # noqa: SLF001
|
||||||
|
assert panel.point_row_count() == before + 3
|
||||||
|
window.close()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""The axis event log: only changes, a baseline on first sight, nothing across a gap."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from orisys_autocal.motion import AxisState, Snapshot
|
||||||
|
from orisys_autocal.motion import events as ev
|
||||||
|
from orisys_autocal.motion.models import AxisStatusBits
|
||||||
|
from orisys_autocal.motion.events import AxisEventTracker
|
||||||
|
|
||||||
|
|
||||||
|
def _axis(axis=0, *, alarm=False, enabled=True, read_ok=True, homing=False, home_status=0, units=10.0, error=""):
|
||||||
|
raw = (512 if alarm else 0) | (64 if homing else 0)
|
||||||
|
return AxisState(
|
||||||
|
axis=axis,
|
||||||
|
enabled=enabled,
|
||||||
|
units=units,
|
||||||
|
status=AxisStatusBits(raw),
|
||||||
|
home_status=home_status,
|
||||||
|
read_ok=read_ok,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _snap(*axes, connected=True) -> Snapshot:
|
||||||
|
return Snapshot(connected=connected, axes=tuple(axes))
|
||||||
|
|
||||||
|
|
||||||
|
def _kinds(events):
|
||||||
|
return [(event.axis, event.kind) for event in events]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_first_poll_is_a_baseline_and_a_steady_state_is_silent():
|
||||||
|
tracker = AxisEventTracker(clock=lambda: 1.0)
|
||||||
|
assert tracker.update(_snap(_axis(0), _axis(1))) == []
|
||||||
|
assert tracker.update(_snap(_axis(0), _axis(1))) == []
|
||||||
|
assert tracker.events == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_alarm_that_is_already_up_when_first_seen_is_reported():
|
||||||
|
tracker = AxisEventTracker()
|
||||||
|
assert _kinds(tracker.update(_snap(_axis(2, alarm=True)))) == [(2, ev.ALARM_ON)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_alarm_enable_and_homing_edges_are_reported_once():
|
||||||
|
tracker = AxisEventTracker()
|
||||||
|
tracker.update(_snap(_axis(0, enabled=False)))
|
||||||
|
assert _kinds(tracker.update(_snap(_axis(0, enabled=True)))) == [(0, ev.ENABLED)]
|
||||||
|
assert _kinds(tracker.update(_snap(_axis(0, alarm=True)))) == [(0, ev.ALARM_ON)]
|
||||||
|
assert tracker.update(_snap(_axis(0, alarm=True))) == []
|
||||||
|
assert _kinds(tracker.update(_snap(_axis(0)))) == [(0, ev.ALARM_OFF)]
|
||||||
|
assert _kinds(tracker.update(_snap(_axis(0, homing=True)))) == [(0, ev.HOME_STARTED)]
|
||||||
|
assert _kinds(tracker.update(_snap(_axis(0, home_status=1)))) == [(0, ev.HOME_DONE)]
|
||||||
|
tracker.update(_snap(_axis(0, homing=True)))
|
||||||
|
assert _kinds(tracker.update(_snap(_axis(0, home_status=0)))) == [(0, ev.HOME_INCOMPLETE)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_read_failure_and_its_recovery_are_reported():
|
||||||
|
tracker = AxisEventTracker()
|
||||||
|
tracker.update(_snap(_axis(1)))
|
||||||
|
failed = tracker.update(_snap(_axis(1, read_ok=False, error="timeout")))
|
||||||
|
assert [(e.axis, e.kind, e.detail) for e in failed] == [(1, ev.READ_FAILED, "timeout")]
|
||||||
|
assert tracker.update(_snap(_axis(1, read_ok=False, error="timeout"))) == []
|
||||||
|
assert _kinds(tracker.update(_snap(_axis(1)))) == [(1, ev.READ_OK)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_gap_in_the_connection_resets_the_baseline():
|
||||||
|
tracker = AxisEventTracker()
|
||||||
|
tracker.update(_snap(_axis(0)))
|
||||||
|
assert _kinds(tracker.update(_snap(connected=False))) == [(ev.NO_AXIS, ev.DISCONNECTED)]
|
||||||
|
# Back again with the axis alarmed: the connection edge, then the alarm as a
|
||||||
|
# first sight; nothing is compared with a state from before the gap.
|
||||||
|
back = tracker.update(_snap(_axis(0, alarm=True)))
|
||||||
|
assert _kinds(back) == [(ev.NO_AXIS, ev.CONNECTED), (0, ev.ALARM_ON)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_units_disagreeing_with_the_configuration_is_an_event_both_ways():
|
||||||
|
tracker = AxisEventTracker()
|
||||||
|
expected = {0: 10.0}
|
||||||
|
assert tracker.update(_snap(_axis(0, units=10.0)), expected) == []
|
||||||
|
mismatch = tracker.update(_snap(_axis(0, units=5.0)), expected)
|
||||||
|
assert [(e.kind, e.detail) for e in mismatch] == [(ev.UNITS_MISMATCH, "10|5")]
|
||||||
|
assert tracker.update(_snap(_axis(0, units=5.0)), expected) == []
|
||||||
|
assert _kinds(tracker.update(_snap(_axis(0, units=10.0)), expected)) == [(0, ev.UNITS_OK)]
|
||||||
|
# With nothing to compare against no verdict is invented.
|
||||||
|
assert AxisEventTracker().update(_snap(_axis(0, units=5.0))) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_log_is_bounded_and_can_be_cleared():
|
||||||
|
tracker = AxisEventTracker(limit=3)
|
||||||
|
tracker.update(_snap(_axis(0)))
|
||||||
|
for index in range(10):
|
||||||
|
tracker.update(_snap(_axis(0, alarm=bool(index % 2 == 0))))
|
||||||
|
assert len(tracker.events) == 3
|
||||||
|
tracker.clear()
|
||||||
|
assert tracker.events == []
|
||||||
@@ -34,6 +34,14 @@ FORBIDDEN_BINDINGS = {"PyQt5", "PyQt6", "PySide2"}
|
|||||||
#: The one binding allowed, and where it may be named.
|
#: The one binding allowed, and where it may be named.
|
||||||
ALLOWED_BINDING = "PySide6"
|
ALLOWED_BINDING = "PySide6"
|
||||||
|
|
||||||
|
#: ``ui/`` modules that deliberately name no binding. ``reveal.py`` hands a path
|
||||||
|
#: to the OS file manager; ``site_export.py`` writes the queue's CSV point sheet.
|
||||||
|
#: Neither builds a widget, so neither has a binding to declare. The rule below
|
||||||
|
#: exists so a UI module cannot *use* a binding it does not name; a module that
|
||||||
|
#: uses none cannot break it, and importing PySide6 only to satisfy the rule would
|
||||||
|
#: be the false declaration the rule is meant to prevent.
|
||||||
|
_BINDING_FREE_UI = {"reveal.py", "site_export.py"}
|
||||||
|
|
||||||
#: Scoped enum holders we care about, with the value names actually used.
|
#: Scoped enum holders we care about, with the value names actually used.
|
||||||
#:
|
#:
|
||||||
#: Hand-maintained, and therefore incomplete by construction: the PySide6 migration
|
#: Hand-maintained, and therefore incomplete by construction: the PySide6 migration
|
||||||
@@ -61,6 +69,10 @@ _SCOPED_ENUMS = {
|
|||||||
"TextElideMode",
|
"TextElideMode",
|
||||||
"TextFlag",
|
"TextFlag",
|
||||||
"AspectRatioMode",
|
"AspectRatioMode",
|
||||||
|
"WindowType",
|
||||||
|
# ``Qt.WidgetAttribute.WA_DeleteOnClose`` is the scoped spelling too; the
|
||||||
|
# class simply was not needed before the video review dialog.
|
||||||
|
"WidgetAttribute",
|
||||||
),
|
),
|
||||||
"QTableWidget": ("SelectionMode", "EditTrigger", "SelectionBehavior"),
|
"QTableWidget": ("SelectionMode", "EditTrigger", "SelectionBehavior"),
|
||||||
"QSizePolicy": ("Policy",),
|
"QSizePolicy": ("Policy",),
|
||||||
@@ -143,6 +155,8 @@ def test_every_ui_module_imports_the_allowed_binding():
|
|||||||
for path in _python_files(APP_SRC / "orisys_autocal" / "ui"):
|
for path in _python_files(APP_SRC / "orisys_autocal" / "ui"):
|
||||||
if path.name == "__init__.py":
|
if path.name == "__init__.py":
|
||||||
continue # a re-export barrel, not a widget module
|
continue # a re-export barrel, not a widget module
|
||||||
|
if path.name in _BINDING_FREE_UI:
|
||||||
|
continue
|
||||||
if ALLOWED_BINDING not in _imported_packages(path):
|
if ALLOWED_BINDING not in _imported_packages(path):
|
||||||
offenders.append(path.name)
|
offenders.append(path.name)
|
||||||
assert not offenders, f"these UI modules do not import {ALLOWED_BINDING}: {offenders}"
|
assert not offenders, f"these UI modules do not import {ALLOWED_BINDING}: {offenders}"
|
||||||
@@ -160,6 +174,12 @@ def test_no_module_uses_an_unscoped_enum():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_window_type_guard_accepts_scoped_values_but_rejects_legacy_values(tmp_path):
|
||||||
|
path = tmp_path / "window_flags.py"
|
||||||
|
path.write_text("flag = Qt.WindowType.Widget\nlegacy = Qt.Widget\n", encoding="utf-8")
|
||||||
|
assert _unscoped_enum_references(path) == ["Qt.Widget"]
|
||||||
|
|
||||||
|
|
||||||
def test_the_controller_interface_is_qt_free():
|
def test_the_controller_interface_is_qt_free():
|
||||||
"""The controller and its guards stay importable from a headless process.
|
"""The controller and its guards stay importable from a headless process.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Live operator feedback must preserve sample validity and UV orientation."""
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from orisys_app_shared.acquisition.models import SdkPacket
|
||||||
|
|
||||||
|
|
||||||
|
class Worker:
|
||||||
|
is_running = True
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.sinks = []
|
||||||
|
|
||||||
|
def add_sink(self, sink):
|
||||||
|
self.sinks.append(sink)
|
||||||
|
|
||||||
|
def remove_sink(self, sink):
|
||||||
|
self.sinks.remove(sink)
|
||||||
|
|
||||||
|
|
||||||
|
def packet(**values):
|
||||||
|
image = np.full((64, 80, 3), (10, 20, 30), dtype=np.uint8)
|
||||||
|
base = SdkPacket(raw_frame=image.copy(), processed_frame=image,
|
||||||
|
timestamp=100, mono_t=1, fps=30, process_ms=1,
|
||||||
|
session_id=1, frame_id=1, normal_force_raw=12,
|
||||||
|
shear_x_raw=2, shear_y_raw=-3)
|
||||||
|
return replace(base, **values)
|
||||||
|
|
||||||
|
|
||||||
|
def test_force_feedback_consumes_every_sample_and_breaks_invalid_gaps(qt_app):
|
||||||
|
from orisys_autocal.ui.live_force_view import LiveForceView
|
||||||
|
worker = Worker()
|
||||||
|
view = LiveForceView(worker)
|
||||||
|
for item in (packet(), packet(frame_id=2, mono_t=1.1, stale_forces=True),
|
||||||
|
packet(frame_id=3, mono_t=1.2, normal_force_raw=20)):
|
||||||
|
worker.sinks[0](item)
|
||||||
|
view.refresh()
|
||||||
|
assert view.history.sample_count == 3
|
||||||
|
x, y = view.curves[0].getData()
|
||||||
|
np.testing.assert_allclose(x, [0, .1, .2])
|
||||||
|
assert y[0] == 12 and np.isnan(y[1]) and y[2] == 20
|
||||||
|
assert "20 raw" in view.readings.text()
|
||||||
|
assert "20.000 N" not in view.readings.text()
|
||||||
|
view.on_packet(packet(session_id=2, normal_force=4, force_calibrated=True,
|
||||||
|
calibrated_axes=("fn",)))
|
||||||
|
view.refresh()
|
||||||
|
assert view.history.sample_count == 1
|
||||||
|
assert "4.000 N" in view.readings.text()
|
||||||
|
assert "2.000 N" not in view.readings.text()
|
||||||
|
worker.is_running = False
|
||||||
|
view.refresh()
|
||||||
|
assert view.state.text()
|
||||||
|
view.shutdown()
|
||||||
|
assert not worker.sinks
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_toggles_redraw_same_frame_without_mutating_measurements(qt_app, monkeypatch):
|
||||||
|
from orisys_autocal.ui import finger_source_panel
|
||||||
|
panel = finger_source_panel.FingerSourcePanel()
|
||||||
|
item = packet(flow=np.full((64, 80, 2), 2., dtype=float),
|
||||||
|
depth=np.full((64, 80), .5), valid_mask=np.ones((64, 80), bool),
|
||||||
|
flow_valid=True, flow_frame_id=1, depth_valid=True, depth_frame_id=1)
|
||||||
|
original = item.processed_frame.copy()
|
||||||
|
panel._last_packet = item
|
||||||
|
frames = []
|
||||||
|
monkeypatch.setattr(panel, "_show_frame", lambda frame: frames.append(frame.copy()))
|
||||||
|
panel._redraw()
|
||||||
|
np.testing.assert_array_equal(frames[-1], original)
|
||||||
|
panel.background_check.setChecked(False)
|
||||||
|
assert not np.array_equal(frames[-1], original)
|
||||||
|
solid = frames[-1]
|
||||||
|
panel.depth_check.setChecked(True)
|
||||||
|
assert not np.array_equal(frames[-1], solid)
|
||||||
|
depth = frames[-1]
|
||||||
|
panel.arrows_check.setChecked(True)
|
||||||
|
assert not np.array_equal(frames[-1], depth)
|
||||||
|
assert frames[-1].shape == original.shape
|
||||||
|
np.testing.assert_array_equal(item.processed_frame, original)
|
||||||
|
assert np.all(item.flow == 2)
|
||||||
|
panel._last_packet = replace(item, flow_valid=False, depth_frame_id=0)
|
||||||
|
panel._redraw()
|
||||||
|
assert panel.display_note.text()
|
||||||
|
np.testing.assert_array_equal(frames[-1], solid)
|
||||||
|
panel.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def test_depth_range_adapts_per_frame_and_excludes_invalid_uv_pixels(qt_app, monkeypatch):
|
||||||
|
from orisys_autocal.ui import finger_source_panel
|
||||||
|
|
||||||
|
panel = finger_source_panel.FingerSourcePanel()
|
||||||
|
panel.background_check.setChecked(False)
|
||||||
|
panel.depth_check.setChecked(True)
|
||||||
|
mask = np.zeros((64, 80), dtype=bool)
|
||||||
|
mask[10:30, 20:40] = True
|
||||||
|
depth = np.full((64, 80), 1e6)
|
||||||
|
depth[mask] = np.linspace(2, 4, mask.sum())
|
||||||
|
depth[10, 20] = np.nan
|
||||||
|
frames = []
|
||||||
|
monkeypatch.setattr(panel, "_show_frame", lambda frame: frames.append(frame.copy()))
|
||||||
|
panel._last_packet = packet(depth=depth, valid_mask=mask,
|
||||||
|
depth_valid=True, depth_frame_id=1)
|
||||||
|
panel._redraw()
|
||||||
|
first = frames[-1]
|
||||||
|
assert "4" in panel.display_note.text()
|
||||||
|
panel._last_packet = replace(panel._last_packet, depth=depth * 10 + 5)
|
||||||
|
panel._redraw()
|
||||||
|
# A different numerical range spans the same colour ramp in the next frame.
|
||||||
|
np.testing.assert_array_equal(frames[-1], first)
|
||||||
|
assert "45" in panel.display_note.text()
|
||||||
|
panel._last_packet = replace(panel._last_packet, depth=np.full_like(depth, np.nan))
|
||||||
|
panel._redraw()
|
||||||
|
assert panel.display_note.text()
|
||||||
|
assert not np.array_equal(frames[-1], first)
|
||||||
|
panel.shutdown()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,757 @@
|
|||||||
|
"""The capture page end to end: one queue, one primary button, one message line.
|
||||||
|
|
||||||
|
These are interface tests in the literal sense: they click the widgets an operator
|
||||||
|
clicks, in the order the controller offers them, and check the files the session
|
||||||
|
wrote. The platform move is stubbed at the window's move planner (the real move is
|
||||||
|
hardware-adjacent and already covered elsewhere); everything else runs as shipped.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from orisys_autocal.sensor.uv_flat import FlatSurface
|
||||||
|
|
||||||
|
POINT = (4.872, 5.444, 16.176)
|
||||||
|
NORMAL = (0.595, 0.673, 0.439)
|
||||||
|
|
||||||
|
#: The only direct Campaign calls the frozen contract keeps in ``ui/``: the
|
||||||
|
#: recorder's lifecycle is not a workflow decision and not rendered data.
|
||||||
|
_ALLOWED_CAMPAIGN_MEMBERS = frozenset({"attach_capture", "flush_close"})
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_accesses(source: str) -> list[str]:
|
||||||
|
"""Every ``campaign.<member>`` access in one module's source, in order."""
|
||||||
|
return [match.group(1) for match in re.finditer(r"campaign\.(\w+)", source)]
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_offenders(directory) -> list[str]:
|
||||||
|
"""UI modules reaching past the controller for campaign state or actions."""
|
||||||
|
offenders = []
|
||||||
|
for path in sorted(directory.rglob("*.py")):
|
||||||
|
for member in _campaign_accesses(path.read_text(encoding="utf-8")):
|
||||||
|
if member not in _ALLOWED_CAMPAIGN_MEMBERS:
|
||||||
|
offenders.append(f"{path.name}: campaign.{member}")
|
||||||
|
return offenders
|
||||||
|
|
||||||
|
|
||||||
|
def _flat() -> FlatSurface:
|
||||||
|
"""A stand-in rasterised surface: every pixel is the same measured point."""
|
||||||
|
size = 8
|
||||||
|
positions = np.tile(np.asarray(POINT, dtype=np.float64), (size, size, 1))
|
||||||
|
normals = np.tile(np.asarray(NORMAL, dtype=np.float64), (size, size, 1))
|
||||||
|
return FlatSurface(
|
||||||
|
size_hw=(size, size),
|
||||||
|
image_rgb=np.zeros((size, size, 3), dtype=np.uint8),
|
||||||
|
positions_mm=positions,
|
||||||
|
normals=normals,
|
||||||
|
face_index=np.zeros((size, size), dtype=np.int32),
|
||||||
|
uv_min=(0.0, 0.0),
|
||||||
|
uv_max=(1.0, 1.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pump(app, seconds: float = 0.3) -> None:
|
||||||
|
deadline = time.monotonic() + seconds
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
app.processEvents()
|
||||||
|
time.sleep(0.005)
|
||||||
|
|
||||||
|
|
||||||
|
def _page(
|
||||||
|
window,
|
||||||
|
qt_app,
|
||||||
|
tmp_path,
|
||||||
|
*,
|
||||||
|
max_force: str = "20",
|
||||||
|
align: bool = True,
|
||||||
|
flat: bool = True,
|
||||||
|
):
|
||||||
|
"""A capture page whose readiness passes in simulation mode.
|
||||||
|
|
||||||
|
``align=True`` (the default) makes the page behave as it does with a platform
|
||||||
|
attached: the point must be aligned before a session may start, so the tests
|
||||||
|
that are about the align step keep covering it. ``align=False`` is the page as
|
||||||
|
it is with nothing connected, and ``flat=False`` leaves out the calibration
|
||||||
|
bundle too.
|
||||||
|
"""
|
||||||
|
window.resize(1440, 900)
|
||||||
|
window.show()
|
||||||
|
window.workspace.set_current_key("sensor")
|
||||||
|
qt_app.processEvents()
|
||||||
|
page = window.calibration_panel
|
||||||
|
page.settings.simulate.setChecked(True)
|
||||||
|
page.settings.sensor_sn.setText("SN1")
|
||||||
|
page.settings.max_force.setText(max_force)
|
||||||
|
page.settings.output_path.setText(str(tmp_path))
|
||||||
|
if align:
|
||||||
|
# The page treats a simulation as needing no move; these tests are about
|
||||||
|
# the step that does, so they ask for the platform-attached behaviour.
|
||||||
|
page._simulating = lambda: False # noqa: SLF001 - the seam under test
|
||||||
|
if flat:
|
||||||
|
page.set_flat(_flat(), "bundle")
|
||||||
|
# A hold of a few hundredths of a second keeps the test fast; the session
|
||||||
|
# contract is the same.
|
||||||
|
page.hold.setRange(0.05, 30)
|
||||||
|
page.hold.setValue(0.05)
|
||||||
|
page.levels.setText("5")
|
||||||
|
page.repeats.setValue(1)
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
def _started_page(window, qt_app, tmp_path, *, sites=("P1", "P2")):
|
||||||
|
"""Two points added through the page, first aligned, session started."""
|
||||||
|
_stub_moves(window, succeeds=True)
|
||||||
|
page = _page(window, qt_app, tmp_path)
|
||||||
|
for index, site_id in enumerate(sites):
|
||||||
|
_add_point(page, site_id, index, index, qt_app)
|
||||||
|
_click_primary(page, qt_app) # align the first point
|
||||||
|
assert page.controller.primary_action().key == "start_session"
|
||||||
|
_click_primary(page, qt_app) # start the session
|
||||||
|
assert page.controller.poll()["phase"] == "load"
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_moves(window, *, succeeds: bool = True) -> list[dict]:
|
||||||
|
"""Replace the window's move planner: record the plan, report the outcome."""
|
||||||
|
plans: list[dict] = []
|
||||||
|
|
||||||
|
def fake_start(plan, title, *, report=None):
|
||||||
|
plans.append({"title": title, "roles": [step.role for step in plan.steps]})
|
||||||
|
if succeeds:
|
||||||
|
window.calibration_panel.alignment_succeeded()
|
||||||
|
else:
|
||||||
|
window.calibration_panel.alignment_failed("refused by the test")
|
||||||
|
|
||||||
|
window._start_move = fake_start # noqa: SLF001 - the seam under test is the page
|
||||||
|
return plans
|
||||||
|
|
||||||
|
|
||||||
|
def _click_primary(page, qt_app, timeout: float = 5.0) -> str:
|
||||||
|
"""Press the primary button once, after filling whatever it needs first."""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
qt_app.processEvents()
|
||||||
|
action = page.controller.primary_action()
|
||||||
|
if action.enabled and not (
|
||||||
|
action.key == "confirm_hold" and page.controller.poll()["remaining_s"] > 0
|
||||||
|
):
|
||||||
|
# The page redraws on its own timer; read the button the operator would
|
||||||
|
# click, not just the controller's answer, or a fast step races the redraw.
|
||||||
|
page._refresh() # noqa: SLF001 - the widget state is what is under test
|
||||||
|
if not page.primary_button.isEnabled():
|
||||||
|
_pump(qt_app, 0.05)
|
||||||
|
continue
|
||||||
|
if action.key == "confirm_target":
|
||||||
|
target = page.controller.model().progress.get("target_n") or 0.0
|
||||||
|
page.reference.setText(f"{float(target):g}")
|
||||||
|
page.primary_button.click()
|
||||||
|
_pump(qt_app, 0.1)
|
||||||
|
return action.key
|
||||||
|
_pump(qt_app, 0.05)
|
||||||
|
raise AssertionError(
|
||||||
|
f"the primary action never became available: "
|
||||||
|
f"{page.controller.primary_action()} / {page.message.text()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_point(page, site_id: str, u: int, v: int, qt_app) -> None:
|
||||||
|
page.u_spin.setValue(u)
|
||||||
|
page.v_spin.setValue(v)
|
||||||
|
page.site_id.setText(site_id)
|
||||||
|
page.add_point_button.click()
|
||||||
|
_pump(qt_app, 0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_point_capture_walkthrough_in_simulation(make_window, qt_app, tmp_path):
|
||||||
|
"""Align, start, load every step, hold, liftoff, align, and finish - by clicking."""
|
||||||
|
window = make_window(move_confirm=False)
|
||||||
|
plans = _stub_moves(window, succeeds=True)
|
||||||
|
page = _page(window, qt_app, tmp_path)
|
||||||
|
_add_point(page, "P1", 0, 0, qt_app)
|
||||||
|
_add_point(page, "P2", 1, 1, qt_app)
|
||||||
|
assert [row.site_id for row in page.controller.model().sites] == ["P1", "P2"]
|
||||||
|
|
||||||
|
# The first point must be aligned before the session may start.
|
||||||
|
assert page.controller.primary_action().key == "align_point"
|
||||||
|
assert t("cap.action.align") == page.primary_button.text()
|
||||||
|
_click_primary(page, qt_app)
|
||||||
|
# The press axis is held because its relation to a contact height is not
|
||||||
|
# calibrated on the test bench; the four movable roles still travel together.
|
||||||
|
assert plans and plans[-1]["roles"] == ["yaw", "pitch", "x", "y"]
|
||||||
|
assert page.controller.primary_action().key == "start_session"
|
||||||
|
assert t("cap.action.start") == page.primary_button.text()
|
||||||
|
|
||||||
|
_click_primary(page, qt_app) # start the session
|
||||||
|
state = page.controller.poll()
|
||||||
|
assert state["phase"] == "load"
|
||||||
|
# Starting a capture does not switch the workspace page.
|
||||||
|
assert window.workspace.current_key() == "sensor"
|
||||||
|
# The recording options are locked once the session exists.
|
||||||
|
assert not page.settings.video_check.isEnabled()
|
||||||
|
assert not page.settings.locked_label.isHidden()
|
||||||
|
assert page.settings.locked_label.text() == t("cap.recording.locked")
|
||||||
|
|
||||||
|
seen: list[str] = []
|
||||||
|
for _ in range(60):
|
||||||
|
action = page.controller.primary_action()
|
||||||
|
seen.append(action.key)
|
||||||
|
if action.key == "finish_session":
|
||||||
|
_click_primary(page, qt_app)
|
||||||
|
break
|
||||||
|
_click_primary(page, qt_app)
|
||||||
|
else: # pragma: no cover - a stuck walkthrough is the failure to report
|
||||||
|
raise AssertionError(f"the session never finished: {seen[-6:]}")
|
||||||
|
# The hold ends by itself at its planned time, so the button may or may not be
|
||||||
|
# caught showing "confirm hold" between two ticks; the order of the steps is
|
||||||
|
# what matters.
|
||||||
|
assert seen[0] == "confirm_target"
|
||||||
|
assert seen.count("confirm_target") >= 3
|
||||||
|
# Two points, so the second one needs its own alignment between them.
|
||||||
|
assert "align_point" in seen
|
||||||
|
assert "confirm_liftoff" in seen
|
||||||
|
|
||||||
|
directory = page._session_directory() # noqa: SLF001 - the recorded folder under test
|
||||||
|
assert directory is not None
|
||||||
|
manifest = json.loads((directory / "session.json").read_text(encoding="utf-8"))
|
||||||
|
assert manifest["status"] == "complete"
|
||||||
|
assert [site["site_id"] for site in manifest["sites"]] == ["P1", "P2"]
|
||||||
|
assert [trial["status"] for trial in manifest["trials"]] == ["complete", "complete"]
|
||||||
|
assert (directory / "stable_means.csv").is_file()
|
||||||
|
# A simulated run writes no video; that is the recorded state, not a failure.
|
||||||
|
assert page.controller.poll()["capture_state"] == "not_recorded"
|
||||||
|
# The end of the session publishes the candidate summary (P1's flat copy).
|
||||||
|
deadline = time.monotonic() + 20.0
|
||||||
|
while not (directory / "summary-latest.json").is_file() and time.monotonic() < deadline:
|
||||||
|
_pump(qt_app, 0.05)
|
||||||
|
assert (directory / "summary-latest.json").is_file()
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_simulation_runs_with_nothing_connected(make_window, qt_app, tmp_path):
|
||||||
|
"""No bundle, no platform, no camera: type two points, start, finish, go again."""
|
||||||
|
window = make_window(move_confirm=False)
|
||||||
|
plans = _stub_moves(window, succeeds=True)
|
||||||
|
page = _page(window, qt_app, tmp_path, align=False, flat=False)
|
||||||
|
_add_point(page, "P1", 0, 0, qt_app)
|
||||||
|
_add_point(page, "P2", 3, 4, qt_app)
|
||||||
|
assert [row.site_id for row in page.controller.model().sites] == ["P1", "P2"]
|
||||||
|
|
||||||
|
# Every ready check passes without a bundle, a bus or an input frame.
|
||||||
|
assert all(check.ok for check in page.controller.readiness()), [
|
||||||
|
check for check in page.controller.readiness() if not check.ok
|
||||||
|
]
|
||||||
|
# No align step stands between the queue and the session.
|
||||||
|
assert page.controller.primary_action().key == "start_session"
|
||||||
|
assert t("cap.action.start") == page.primary_button.text()
|
||||||
|
|
||||||
|
_click_primary(page, qt_app) # start the session
|
||||||
|
assert page.controller.poll()["phase"] == "load"
|
||||||
|
|
||||||
|
seen: list[str] = []
|
||||||
|
for _ in range(60):
|
||||||
|
action = page.controller.primary_action()
|
||||||
|
seen.append(action.key)
|
||||||
|
_click_primary(page, qt_app)
|
||||||
|
if action.key == "finish_session":
|
||||||
|
break
|
||||||
|
else: # pragma: no cover - a stuck walkthrough is the failure to report
|
||||||
|
raise AssertionError(f"the session never finished: {seen[-6:]}")
|
||||||
|
assert "align_point" not in seen
|
||||||
|
assert "confirm_liftoff" in seen
|
||||||
|
assert plans == [] # nothing asked the window to move an axis
|
||||||
|
|
||||||
|
directory = page._session_directory() # noqa: SLF001
|
||||||
|
manifest = json.loads((directory / "session.json").read_text(encoding="utf-8"))
|
||||||
|
assert manifest["simulate"] is True
|
||||||
|
assert manifest["status"] == "complete"
|
||||||
|
assert {site["pose"]["normal_aligned_to"] for site in manifest["sites"]} == {
|
||||||
|
"simulated"
|
||||||
|
}
|
||||||
|
# Closing out leaves the page ready to run the same queue again.
|
||||||
|
assert page.controller.poll()["phase"] == "idle"
|
||||||
|
assert page.controller.primary_action().key == "start_session"
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_refused_alignment_keeps_start_disabled(make_window, qt_app, tmp_path):
|
||||||
|
"""A move the operator did not confirm must never unlock "start capture"."""
|
||||||
|
window = make_window(move_confirm=False)
|
||||||
|
_stub_moves(window, succeeds=False)
|
||||||
|
page = _page(window, qt_app, tmp_path)
|
||||||
|
_add_point(page, "P1", 0, 0, qt_app)
|
||||||
|
|
||||||
|
_click_primary(page, qt_app) # align: the window refuses the move
|
||||||
|
assert page.controller.primary_action().key == "align_point"
|
||||||
|
assert page.controller.primary_action().key != "start_session"
|
||||||
|
assert t("cap.action.start") != page.primary_button.text()
|
||||||
|
assert t("cap.message.align_failed", detail="refused by the test") in page.message.text()
|
||||||
|
|
||||||
|
# A retry whose move is confirmed is what unlocks the session.
|
||||||
|
_stub_moves(window, succeeds=True)
|
||||||
|
_click_primary(page, qt_app)
|
||||||
|
assert page.controller.primary_action().key == "start_session"
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_capture_page_never_reaches_past_the_controller():
|
||||||
|
"""P4 exit condition: ``ui/`` renders controller output, not campaign state.
|
||||||
|
|
||||||
|
The guard is a member-access scan rather than a fixed string list: any new
|
||||||
|
``campaign.<member>`` in a UI module is a finding unless it is one of the two
|
||||||
|
recorder-lifecycle methods the frozen contract keeps (interfaces 1.11). That
|
||||||
|
covers ``phase``, ``sites``, ``_*``, every queue edit and every flow action —
|
||||||
|
including ones added later.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ui = Path(__file__).resolve().parents[1] / "src" / "orisys_autocal" / "ui"
|
||||||
|
assert _campaign_offenders(ui) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_campaign_guard_catches_a_regression():
|
||||||
|
"""The guard above must fail on a forbidden access, not merely pass by luck."""
|
||||||
|
assert _campaign_accesses("x = self.campaign.sites\n") == ["sites"]
|
||||||
|
assert _campaign_accesses("if self.campaign.phase == 'idle':") == ["phase"]
|
||||||
|
assert _campaign_accesses("self.campaign._skipped") == ["_skipped"]
|
||||||
|
assert _campaign_accesses("self.campaign.add_solved_site(a)") == ["add_solved_site"]
|
||||||
|
assert _campaign_accesses("self.campaign.confirm_reference(1)") == ["confirm_reference"]
|
||||||
|
assert _campaign_accesses("self.campaign.attach_capture(w)") == ["attach_capture"]
|
||||||
|
for member in (
|
||||||
|
"phase",
|
||||||
|
"sites",
|
||||||
|
"_skipped",
|
||||||
|
"add_solved_site",
|
||||||
|
"update_site",
|
||||||
|
"remove_site",
|
||||||
|
"move_site",
|
||||||
|
"store_solved_pose",
|
||||||
|
"set_force_rotation",
|
||||||
|
"confirm_reference",
|
||||||
|
"complete_hold",
|
||||||
|
"stop",
|
||||||
|
"remeasure_step",
|
||||||
|
"skip_current_point",
|
||||||
|
):
|
||||||
|
assert member not in _ALLOWED_CAMPAIGN_MEMBERS, member
|
||||||
|
|
||||||
|
|
||||||
|
def test_unavailable_codecs_are_disabled_with_their_reason(make_window):
|
||||||
|
"""A codec this machine cannot open is refused here, never silently swapped."""
|
||||||
|
window = make_window()
|
||||||
|
settings = window.calibration_panel.settings
|
||||||
|
settings.set_availability({"mp4v": True, "avc1": False, "H264": False})
|
||||||
|
model = settings.codec.model()
|
||||||
|
for index in range(settings.codec.count()):
|
||||||
|
item = model.item(index)
|
||||||
|
codec = settings.codec.itemData(index)
|
||||||
|
assert item.isEnabled() is (codec == "mp4v")
|
||||||
|
assert settings.codec.currentData() == "mp4v"
|
||||||
|
for codec in ("avc1", "H264"):
|
||||||
|
assert t("cap.recording.unavailable", codec=codec) in settings.codec_reason.text()
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_size_estimate_warns_below_twice_the_planned_volume(make_window, monkeypatch):
|
||||||
|
from orisys_autocal.ui import capture_settings_dialog as module
|
||||||
|
|
||||||
|
window = make_window()
|
||||||
|
settings = window.calibration_panel.settings
|
||||||
|
settings.set_availability({"mp4v": True, "avc1": False, "H264": False})
|
||||||
|
settings.video_check.setChecked(True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
module.shutil,
|
||||||
|
"disk_usage",
|
||||||
|
lambda path: SimpleNamespace(total=0, used=0, free=1_000_000_000),
|
||||||
|
)
|
||||||
|
# One hour of mp4v at the measured bench bitrate is far more than 1 GB.
|
||||||
|
settings.set_plan(3600.0)
|
||||||
|
assert t("cap.recording.disk_short") in settings.estimate.text()
|
||||||
|
assert "12.15 GB" in settings.estimate.text()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
module.shutil,
|
||||||
|
"disk_usage",
|
||||||
|
lambda path: SimpleNamespace(total=0, used=0, free=10**14),
|
||||||
|
)
|
||||||
|
settings.set_plan(3601.0)
|
||||||
|
assert t("cap.recording.disk_short") not in settings.estimate.text()
|
||||||
|
# Recording no video makes the size question disappear, not a zero.
|
||||||
|
settings.video_check.setChecked(False)
|
||||||
|
assert settings.estimate.text().startswith(t("cap.recording.off"))
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_session_recorded_without_video_says_so_in_the_review(qt_app, tmp_path):
|
||||||
|
from evidence_helpers import multipoint_session
|
||||||
|
from orisys_autocal.session_review import review_session
|
||||||
|
from orisys_autocal.ui.record_overview_dialog import RecordOverviewDialog
|
||||||
|
|
||||||
|
root = multipoint_session(
|
||||||
|
tmp_path / "quiet",
|
||||||
|
sites=("P1",),
|
||||||
|
repeats=1,
|
||||||
|
levels=(5.0,),
|
||||||
|
recording_block={"video": False, "codec": "mp4v", "lossless_frames": False},
|
||||||
|
)
|
||||||
|
review = review_session(root)
|
||||||
|
assert review["recording"]["video"] is False
|
||||||
|
dialog = RecordOverviewDialog(root, review=review)
|
||||||
|
try:
|
||||||
|
assert t("overview.video_not_recorded") in dialog.identity.text()
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
# A session that kept its video says nothing about a missing one.
|
||||||
|
with_video = multipoint_session(
|
||||||
|
tmp_path / "loud", sites=("P1",), repeats=1, levels=(5.0,)
|
||||||
|
)
|
||||||
|
dialog = RecordOverviewDialog(with_video, review=review_session(with_video))
|
||||||
|
try:
|
||||||
|
assert t("overview.video_not_recorded") not in dialog.identity.text()
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_page_refuses_start_until_the_ready_checks_pass(make_window, qt_app):
|
||||||
|
"""Every ✗ check names itself and blocks the start with a stated reason."""
|
||||||
|
window = make_window(move_confirm=False)
|
||||||
|
page = window.calibration_panel
|
||||||
|
window.workspace.set_current_key("sensor")
|
||||||
|
qt_app.processEvents()
|
||||||
|
action = page.controller.primary_action()
|
||||||
|
reason = t(action.blocked_reason)
|
||||||
|
assert action.enabled is False
|
||||||
|
assert page.primary_reason.text() == reason
|
||||||
|
checks = {check.key: check for check in page.controller.readiness()}
|
||||||
|
assert checks["max_force"].ok is False
|
||||||
|
assert checks["sensor_sn"].ok is False
|
||||||
|
for key in ("calibration_bundle", "input_frames", "bus_ready"):
|
||||||
|
assert checks[key].ok is False
|
||||||
|
assert page.ready_labels[key].text().startswith("✗")
|
||||||
|
# Filling one fact turns only that chip over.
|
||||||
|
page.settings.sensor_sn.setText("SN1")
|
||||||
|
checks = {check.key: check for check in page.controller.readiness()}
|
||||||
|
assert checks["sensor_sn"].ok is True
|
||||||
|
assert page.controller.primary_action().enabled is False
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_point_needs_a_maximum_force_and_the_controller_is_told(
|
||||||
|
make_window, qt_app, tmp_path
|
||||||
|
):
|
||||||
|
"""The queue edit is the controller's, including its maximum-force refusal."""
|
||||||
|
window = make_window(move_confirm=False)
|
||||||
|
page = _page(window, qt_app, tmp_path, max_force="")
|
||||||
|
page._refresh() # noqa: SLF001 - the page's own render is the unit under test
|
||||||
|
page.add_point_button.click()
|
||||||
|
_pump(qt_app, 0.1)
|
||||||
|
assert page.controller.model().sites == []
|
||||||
|
assert page.message.text().endswith(t("cap.block.no_max_force"))
|
||||||
|
|
||||||
|
# The dialog value reaches the controller through set_max_force, not a rebuild.
|
||||||
|
page.settings.max_force.setText("20")
|
||||||
|
page._refresh() # noqa: SLF001
|
||||||
|
checks = {check.key: check for check in page.controller.readiness()}
|
||||||
|
assert checks["max_force"].ok is True
|
||||||
|
_add_point(page, "P1", 0, 0, qt_app)
|
||||||
|
assert [row.site_id for row in page.controller.model().sites] == ["P1"]
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_skipped_point_is_rendered_from_the_controller_row(
|
||||||
|
make_window, qt_app, tmp_path
|
||||||
|
):
|
||||||
|
"""The skip mark is the controller's; the page only draws ``SiteRow.skipped``."""
|
||||||
|
window = make_window(move_confirm=False)
|
||||||
|
page = _started_page(window, qt_app, tmp_path)
|
||||||
|
assert page.controller.model().sites[0].skipped is False
|
||||||
|
|
||||||
|
page._refresh() # noqa: SLF001 - render the freshly enabled button
|
||||||
|
page.skip_button.click()
|
||||||
|
_pump(qt_app, 0.2)
|
||||||
|
|
||||||
|
assert page.controller.model().sites[0].skipped is True
|
||||||
|
assert page.queue_table.item(0, 4).text() == t("cap.queue.state_skipped")
|
||||||
|
assert page.message.text().endswith(t("cap.message.skipped", site="P1"))
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_stop_button_leaves_the_reason_to_the_controller(
|
||||||
|
make_window, qt_app, tmp_path
|
||||||
|
):
|
||||||
|
"""The stop button passes no reason; the controller records the default one."""
|
||||||
|
window = make_window(move_confirm=False)
|
||||||
|
page = _started_page(window, qt_app, tmp_path, sites=("P1",))
|
||||||
|
directory = page._session_directory() # noqa: SLF001
|
||||||
|
page._refresh() # noqa: SLF001 - render the freshly enabled button
|
||||||
|
page.stop_button.click()
|
||||||
|
_pump(qt_app, 0.2)
|
||||||
|
|
||||||
|
manifest = json.loads((directory / "session.json").read_text(encoding="utf-8"))
|
||||||
|
assert manifest["reason"] == "operator stopped"
|
||||||
|
assert page.controller.primary_action().key == "finish_session"
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_closing_the_window_records_the_application_reason(
|
||||||
|
make_window, qt_app, tmp_path
|
||||||
|
):
|
||||||
|
"""Window shutdown names its own reason, so the data can tell the two apart."""
|
||||||
|
window = make_window(move_confirm=False)
|
||||||
|
page = _started_page(window, qt_app, tmp_path, sites=("P1",))
|
||||||
|
directory = page._session_directory() # noqa: SLF001
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
manifest = json.loads((directory / "session.json").read_text(encoding="utf-8"))
|
||||||
|
assert manifest["reason"] == "application closed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_updating_a_point_retakes_it_and_keeps_its_queue_position(
|
||||||
|
make_window, qt_app, tmp_path
|
||||||
|
):
|
||||||
|
"""A retaken point goes through the controller and stays where the operator put it."""
|
||||||
|
window = make_window(move_confirm=False)
|
||||||
|
page = _page(window, qt_app, tmp_path)
|
||||||
|
for index, site_id in enumerate(("P1", "P2", "P3")):
|
||||||
|
_add_point(page, site_id, index, index, qt_app)
|
||||||
|
page.queue_table.selectRow(1) # the operator's row selection
|
||||||
|
qt_app.processEvents()
|
||||||
|
assert page._selected_site == "P2" # noqa: SLF001
|
||||||
|
page.u_spin.setValue(5)
|
||||||
|
page.v_spin.setValue(5)
|
||||||
|
page.site_id.setText("P2b")
|
||||||
|
page.update_point_button.click()
|
||||||
|
_pump(qt_app, 0.2)
|
||||||
|
|
||||||
|
rows = page.controller.model().sites
|
||||||
|
assert [row.site_id for row in rows] == ["P1", "P2b", "P3"]
|
||||||
|
assert rows[1].site_id == "P2b"
|
||||||
|
assert rows[1].uv_px is not None
|
||||||
|
assert page._selected_site == "P2b" # noqa: SLF001
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------- P5: geometry, sheet, reach
|
||||||
|
|
||||||
|
|
||||||
|
def _drive_to_liftoff(page) -> None:
|
||||||
|
"""Finish the first point's repeat directly, leaving the session at liftoff."""
|
||||||
|
campaign = page.controller.campaign
|
||||||
|
while campaign.phase == "load":
|
||||||
|
target = campaign.progress()["target_n"]
|
||||||
|
campaign.confirm_reference(target)
|
||||||
|
campaign.complete_hold(
|
||||||
|
now_ns=campaign._hold_started_ns + int(campaign._hold_s * 1e9)
|
||||||
|
)
|
||||||
|
assert campaign.phase == "liftoff"
|
||||||
|
|
||||||
|
|
||||||
|
def test_changing_the_mounting_resolves_every_queued_pose(make_window, qt_app, tmp_path):
|
||||||
|
"""stackChanged re-solves the queue and writes the fresh poses back while idle."""
|
||||||
|
window = make_window(move_confirm=False)
|
||||||
|
page = _page(window, qt_app, tmp_path)
|
||||||
|
_add_point(page, "P1", 0, 0, qt_app)
|
||||||
|
_add_point(page, "P2", 1, 1, qt_app)
|
||||||
|
before = {row.site_id: row.pose["z"] for row in page.controller.model().sites}
|
||||||
|
|
||||||
|
spin = window.finger_panel.sensor_z_spin
|
||||||
|
spin.setValue(spin.value() + 4.0) # the operator changes the mounting height
|
||||||
|
_pump(qt_app, 0.2)
|
||||||
|
|
||||||
|
rows = page.controller.model().sites
|
||||||
|
assert all(row.reachable for row in rows)
|
||||||
|
for row in rows:
|
||||||
|
assert row.pose["z"] != pytest.approx(before[row.site_id]), row.site_id
|
||||||
|
fresh = page._solve_uv(*row.uv_px)[2] # noqa: SLF001 - the solver's answer
|
||||||
|
assert row.pose["z"] == pytest.approx(fresh["z"])
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unreachable_point_is_marked_and_cannot_be_aligned(
|
||||||
|
make_window, qt_app, tmp_path
|
||||||
|
):
|
||||||
|
"""A travel limit that makes a point unreachable disables its align, visibly."""
|
||||||
|
window = make_window(move_confirm=False)
|
||||||
|
emitted = _stub_moves(window, succeeds=True)
|
||||||
|
page = _page(window, qt_app, tmp_path)
|
||||||
|
_add_point(page, "P1", 0, 0, qt_app)
|
||||||
|
assert page.controller.model().sites[0].reachable is True
|
||||||
|
emitted.clear()
|
||||||
|
|
||||||
|
window.finger_panel.stack.yaw_limit_deg = 0.0 # the stage can no longer turn
|
||||||
|
window.finger_panel.stackChanged.emit()
|
||||||
|
_pump(qt_app, 0.2)
|
||||||
|
|
||||||
|
row = page.controller.model().sites[0]
|
||||||
|
assert row.reachable is False
|
||||||
|
assert page.queue_table.item(0, 3).text() == t("cap.queue.unreachable")
|
||||||
|
action = page.controller.primary_action()
|
||||||
|
assert (action.key, action.enabled) == ("align_point", False)
|
||||||
|
assert action.blocked_reason == "cap.block.no_pose"
|
||||||
|
page._align() # noqa: SLF001 - a direct call must be refused too
|
||||||
|
assert not emitted
|
||||||
|
assert t("cap.block.pose_unreachable") in page.message.text()
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_frozen_queue_is_not_rewritten_when_the_mounting_changes(
|
||||||
|
make_window, qt_app, tmp_path
|
||||||
|
):
|
||||||
|
"""Between trials the queue is frozen: reachability is shown, poses are not stored."""
|
||||||
|
window = make_window(move_confirm=False)
|
||||||
|
page = _started_page(window, qt_app, tmp_path)
|
||||||
|
_drive_to_liftoff(page)
|
||||||
|
page._refresh() # noqa: SLF001
|
||||||
|
recorded = page.controller.model().sites[1].pose["z"]
|
||||||
|
|
||||||
|
spin = window.finger_panel.sensor_z_spin
|
||||||
|
spin.setValue(spin.value() + 4.0)
|
||||||
|
_pump(qt_app, 0.2)
|
||||||
|
|
||||||
|
rows = page.controller.model().sites
|
||||||
|
assert rows[1].pose["z"] == pytest.approx(recorded) # nothing written back
|
||||||
|
assert page._live_reachable # noqa: SLF001 - the display-only answer exists
|
||||||
|
fresh = page._solve_uv(*rows[1].uv_px)[2] # noqa: SLF001
|
||||||
|
assert page._live_reachable[rows[1].site_id] == bool( # noqa: SLF001
|
||||||
|
fresh["ok"] and fresh["in_range"]
|
||||||
|
)
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_point_sheet_matches_the_documented_fields(make_window, qt_app, tmp_path):
|
||||||
|
"""P5 exit: the operation table's fields are the data-format document's, in order."""
|
||||||
|
from orisys_autocal.sensor.uv_flat import mapping_sha256, uv_origin
|
||||||
|
from orisys_autocal.ui.site_export import OPERATION_TABLE_FIELDS, write_operation_table
|
||||||
|
|
||||||
|
document = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "docs"
|
||||||
|
/ "calibration-data-format.zh.md"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
# The document spells out the first two blocks; the rest are described as a group.
|
||||||
|
explicit = (
|
||||||
|
"order", "site_id", "u_px", "v_px",
|
||||||
|
"x_sensor_mm", "y_sensor_mm", "z_sensor_mm",
|
||||||
|
"x_platform_mm", "y_platform_mm", "z_platform_mm",
|
||||||
|
"yaw_deg", "pitch_deg",
|
||||||
|
"origin_raster_x_px", "origin_raster_y_px",
|
||||||
|
)
|
||||||
|
assert OPERATION_TABLE_FIELDS[: len(explicit)] == explicit
|
||||||
|
for field in explicit:
|
||||||
|
assert field in document, f"{field} is not named in the format document"
|
||||||
|
|
||||||
|
window = make_window(move_confirm=False)
|
||||||
|
page = _page(window, qt_app, tmp_path)
|
||||||
|
_add_point(page, "P1", 0, 0, qt_app)
|
||||||
|
_add_point(page, "P2", 1, 1, qt_app)
|
||||||
|
target = tmp_path / "points.csv"
|
||||||
|
write_operation_table(target, page.controller.model().sites, page._flat)
|
||||||
|
|
||||||
|
with target.open(encoding="utf-8-sig", newline="") as handle:
|
||||||
|
header = next(csv.reader(handle))
|
||||||
|
written = list(csv.DictReader(target.open(encoding="utf-8-sig", newline="")))
|
||||||
|
assert tuple(header) == OPERATION_TABLE_FIELDS
|
||||||
|
assert [row["site_id"] for row in written] == ["P1", "P2"]
|
||||||
|
origin = uv_origin(page._flat)
|
||||||
|
assert int(written[0]["origin_raster_x_px"]) == origin[0]
|
||||||
|
assert int(written[0]["origin_raster_y_px"]) == origin[1]
|
||||||
|
assert int(written[0]["raster_width_px"]) == page._flat.width
|
||||||
|
assert int(written[0]["raster_height_px"]) == page._flat.height
|
||||||
|
assert written[0]["u_positive"] == "right"
|
||||||
|
assert written[0]["v_positive"] == "up"
|
||||||
|
assert written[0]["mapping_sha256"] == mapping_sha256(page._flat)
|
||||||
|
for record, row in zip(written, page.controller.model().sites):
|
||||||
|
assert float(record["x_sensor_mm"]) == pytest.approx(row.position["x"])
|
||||||
|
assert float(record["x_platform_mm"]) == pytest.approx(row.pose["x"])
|
||||||
|
assert float(record["yaw_deg"]) == pytest.approx(row.pose["yaw"])
|
||||||
|
|
||||||
|
# A recorded coordinate that no longer matches the map refuses the sheet.
|
||||||
|
page.controller.campaign.sites[0].x += 0.5
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
write_operation_table(tmp_path / "invalid.csv", page.controller.model().sites, page._flat)
|
||||||
|
assert not (tmp_path / "invalid.csv").exists()
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_export_button_writes_the_sheet_and_reports_it(
|
||||||
|
make_window, qt_app, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
from orisys_autocal.ui import calibration_panel as module
|
||||||
|
|
||||||
|
window = make_window(move_confirm=False)
|
||||||
|
page = _page(window, qt_app, tmp_path)
|
||||||
|
_add_point(page, "P1", 0, 0, qt_app)
|
||||||
|
target = tmp_path / "sheet.csv"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
module.QFileDialog,
|
||||||
|
"getSaveFileName",
|
||||||
|
lambda *args, **kwargs: (str(target), ""),
|
||||||
|
)
|
||||||
|
page.export_sheet_button.click()
|
||||||
|
_pump(qt_app, 0.1)
|
||||||
|
|
||||||
|
assert target.is_file()
|
||||||
|
assert page.message.text() == t("cap.queue.export_saved", path=str(target))
|
||||||
|
# The refusal path names the point and writes nothing.
|
||||||
|
page.controller.campaign.sites[0].x += 0.5
|
||||||
|
page.export_sheet_button.click()
|
||||||
|
_pump(qt_app, 0.1)
|
||||||
|
assert t("cap.queue.export_edited_point", order=1) in page.message.text()
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
def test_a_failed_summary_copy_is_reported_not_called_published(make_window):
|
||||||
|
"""The copy under summary/ is a convenience; a failure must not read as success."""
|
||||||
|
window = make_window()
|
||||||
|
page = window.calibration_panel
|
||||||
|
detail = "the readable copy under summary/ was not written: no space left"
|
||||||
|
page._summary_warnings = [detail]
|
||||||
|
page._summary_finished("/some/published/summary", None)
|
||||||
|
assert page.message.text() == t("summary.copy_failed").format(detail=detail)
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stored_unavailable_codec_does_not_become_the_selection(make_window):
|
||||||
|
"""Loading stored options must not re-select a codec the probe disabled."""
|
||||||
|
window = make_window()
|
||||||
|
settings = window.calibration_panel.settings
|
||||||
|
settings.prefs["recording"] = {
|
||||||
|
"video": True,
|
||||||
|
"codec": "avc1",
|
||||||
|
"lossless_frames": False,
|
||||||
|
}
|
||||||
|
# The dialog probes first and loads the stored options second; that order is
|
||||||
|
# what used to put the disabled avc1 back into the combo.
|
||||||
|
settings.set_availability({"mp4v": True, "avc1": False, "H264": False})
|
||||||
|
settings._load_preferences()
|
||||||
|
assert settings.codec.currentData() == "mp4v"
|
||||||
|
assert settings.recording_options().codec == "mp4v"
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_settings_dialog_does_not_squeeze_the_input_rows(make_window, qt_app):
|
||||||
|
"""The input panel keeps its natural height: no row may overlap the next one."""
|
||||||
|
window = make_window()
|
||||||
|
page = window.calibration_panel
|
||||||
|
dialog = page.settings
|
||||||
|
dialog.resize(720, 700)
|
||||||
|
dialog.show()
|
||||||
|
for _ in range(5):
|
||||||
|
qt_app.processEvents()
|
||||||
|
panel = window.finger_source_panel
|
||||||
|
|
||||||
|
assert panel.height() >= panel.minimumSizeHint().height()
|
||||||
|
video = panel.video_path.geometry()
|
||||||
|
config = panel.config_ref.geometry()
|
||||||
|
assert video.bottom() < config.top(), (video, config)
|
||||||
|
# The preview stays bounded, so the sections below remain reachable.
|
||||||
|
assert panel.preview.height() <= 200
|
||||||
|
dialog.close()
|
||||||
|
window.close()
|
||||||
@@ -17,6 +17,7 @@ No test here touches a controller or a DLL.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ctypes
|
import ctypes
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ from orisys_autocal.motion import (
|
|||||||
DEFAULT_BAS_TASK,
|
DEFAULT_BAS_TASK,
|
||||||
INIT_OK,
|
INIT_OK,
|
||||||
INIT_RUNNING,
|
INIT_RUNNING,
|
||||||
|
BusStatus,
|
||||||
ConnectionSpec,
|
ConnectionSpec,
|
||||||
DemoTransport,
|
DemoTransport,
|
||||||
MotionService,
|
MotionService,
|
||||||
@@ -224,6 +226,44 @@ def test_adopted_program_without_a_known_file_says_so():
|
|||||||
assert "文件名" in result.message or "file name" in result.message
|
assert "文件名" in result.message or "file name" in result.message
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_reads_axis_4_and_does_not_write_parameters(caplog):
|
||||||
|
"""Connecting must not replace configuration the vendor software just set."""
|
||||||
|
transport = DemoTransport()
|
||||||
|
writes: list[tuple] = []
|
||||||
|
transport.set_units = lambda axis, value: writes.append(("units", axis, value))
|
||||||
|
transport.set_speed = lambda axis, value: writes.append(("speed", axis, value))
|
||||||
|
transport.set_accel = lambda axis, value: writes.append(("accel", axis, value))
|
||||||
|
transport.bas_download = lambda filename, run_mode=1: writes.append(("bas", filename))
|
||||||
|
service = MotionService(transport)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO):
|
||||||
|
result = service.connect(ConnectionSpec(mode="demo"))
|
||||||
|
|
||||||
|
assert result.ok, result.message
|
||||||
|
assert writes == []
|
||||||
|
readback = [record.message for record in caplog.records if record.message.startswith("connect axis 4")]
|
||||||
|
assert readback, caplog.text
|
||||||
|
assert "mpos=" in readback[-1]
|
||||||
|
assert "not written" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_axis_4_jog_reuses_the_shared_motion_call_and_logs_position(caplog):
|
||||||
|
service = MotionService(DemoTransport())
|
||||||
|
assert service.connect(ConnectionSpec(mode="demo")).ok
|
||||||
|
service._bus = BusStatus(bus_type="ecat", init_status=INIT_OK, total_axes=4) # noqa: SLF001
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO):
|
||||||
|
result = service.jog(4, True)
|
||||||
|
|
||||||
|
assert result.ok, result.message
|
||||||
|
assert result.code == 0
|
||||||
|
logged = [record.message for record in caplog.records if record.message.startswith("jog axis 4")]
|
||||||
|
assert logged, caplog.text
|
||||||
|
assert "ok=True" in logged[-1]
|
||||||
|
assert "code=0" in logged[-1]
|
||||||
|
assert "mpos=" in logged[-1]
|
||||||
|
|
||||||
|
|
||||||
def test_reinit_needs_the_bus_not_to_be_mid_run():
|
def test_reinit_needs_the_bus_not_to_be_mid_run():
|
||||||
service = MotionService(DemoTransport())
|
service = MotionService(DemoTransport())
|
||||||
service.connect(ConnectionSpec(mode="demo"))
|
service.connect(ConnectionSpec(mode="demo"))
|
||||||
|
|||||||
@@ -180,20 +180,21 @@ def test_the_axis_mapping_page_holds_the_mapping_and_refuses_duplicates(make_win
|
|||||||
window = make_window()
|
window = make_window()
|
||||||
try:
|
try:
|
||||||
dialog = window.device_setup
|
dialog = window.device_setup
|
||||||
assert dialog.tabs.count() == 3
|
assert dialog.tabs.count() == 5
|
||||||
page = dialog.roles_page
|
page = dialog.roles_page
|
||||||
assert page.roles() == DEFAULT_AXIS_ROLES
|
assert page.roles() == DEFAULT_AXIS_ROLES
|
||||||
assert page.problem() == ""
|
assert page.problem() == ""
|
||||||
assert window.poller.service.axis_roles == DEFAULT_AXIS_ROLES
|
assert window.poller.service.axis_roles == DEFAULT_AXIS_ROLES
|
||||||
|
|
||||||
# Two roles on one drive: reported on the page *and* by the guard.
|
# Two roles on one drive: reported on the page *and* by the guard. Yaw is
|
||||||
page.combos["pitch"].setCurrentIndex(page.combos["pitch"].findData(3))
|
# moved onto pitch's drive, which is the duplicate this page exists to refuse.
|
||||||
|
page.combos["yaw"].setCurrentIndex(page.combos["yaw"].findData(3))
|
||||||
assert page.problem() != ""
|
assert page.problem() != ""
|
||||||
assert window.poller.service.roles_problem() != ""
|
assert window.poller.service.roles_problem() != ""
|
||||||
assert window.poller.service.check_axes_move({"yaw": 1.0}) is not None
|
assert window.poller.service.check_axes_move({"yaw": 1.0}) is not None
|
||||||
|
|
||||||
# Correcting it clears both.
|
# Correcting it clears both.
|
||||||
page.combos["pitch"].setCurrentIndex(page.combos["pitch"].findData(2))
|
page.combos["yaw"].setCurrentIndex(page.combos["yaw"].findData(2))
|
||||||
assert page.problem() == ""
|
assert page.problem() == ""
|
||||||
assert window.poller.service.roles_problem() == ""
|
assert window.poller.service.roles_problem() == ""
|
||||||
finally:
|
finally:
|
||||||
@@ -222,3 +223,63 @@ def test_opening_the_setup_does_not_touch_the_connection(make_window):
|
|||||||
assert window.connection_panel.open_button.isEnabled() is True
|
assert window.connection_panel.open_button.isEnabled() is True
|
||||||
finally:
|
finally:
|
||||||
window.close()
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_press_page_starts_uncalibrated_and_says_so(make_window):
|
||||||
|
"""Until the zero is measured the press axis is not a coordinate anyone knows."""
|
||||||
|
window = make_window()
|
||||||
|
try:
|
||||||
|
page = window.device_setup.press_page
|
||||||
|
assert page.press()["calibrated"] is False
|
||||||
|
assert page.state_label.text() == t("setup.press_unset")
|
||||||
|
assert window.finger_panel.solved_pose # bound; the pose itself needs a bundle
|
||||||
|
finally:
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_press_axis_edit_reaches_the_panel_that_plans_the_move(make_window):
|
||||||
|
"""The page is a measurement, and the move has to obey it immediately.
|
||||||
|
|
||||||
|
A zero typed here but not handed to the solver's consumer would leave the
|
||||||
|
confirmation box showing a height the press axis was never told about.
|
||||||
|
"""
|
||||||
|
window = make_window()
|
||||||
|
try:
|
||||||
|
page = window.device_setup.press_page
|
||||||
|
page.zero_spin.setValue(12.5)
|
||||||
|
page.calibrated_check.setChecked(True)
|
||||||
|
assert window.prefs["press_axis"] == {
|
||||||
|
"zero_mm": 12.5,
|
||||||
|
"rising": True,
|
||||||
|
"calibrated": True,
|
||||||
|
}
|
||||||
|
assert window.finger_panel._press.calibrated is True # noqa: SLF001
|
||||||
|
assert window._press_axis.zero_mm == 12.5 # noqa: SLF001
|
||||||
|
assert page.state_label.text() == t("setup.press_set")
|
||||||
|
|
||||||
|
page.rising_check.setChecked(False)
|
||||||
|
assert window.prefs["press_axis"]["rising"] is False
|
||||||
|
assert window._press_axis.rising is False # noqa: SLF001
|
||||||
|
finally:
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stored_press_axis_is_restored_into_the_page(make_window):
|
||||||
|
window = make_window(
|
||||||
|
press_axis={"zero_mm": -3.25, "rising": False, "calibrated": True}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
page = window.device_setup.press_page
|
||||||
|
assert page.press() == {"zero_mm": -3.25, "rising": False, "calibrated": True}
|
||||||
|
assert window.finger_panel._press.zero_mm == -3.25 # noqa: SLF001
|
||||||
|
finally:
|
||||||
|
window.close()
|
||||||
|
@pytest.mark.parametrize("settings", [
|
||||||
|
{"zero_mm": 12.5, "rising": True, "calibrated": "false"},
|
||||||
|
{"zero_mm": float("nan"), "rising": True, "calibrated": True},
|
||||||
|
{"zero_mm": 12.5, "rising": "false", "calibrated": True},
|
||||||
|
])
|
||||||
|
def test_invalid_stored_press_relation_does_not_enable_motion(make_window, settings):
|
||||||
|
window = make_window()
|
||||||
|
window.prefs["press_axis"] = settings
|
||||||
|
assert window._press_axis.target_for(19.051) is None
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
|||||||
|
"""The Autocal sensor tab drives the shared finger acquisition interface."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
class FakeFingerWorker:
|
||||||
|
def __init__(self, *, on_error):
|
||||||
|
self.on_error = on_error
|
||||||
|
self.is_running = False
|
||||||
|
self.started = None
|
||||||
|
self.stopped = []
|
||||||
|
self.packet = None
|
||||||
|
|
||||||
|
def start(self, video, *, config_ref):
|
||||||
|
self.started = (video, config_ref)
|
||||||
|
self.is_running = True
|
||||||
|
|
||||||
|
def stop(self, *, wait=False):
|
||||||
|
self.stopped.append(wait)
|
||||||
|
self.is_running = False
|
||||||
|
|
||||||
|
def latest(self):
|
||||||
|
return self.packet
|
||||||
|
|
||||||
|
def consume_end_reason(self):
|
||||||
|
return "stopped"
|
||||||
|
|
||||||
|
|
||||||
|
def test_video_controls_use_shared_worker_and_show_processed_frame(
|
||||||
|
qt_app, tmp_dir, monkeypatch
|
||||||
|
):
|
||||||
|
from orisys_autocal.ui import finger_source_panel
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
finger_source_panel, "FingerAcquisitionWorker", FakeFingerWorker
|
||||||
|
)
|
||||||
|
video = tmp_dir / "input.mp4"
|
||||||
|
config = tmp_dir / "sensor.json"
|
||||||
|
video.write_bytes(b"video")
|
||||||
|
config.write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
panel = finger_source_panel.FingerSourcePanel(str(video), str(config))
|
||||||
|
panel.start_button.click()
|
||||||
|
assert panel.worker.started == (str(video), str(config))
|
||||||
|
assert not panel.start_button.isEnabled()
|
||||||
|
|
||||||
|
panel.worker.packet = SimpleNamespace(
|
||||||
|
session_id=1,
|
||||||
|
frame_id=7,
|
||||||
|
timestamp=123.5,
|
||||||
|
processed_frame=np.full((20, 30, 3), (10, 20, 30), dtype=np.uint8),
|
||||||
|
)
|
||||||
|
panel._poll()
|
||||||
|
assert panel._last_image.size().width() == 30
|
||||||
|
assert panel.frame_label.text().find("7") >= 0
|
||||||
|
assert not panel.preview.pixmap().isNull()
|
||||||
|
|
||||||
|
panel.stop_button.click()
|
||||||
|
panel._poll()
|
||||||
|
assert panel.worker.stopped == [False]
|
||||||
|
assert panel.start_button.isEnabled()
|
||||||
|
panel.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sensor_tab_is_live_panel_and_paths_are_saved(make_window, tmp_dir):
|
||||||
|
window = make_window()
|
||||||
|
assert window.workspace.page("sensor") is window.calibration_panel
|
||||||
|
assert window.calibration_panel.source_panel is window.finger_source_panel
|
||||||
|
window.finger_source_panel.video_path.setText(str(tmp_dir / "input.mp4"))
|
||||||
|
window.finger_source_panel.config_ref.setText(str(tmp_dir / "sensor.json"))
|
||||||
|
window.finger_source_panel.source_kind.setCurrentIndex(1)
|
||||||
|
window.finger_source_panel.camera_index.setValue(3)
|
||||||
|
window._persist_prefs()
|
||||||
|
assert window.prefs["finger_video_path"].endswith("input.mp4")
|
||||||
|
assert window.prefs["finger_config_ref"].endswith("sensor.json")
|
||||||
|
assert window.prefs["finger_source_kind"] == "camera"
|
||||||
|
assert window.prefs["finger_camera_index"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_camera_uses_integer_source_and_same_worker(qt_app, tmp_dir, monkeypatch):
|
||||||
|
from orisys_autocal.ui import finger_source_panel
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
finger_source_panel, "FingerAcquisitionWorker", FakeFingerWorker
|
||||||
|
)
|
||||||
|
config = tmp_dir / "sensor.json"
|
||||||
|
config.write_text("{}", encoding="utf-8")
|
||||||
|
panel = finger_source_panel.FingerSourcePanel(
|
||||||
|
config_ref=str(config), source_kind="camera", camera_index=3
|
||||||
|
)
|
||||||
|
panel.start_button.click()
|
||||||
|
assert panel.worker.started == (3, str(config))
|
||||||
|
assert not panel.camera_index.isEnabled()
|
||||||
|
panel.stop_button.click()
|
||||||
|
panel._poll()
|
||||||
|
assert panel.camera_index.isEnabled()
|
||||||
|
panel.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_input_is_visible_and_does_not_start_worker(qt_app, monkeypatch):
|
||||||
|
from orisys_autocal.ui import finger_source_panel
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
finger_source_panel, "FingerAcquisitionWorker", FakeFingerWorker
|
||||||
|
)
|
||||||
|
panel = finger_source_panel.FingerSourcePanel()
|
||||||
|
panel.start_button.click()
|
||||||
|
assert panel.worker.started is None
|
||||||
|
assert panel.start_button.isEnabled()
|
||||||
|
assert panel._error
|
||||||
|
assert panel._error in panel.status_label.text()
|
||||||
|
panel.shutdown()
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""Offline force feedback contracts; no controller or sensor is opened."""
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from orisys_autocal.force_control import ForceControlConfig, ForceController
|
||||||
|
from orisys_autocal.reference_force import (
|
||||||
|
NormalForceSample,
|
||||||
|
WrenchSample,
|
||||||
|
project_wrench,
|
||||||
|
)
|
||||||
|
from orisys_autocal.multipoint import normal_force_n, parse_rotation
|
||||||
|
|
||||||
|
|
||||||
|
def config(**overrides):
|
||||||
|
values = dict(
|
||||||
|
target_n=2.0,
|
||||||
|
kp_mm_per_n_s=0.2,
|
||||||
|
ki_mm_per_n_s2=0.1,
|
||||||
|
kd_mm_per_n=0.0,
|
||||||
|
max_force_n=5.0,
|
||||||
|
max_speed_mm_s=0.5,
|
||||||
|
max_depth_mm=1.0,
|
||||||
|
max_sample_age_s=0.2,
|
||||||
|
max_duration_s=10.0,
|
||||||
|
)
|
||||||
|
return ForceControlConfig(**(values | overrides))
|
||||||
|
|
||||||
|
|
||||||
|
def sample(force=2.0, seconds=1.0, **overrides):
|
||||||
|
return NormalForceSample(
|
||||||
|
force_n=force,
|
||||||
|
t_host_mono_ns=int(seconds * 1e9),
|
||||||
|
source_id="reference-1",
|
||||||
|
calibration_id="tare-1",
|
||||||
|
**overrides,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update(controller, force=2.0, seconds=1.0, depth=0.3, **overrides):
|
||||||
|
return controller.update(
|
||||||
|
sample(force, seconds, **overrides), depth_mm=depth, now_ns=int(seconds * 1e9)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ft_projection_uses_rotation_polarity_and_preserves_identity():
|
||||||
|
# Proper rotation: sensor +X -> tool +Z; tool reaction is negative compression.
|
||||||
|
rotation = parse_rotation([0, 0, -1, 0, 1, 0, 1, 0, 0])
|
||||||
|
raw = WrenchSample(
|
||||||
|
(-3.0, 7.0, 8.0), 123, "ft-1", "tare-4", "ft", torque_nm=(0.1, 0.2, 0.3)
|
||||||
|
)
|
||||||
|
measured = project_wrench(
|
||||||
|
raw, rotation=rotation, expected_frame="ft", compression_sign=-1
|
||||||
|
)
|
||||||
|
assert measured.force_n == 3.0
|
||||||
|
assert measured.t_host_mono_ns == 123
|
||||||
|
assert measured.source_id == "ft-1" and measured.calibration_id == "tare-4"
|
||||||
|
assert normal_force_n(-3, 7, 8, rotation) == -3.0
|
||||||
|
assert raw.torque_nm == (0.1, 0.2, 0.3)
|
||||||
|
with pytest.raises(ValueError, match="frame"):
|
||||||
|
project_wrench(
|
||||||
|
raw, rotation=rotation, expected_frame="platform", compression_sign=-1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_projection_rejects_mirror_and_keeps_invalid_sample_invalid():
|
||||||
|
raw = WrenchSample((1.0, 2.0, 3.0), 1, "ft", "tare", "ft", valid=False)
|
||||||
|
projected = project_wrench(
|
||||||
|
raw,
|
||||||
|
rotation=((1, 0, 0), (0, 1, 0), (0, 0, 1)),
|
||||||
|
expected_frame="ft",
|
||||||
|
compression_sign=1,
|
||||||
|
)
|
||||||
|
assert not projected.valid
|
||||||
|
with pytest.raises(ValueError, match="determinant"):
|
||||||
|
project_wrench(
|
||||||
|
raw,
|
||||||
|
rotation=((-1, 0, 0), (0, 1, 0), (0, 0, 1)),
|
||||||
|
expected_frame="ft",
|
||||||
|
compression_sign=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_force_drop_is_compensated_or_recorded_without_motion():
|
||||||
|
active = ForceController(config(), mode="compensate")
|
||||||
|
passive = ForceController(config(), mode="record_relaxation")
|
||||||
|
for controller in (active, passive):
|
||||||
|
assert update(controller).velocity_mm_s == 0
|
||||||
|
compensation = update(active, force=1.8, seconds=1.1)
|
||||||
|
observation = update(passive, force=1.8, seconds=1.1)
|
||||||
|
assert compensation.velocity_mm_s > 0
|
||||||
|
assert observation.velocity_mm_s == 0
|
||||||
|
assert observation.force_rate_n_s == pytest.approx(-2.0)
|
||||||
|
assert observation.sample.force_n == 1.8 and observation.depth_mm == 0.3
|
||||||
|
assert observation.error_n == pytest.approx(0.2)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"field,value",
|
||||||
|
[
|
||||||
|
("kp_mm_per_n_s", math.nan),
|
||||||
|
("max_depth_mm", 0),
|
||||||
|
("max_sample_age_s", -1),
|
||||||
|
("target_n", 6),
|
||||||
|
("ki_mm_per_n_s2", -1),
|
||||||
|
("max_duration_s", math.inf),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_bad_configuration_is_rejected(field, value):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
config(**{field: value})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"fault",
|
||||||
|
[
|
||||||
|
"invalid",
|
||||||
|
"stale",
|
||||||
|
"future",
|
||||||
|
"duplicate",
|
||||||
|
"gap",
|
||||||
|
"overforce",
|
||||||
|
"depth",
|
||||||
|
"nonfinite",
|
||||||
|
"identity",
|
||||||
|
"timeout",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_faults_latch_zero_output_until_explicit_reset(fault):
|
||||||
|
controller = ForceController(config())
|
||||||
|
update(controller)
|
||||||
|
item, now, depth = sample(1.0, 1.1), 1_100_000_000, 0.3
|
||||||
|
if fault == "invalid":
|
||||||
|
item = replace(item, valid=False)
|
||||||
|
elif fault == "stale":
|
||||||
|
now = 1_400_000_000
|
||||||
|
elif fault == "future":
|
||||||
|
now = 1_000_000_000
|
||||||
|
elif fault == "duplicate":
|
||||||
|
item = sample(1.0, 1.0)
|
||||||
|
elif fault == "gap":
|
||||||
|
item, now = sample(1.0, 1.4), 1_400_000_000
|
||||||
|
elif fault == "overforce":
|
||||||
|
item = sample(-6.0, 1.1)
|
||||||
|
elif fault == "depth":
|
||||||
|
depth = 1.1
|
||||||
|
elif fault == "nonfinite":
|
||||||
|
item = sample(math.nan, 1.1)
|
||||||
|
elif fault == "identity":
|
||||||
|
item = replace(item, calibration_id="new-tare")
|
||||||
|
elif fault == "timeout":
|
||||||
|
controller = ForceController(config(max_duration_s=0.05))
|
||||||
|
update(controller)
|
||||||
|
result = controller.update(item, depth_mm=depth, now_ns=now)
|
||||||
|
assert result.fault and result.velocity_mm_s == 0
|
||||||
|
assert update(controller, seconds=1.2).fault == result.fault
|
||||||
|
controller.reset()
|
||||||
|
assert not update(controller, seconds=1.3).fault
|
||||||
|
|
||||||
|
|
||||||
|
def test_derivative_is_on_measurement_and_uses_sample_time():
|
||||||
|
controller = ForceController(
|
||||||
|
config(kp_mm_per_n_s=0, ki_mm_per_n_s2=0, kd_mm_per_n=0.1), mode="compensate"
|
||||||
|
)
|
||||||
|
update(controller)
|
||||||
|
assert update(controller, force=1.9, seconds=1.1).velocity_mm_s == pytest.approx(
|
||||||
|
0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_saturation_does_not_accumulate_integral():
|
||||||
|
controller = ForceController(
|
||||||
|
config(kp_mm_per_n_s=1, ki_mm_per_n_s2=1), mode="compensate"
|
||||||
|
)
|
||||||
|
for index in range(10):
|
||||||
|
assert update(controller, force=0, seconds=1 + index * 0.1).velocity_mm_s == 0.5
|
||||||
|
assert update(controller, force=2.1, seconds=2).velocity_mm_s < 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_pure_integral_can_saturate_without_losing_its_output():
|
||||||
|
controller = ForceController(
|
||||||
|
config(kp_mm_per_n_s=0, ki_mm_per_n_s2=10), mode="compensate"
|
||||||
|
)
|
||||||
|
update(controller, force=0)
|
||||||
|
assert update(controller, force=0, seconds=1.1).velocity_mm_s == 0.5
|
||||||
|
assert update(controller, force=2.1, seconds=1.2).velocity_mm_s < 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_numerical_overflow_faults_instead_of_returning_a_velocity():
|
||||||
|
controller = ForceController(config(kp_mm_per_n_s=1e308), mode="compensate")
|
||||||
|
assert update(controller, force=-2).fault == "numerical_failure"
|
||||||
|
|
||||||
|
|
||||||
|
def test_depth_horizon_limits_velocity_and_boundary_faults():
|
||||||
|
controller = ForceController(config(), mode="compensate")
|
||||||
|
result = update(controller, force=0, depth=0.99)
|
||||||
|
assert result.velocity_mm_s <= 0.05 + 1e-12
|
||||||
|
assert update(controller, force=0, seconds=1.1, depth=1.0).fault
|
||||||
|
|
||||||
|
|
||||||
|
def test_relaxing_rubber_model_tracks_force_without_unbounded_depth():
|
||||||
|
# Standard linear solid: F = k_inf*x + q, q' = k_relax*x' - q/tau.
|
||||||
|
controller = ForceController(
|
||||||
|
config(max_sample_age_s=0.1, kp_mm_per_n_s=0.25, ki_mm_per_n_s2=0.25),
|
||||||
|
mode="compensate",
|
||||||
|
)
|
||||||
|
depth, transient = 0.4, 1.2
|
||||||
|
initial_force = 2 * depth + transient
|
||||||
|
peak = initial_force
|
||||||
|
for index in range(400):
|
||||||
|
force = 2 * depth + transient
|
||||||
|
decision = update(
|
||||||
|
controller, force=force, seconds=1 + index * 0.02, depth=depth
|
||||||
|
)
|
||||||
|
assert not decision.fault
|
||||||
|
change = decision.velocity_mm_s * 0.02
|
||||||
|
depth += change
|
||||||
|
transient += 3 * change - transient * 0.02 / 0.5
|
||||||
|
peak = max(peak, force)
|
||||||
|
assert abs(force - 2.0) < 0.08
|
||||||
|
assert 0.4 < depth <= 1.0
|
||||||
|
assert peak < 2.3
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""A force row belongs to a frame by identity, never by its line number."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from evidence_helpers import (
|
||||||
|
BASE_NS,
|
||||||
|
force_row,
|
||||||
|
frame_row,
|
||||||
|
write_capture,
|
||||||
|
)
|
||||||
|
from orisys_autocal.frames_index import (
|
||||||
|
DUPLICATE_REASON,
|
||||||
|
joined_samples,
|
||||||
|
read_frame_index,
|
||||||
|
window_samples,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _capture(tmp_path, frames, forces, **kwargs):
|
||||||
|
return write_capture(tmp_path / "capture", frames, forces, video_segments=0, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_samples_are_paired_by_frame_id_not_by_row_order(tmp_path):
|
||||||
|
frames = [
|
||||||
|
frame_row(1, BASE_NS + 10),
|
||||||
|
frame_row(2, BASE_NS + 20),
|
||||||
|
frame_row(3, BASE_NS + 30),
|
||||||
|
]
|
||||||
|
# The force rows are reversed on disk, as an out-of-order write would leave them.
|
||||||
|
forces = [force_row(3, fn=30.0), force_row(2, fn=20.0), force_row(1, fn=10.0)]
|
||||||
|
_capture(tmp_path, frames, forces)
|
||||||
|
|
||||||
|
samples, report = joined_samples(tmp_path / "capture")
|
||||||
|
|
||||||
|
assert [sample.frame_id for sample in samples] == [1, 2, 3]
|
||||||
|
assert [sample.t_host_mono_ns for sample in samples] == [
|
||||||
|
BASE_NS + 10, BASE_NS + 20, BASE_NS + 30,
|
||||||
|
]
|
||||||
|
assert [sample.value("FN") for sample in samples] == [10.0, 20.0, 30.0]
|
||||||
|
assert report.unjoined_force_rows == 0
|
||||||
|
|
||||||
|
window = window_samples(samples, BASE_NS + 15, BASE_NS + 30)
|
||||||
|
assert [sample.frame_id for sample in window] == [2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_repeated_frame_id_excludes_the_samples_it_would_pair(tmp_path):
|
||||||
|
"""A camera that restarted reuses frame numbers; neither copy is the measurement."""
|
||||||
|
frames = [
|
||||||
|
frame_row(1, BASE_NS + 10),
|
||||||
|
frame_row(2, BASE_NS + 20),
|
||||||
|
frame_row(1, BASE_NS + 1_000),
|
||||||
|
frame_row(2, BASE_NS + 1_010),
|
||||||
|
]
|
||||||
|
forces = [
|
||||||
|
force_row(1, fn=10.0),
|
||||||
|
force_row(2, fn=20.0),
|
||||||
|
force_row(1, fn=11.0),
|
||||||
|
force_row(2, fn=21.0),
|
||||||
|
]
|
||||||
|
_capture(tmp_path, frames, forces)
|
||||||
|
|
||||||
|
samples, report = joined_samples(tmp_path / "capture")
|
||||||
|
|
||||||
|
assert samples == []
|
||||||
|
assert report.duplicate_frame_ids == 2
|
||||||
|
assert DUPLICATE_REASON in report.reasons
|
||||||
|
|
||||||
|
|
||||||
|
def test_frames_outside_the_video_are_kept_only_when_asked_for(tmp_path):
|
||||||
|
frames = [
|
||||||
|
frame_row(1, BASE_NS + 10, in_video=1),
|
||||||
|
frame_row(2, BASE_NS + 20, in_video=0),
|
||||||
|
frame_row(3, BASE_NS + 30, in_video=1),
|
||||||
|
]
|
||||||
|
forces = [force_row(1, fn=1.0), force_row(2, fn=2.0), force_row(3, fn=3.0)]
|
||||||
|
_capture(tmp_path, frames, forces)
|
||||||
|
|
||||||
|
with_video, report = joined_samples(tmp_path / "capture", require_video=True)
|
||||||
|
everything, _ = joined_samples(tmp_path / "capture")
|
||||||
|
|
||||||
|
assert [sample.frame_id for sample in with_video] == [1, 3]
|
||||||
|
assert [sample.frame_id for sample in everything] == [1, 2, 3]
|
||||||
|
assert report.frames_not_in_video == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_dropped_frame_row_has_no_time_to_pair_with(tmp_path):
|
||||||
|
frames = [
|
||||||
|
frame_row(1, BASE_NS + 10),
|
||||||
|
frame_row(2, 0, dropped=1),
|
||||||
|
frame_row(3, BASE_NS + 30),
|
||||||
|
]
|
||||||
|
forces = [force_row(1, fn=1.0), force_row(2, fn=2.0), force_row(3, fn=3.0)]
|
||||||
|
_capture(tmp_path, frames, forces)
|
||||||
|
|
||||||
|
samples, report = joined_samples(tmp_path / "capture")
|
||||||
|
|
||||||
|
assert [sample.frame_id for sample in samples] == [1, 3]
|
||||||
|
assert report.unjoined_force_rows == 1
|
||||||
|
assert report.frames_dropped == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_recording_without_an_in_video_column_still_joins(tmp_path):
|
||||||
|
"""Older recordings have no membership column; unknown is not "outside"."""
|
||||||
|
fields = [
|
||||||
|
name for name in (
|
||||||
|
"frame_id", "source_frame_id", "t_rel_s", "t_host_mono_ns", "t_host_utc_ns",
|
||||||
|
"acquired_at", "time_basis", "media_time_ms", "segment", "segment_frame",
|
||||||
|
"dropped", "capture_dropped",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
frames = [
|
||||||
|
{"frame_id": 1, "t_host_mono_ns": BASE_NS + 10, "dropped": 0, "t_rel_s": "0.0"},
|
||||||
|
{"frame_id": 2, "t_host_mono_ns": BASE_NS + 20, "dropped": 0, "t_rel_s": "0.0"},
|
||||||
|
]
|
||||||
|
_capture(tmp_path, frames, [force_row(1, fn=1.0), force_row(2, fn=2.0)], fields=fields)
|
||||||
|
|
||||||
|
samples, report = joined_samples(tmp_path / "capture", require_video=True)
|
||||||
|
|
||||||
|
assert [sample.frame_id for sample in samples] == [1, 2]
|
||||||
|
assert report.frames_not_in_video == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_axis_is_empty_not_zero(tmp_path):
|
||||||
|
frames = [frame_row(1, BASE_NS + 10), frame_row(2, BASE_NS + 20)]
|
||||||
|
forces = [
|
||||||
|
force_row(1, fn=10.0, fx=0.5, fy=-0.25),
|
||||||
|
force_row(2, fn=None, fx=None, fy=None, valid=0),
|
||||||
|
]
|
||||||
|
_capture(tmp_path, frames, forces)
|
||||||
|
|
||||||
|
samples, _ = joined_samples(tmp_path / "capture")
|
||||||
|
|
||||||
|
assert samples[0].values == (10.0, 0.5, -0.25)
|
||||||
|
assert samples[1].value("FN") is None
|
||||||
|
assert samples[1].valid is False
|
||||||
|
assert samples[1].usable() is False
|
||||||
|
assert samples[0].usable() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_frame_index_keeps_repeated_ids_and_unknown_video_membership(tmp_path):
|
||||||
|
frames = [
|
||||||
|
frame_row(1, BASE_NS + 10, in_video=1),
|
||||||
|
frame_row(1, BASE_NS + 20, in_video=0),
|
||||||
|
]
|
||||||
|
_capture(tmp_path, frames, [force_row(1, fn=1.0)])
|
||||||
|
|
||||||
|
index = read_frame_index(tmp_path / "capture")
|
||||||
|
|
||||||
|
assert [row.frame_id for row in index] == [1, 1]
|
||||||
|
assert [row.in_video for row in index] == [True, False]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_capture_directory_reads_as_empty(tmp_path):
|
||||||
|
samples, report = joined_samples(tmp_path / "absent")
|
||||||
|
|
||||||
|
assert samples == []
|
||||||
|
assert report.frames_total == 0 and report.force_rows == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("frame_id", [0, 120])
|
||||||
|
def test_frame_ids_are_used_verbatim(tmp_path, frame_id):
|
||||||
|
"""Zero and large ids are still identities; nothing is renumbered."""
|
||||||
|
frames = [frame_row(frame_id, BASE_NS + 10)]
|
||||||
|
_capture(tmp_path, frames, [force_row(frame_id, fn=2.0)])
|
||||||
|
|
||||||
|
samples, _ = joined_samples(tmp_path / "capture")
|
||||||
|
|
||||||
|
assert samples[0].frame_id == frame_id
|
||||||
@@ -39,12 +39,8 @@ def _random_units(count: int, seed: int) -> list[np.ndarray]:
|
|||||||
|
|
||||||
|
|
||||||
def _apply(stack: PlatformStack, unit: np.ndarray, yaw_deg: float, pitch_deg: float) -> np.ndarray:
|
def _apply(stack: PlatformStack, unit: np.ndarray, yaw_deg: float, pitch_deg: float) -> np.ndarray:
|
||||||
"""Re-apply a returned pose with the library's own rotations."""
|
"""Re-apply a returned pose with the same rotation the drives execute."""
|
||||||
return (
|
return stack.platform_rotation(yaw_deg, pitch_deg) @ unit
|
||||||
rotation_about(stack.pitch_axis, pitch_deg)
|
|
||||||
@ rotation_about(stack.yaw_axis, yaw_deg)
|
|
||||||
@ unit
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ exactness
|
# ------------------------------------------------------------------ exactness
|
||||||
@@ -108,11 +104,10 @@ def test_the_two_methods_agree_inside_the_envelope():
|
|||||||
|
|
||||||
|
|
||||||
def test_the_two_pitch_bindings_are_the_same_family():
|
def test_the_two_pitch_bindings_are_the_same_family():
|
||||||
"""Confirmed at the bench: a 90 degree yaw takes one binding to the other.
|
"""``R_y(p)·R_z(t)`` and ``R_x(p)·R_z(t − 90)`` reach the same alignments.
|
||||||
|
|
||||||
``R_y(p)·R_z(t)`` and ``R_x(p)·R_z(t − 90)`` agree to machine precision. So
|
This is a property of the right-hand matrices. The drive does not get to
|
||||||
which axis is called pitch is a naming and zero-offset choice, not a
|
pick either binding: pitch is about ``x`` with the measured command sign.
|
||||||
correctness question — the two reach exactly the same set of alignments.
|
|
||||||
"""
|
"""
|
||||||
unit = np.array([0.3, 0.5, -0.81])
|
unit = np.array([0.3, 0.5, -0.81])
|
||||||
unit = unit / np.linalg.norm(unit)
|
unit = unit / np.linalg.norm(unit)
|
||||||
@@ -199,13 +194,13 @@ def test_the_surface_normals_point_up_so_the_face_faces_the_tool(bundle):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_few_vertices_end_turned_over_and_those_are_edge_on(bundle):
|
def test_no_vertex_ends_turned_over(bundle):
|
||||||
"""Turning the face over is the failure mode; measure it and bound it.
|
"""Turning the face over is the failure mode; with the right up axis it is zero.
|
||||||
|
|
||||||
The remainder are vertices whose normals are 60-90 degrees off the tool axis —
|
``uprightness`` used to compare the platform's Y axis, so 47 of these vertices read
|
||||||
the rim of the dome. Aligning an edge-on face has to roll the body about that
|
as turned over even though they only rolled about the vertical. Against the
|
||||||
normal, and the sign is decided by rounding, so they cannot be pushed to zero
|
post-mount nominal up (the tool axis) no vertex ends inverted, and this now fails
|
||||||
and should not be asserted away.
|
if the reference regresses rather than reporting a small count as acceptable.
|
||||||
"""
|
"""
|
||||||
stack = PlatformStack()
|
stack = PlatformStack()
|
||||||
normals = bundle.vertex_normals()
|
normals = bundle.vertex_normals()
|
||||||
@@ -214,9 +209,7 @@ def test_few_vertices_end_turned_over_and_those_are_edge_on(bundle):
|
|||||||
pose = stack.solve(np.zeros(3), normal)
|
pose = stack.solve(np.zeros(3), normal)
|
||||||
if stack.uprightness(pose.yaw_deg, pose.pitch_deg) < -0.5:
|
if stack.uprightness(pose.yaw_deg, pose.pitch_deg) < -0.5:
|
||||||
flipped += 1
|
flipped += 1
|
||||||
assert flipped <= len(normals) * 0.03, (
|
assert flipped == 0, f"{flipped} of {len(normals)} vertices ended turned over"
|
||||||
f"{flipped} of {len(normals)} vertices ended turned over"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_pose_reports_whether_it_is_upright():
|
def test_a_pose_reports_whether_it_is_upright():
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import numpy as np
|
|||||||
import pytest
|
import pytest
|
||||||
from orisys_autocal.motion.kinematics import (
|
from orisys_autocal.motion.kinematics import (
|
||||||
ALIGNED_TOL_DEG,
|
ALIGNED_TOL_DEG,
|
||||||
|
Branch,
|
||||||
PlatformStack,
|
PlatformStack,
|
||||||
Pose,
|
Pose,
|
||||||
residual_for,
|
residual_for,
|
||||||
@@ -58,9 +59,9 @@ def _brute_force_best(
|
|||||||
pitch_limit = min(360.0, stack.pitch_limit_deg)
|
pitch_limit = min(360.0, stack.pitch_limit_deg)
|
||||||
best = 1e9
|
best = 1e9
|
||||||
for yaw in np.arange(-yaw_limit, yaw_limit + step, step):
|
for yaw in np.arange(-yaw_limit, yaw_limit + step, step):
|
||||||
turned = rotation_about(stack.yaw_axis, float(yaw)) @ unit_platform
|
turned = stack.yaw_rotation(float(yaw)) @ unit_platform
|
||||||
for pitch in np.arange(-pitch_limit, pitch_limit + step, step):
|
for pitch in np.arange(-pitch_limit, pitch_limit + step, step):
|
||||||
rotated = rotation_about(stack.pitch_axis, float(pitch)) @ turned
|
rotated = stack.pitch_rotation(float(pitch)) @ turned
|
||||||
residual = float(np.degrees(np.arccos(np.clip(rotated @ tool, -1.0, 1.0))))
|
residual = float(np.degrees(np.arccos(np.clip(rotated @ tool, -1.0, 1.0))))
|
||||||
best = min(best, residual)
|
best = min(best, residual)
|
||||||
return best
|
return best
|
||||||
@@ -76,13 +77,78 @@ def test_the_axis_map_is_a_rotation():
|
|||||||
assert np.allclose(matrix @ matrix.T, np.eye(3))
|
assert np.allclose(matrix @ matrix.T, np.eye(3))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_crown_point_moves_along_y_and_does_not_yaw():
|
||||||
|
"""The top face used to be a Y move. A 1.6° sideways lean must not yaw 90°."""
|
||||||
|
stack = PlatformStack(sensor_offset_mm=(0.0, 0.0, 8.0), pitch_limit_deg=90.0)
|
||||||
|
point = np.array([0.150, 8.355, 5.483])
|
||||||
|
normal = np.array([0.028, 1.0, 0.0])
|
||||||
|
normal = normal / np.linalg.norm(normal)
|
||||||
|
pose = stack.solve(point, normal)
|
||||||
|
assert pose.yaw_deg == pytest.approx(0.0, abs=1e-6)
|
||||||
|
assert pose.pitch_deg == pytest.approx(0.0, abs=1e-6)
|
||||||
|
assert pose.table_xy_mm[1] == pytest.approx(-5.483, abs=0.02)
|
||||||
|
assert abs(pose.table_xy_mm[0]) < 0.5
|
||||||
|
assert pose.branch_reason == "crown"
|
||||||
|
assert pose.reachable
|
||||||
|
assert pose.reason == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_sideways_normal_still_yaws():
|
||||||
|
"""A lean the pitch stage cannot nod away still takes the exact yaw."""
|
||||||
|
stack = PlatformStack()
|
||||||
|
normal = np.array([0.7, 0.7, 0.1])
|
||||||
|
normal = normal / np.linalg.norm(normal)
|
||||||
|
pose = stack.solve(np.zeros(3), normal)
|
||||||
|
assert pose.residual_deg < 1e-4
|
||||||
|
assert abs(pose.yaw_deg) > 20.0
|
||||||
|
assert pose.branch_reason != "crown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_positive_stage_commands_match_the_drives():
|
||||||
|
"""Bench 2026-10-15, one axis at a time from the commissioned zero.
|
||||||
|
|
||||||
|
Yaw +10° turned the tip clockwise viewed from above. Positive pitch nods the
|
||||||
|
dome toward the operator (platform −Y). The preview uses this same rotation,
|
||||||
|
so a commanded angle draws the motion the drive will make.
|
||||||
|
"""
|
||||||
|
stack = PlatformStack()
|
||||||
|
assert stack.yaw_axis == "z" and stack.pitch_axis == "x"
|
||||||
|
assert np.allclose(stack.platform_rotation(0.0, 0.0), np.eye(3))
|
||||||
|
|
||||||
|
tip = stack.platform_rotation(10.0, 0.0) @ np.array([0.0, 1.0, 0.0])
|
||||||
|
# Clockwise from above: the tip, pointing at +Y, swings toward +X.
|
||||||
|
assert tip[0] > 0.0
|
||||||
|
assert tip[1] > 0.0
|
||||||
|
assert abs(tip[2]) < 1e-12
|
||||||
|
|
||||||
|
dome = stack.platform_rotation(0.0, 10.0) @ np.array([0.0, 0.0, 1.0])
|
||||||
|
# A nod, not a roll: the face leans toward −Y and does not pick up +X.
|
||||||
|
assert dome[1] < 0.0
|
||||||
|
assert abs(dome[0]) < 1e-12
|
||||||
|
assert dome[2] > 0.0
|
||||||
|
|
||||||
|
|
||||||
def test_the_sensor_y_maps_to_the_platform_z():
|
def test_the_sensor_y_maps_to_the_platform_z():
|
||||||
"""The bench's frame relation, stated once so it can be corrected once."""
|
"""The bench's frame relation, stated once so it can be corrected once."""
|
||||||
assert np.allclose(to_platform(np.array([0.0, 1.0, 0.0])), [0.0, 0.0, 1.0])
|
assert np.allclose(to_platform(np.array([0.0, 1.0, 0.0])), [0.0, 0.0, 1.0])
|
||||||
|
|
||||||
|
|
||||||
def test_the_sensor_x_maps_to_the_platform_x():
|
def test_the_sensor_x_maps_to_the_platform_minus_x():
|
||||||
assert np.allclose(to_platform(np.array([1.0, 0.0, 0.0])), [1.0, 0.0, 0.0])
|
"""Both table axes were reversed on the bench. −X keeps the map a rotation."""
|
||||||
|
assert np.allclose(to_platform(np.array([1.0, 0.0, 0.0])), [-1.0, 0.0, 0.0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_sensor_z_maps_to_the_platform_y():
|
||||||
|
"""Shell length and machine Y point the same way, so the tip is +Y."""
|
||||||
|
assert np.allclose(to_platform(np.array([0.0, 0.0, 1.0])), [0.0, 1.0, 0.0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tip_side_point_commands_negative_table_y():
|
||||||
|
"""Bringing a point toward the tip under the tool moves the table to −Y."""
|
||||||
|
pose = PlatformStack().solve(np.array([0.0, 8.0, 6.0]), np.array([0.0, 1.0, 0.0]))
|
||||||
|
assert pose.table_xy_mm[1] == pytest.approx(-6.0)
|
||||||
|
side = PlatformStack().solve(np.array([2.0, 8.0, 0.0]), np.array([0.0, 1.0, 0.0]))
|
||||||
|
assert side.table_xy_mm[0] == pytest.approx(2.0)
|
||||||
|
|
||||||
|
|
||||||
def test_the_mount_round_trips():
|
def test_the_mount_round_trips():
|
||||||
@@ -104,7 +170,7 @@ def test_an_incomplete_map_is_refused():
|
|||||||
|
|
||||||
|
|
||||||
def test_the_mapping_is_described_for_the_ui():
|
def test_the_mapping_is_described_for_the_ui():
|
||||||
assert describe() == "sensor x->x, y->z, z->-y"
|
assert describe() == "sensor x->-x, y->z, z->y"
|
||||||
|
|
||||||
|
|
||||||
def test_swapping_the_mapping_changes_the_answer():
|
def test_swapping_the_mapping_changes_the_answer():
|
||||||
@@ -154,9 +220,7 @@ def test_the_nominal_normal_needs_no_rotation_and_lands_on_plus_z():
|
|||||||
unit = unit / np.linalg.norm(unit)
|
unit = unit / np.linalg.norm(unit)
|
||||||
pose = stack.solve(np.array([0.0, 5.0, 0.0]), nominal)
|
pose = stack.solve(np.array([0.0, 5.0, 0.0]), nominal)
|
||||||
|
|
||||||
landed = rotation_about(stack.pitch_axis, pose.pitch_deg) @ rotation_about(
|
landed = stack.platform_rotation(pose.yaw_deg, pose.pitch_deg) @ unit
|
||||||
stack.yaw_axis, pose.yaw_deg
|
|
||||||
) @ unit
|
|
||||||
landed = landed / np.linalg.norm(landed)
|
landed = landed / np.linalg.norm(landed)
|
||||||
assert landed[2] > 0.0, f"the face was aimed at {np.round(landed, 3)}, not +Z"
|
assert landed[2] > 0.0, f"the face was aimed at {np.round(landed, 3)}, not +Z"
|
||||||
# And it gets there without turning: the commissioned pose is the identity.
|
# And it gets there without turning: the commissioned pose is the identity.
|
||||||
@@ -240,7 +304,7 @@ def test_the_closed_form_pitch_beats_a_direct_scan():
|
|||||||
tool = stack.tool_direction
|
tool = stack.tool_direction
|
||||||
cosine, sine = np.cos(np.radians(scan)), np.sin(np.radians(scan))
|
cosine, sine = np.cos(np.radians(scan)), np.sin(np.radians(scan))
|
||||||
stationary, first, second, sign_first, sign_second = (
|
stationary, first, second, sign_first, sign_second = (
|
||||||
stack._rotation_coefficients(stack.pitch_axis) # noqa: SLF001
|
stack._signed_coefficients(stack.pitch_axis) # noqa: SLF001
|
||||||
)
|
)
|
||||||
dot = (
|
dot = (
|
||||||
yawed[stationary] * tool[stationary]
|
yawed[stationary] * tool[stationary]
|
||||||
@@ -261,28 +325,11 @@ def _independent_residual(
|
|||||||
"""The residual worked out from raw matrices built here, not by the solver.
|
"""The residual worked out from raw matrices built here, not by the solver.
|
||||||
|
|
||||||
``residual_for`` shares ``rotation_about`` with the solver, so it cannot catch
|
``residual_for`` shares ``rotation_about`` with the solver, so it cannot catch
|
||||||
an error in that shared helper. This builds the two 3x3 matrices from the
|
an error in that shared helper. The matrices live in ``_independent_rotation``.
|
||||||
rotation formulas directly, so the two paths agree only if both are right.
|
|
||||||
"""
|
"""
|
||||||
normal = stack.normal_to_platform(np.asarray(normal_sensor, dtype=np.float64))
|
normal = stack.normal_to_platform(np.asarray(normal_sensor, dtype=np.float64))
|
||||||
unit = normal / np.linalg.norm(normal)
|
unit = normal / np.linalg.norm(normal)
|
||||||
|
turned = _independent_rotation(yaw_deg, pitch_deg) @ unit
|
||||||
yaw, pitch = np.radians(yaw_deg), np.radians(pitch_deg)
|
|
||||||
about_yaw = np.array(
|
|
||||||
[
|
|
||||||
[np.cos(yaw), -np.sin(yaw), 0.0],
|
|
||||||
[np.sin(yaw), np.cos(yaw), 0.0],
|
|
||||||
[0.0, 0.0, 1.0],
|
|
||||||
]
|
|
||||||
)
|
|
||||||
about_pitch = np.array(
|
|
||||||
[
|
|
||||||
[np.cos(pitch), 0.0, np.sin(pitch)],
|
|
||||||
[0.0, 1.0, 0.0],
|
|
||||||
[-np.sin(pitch), 0.0, np.cos(pitch)],
|
|
||||||
]
|
|
||||||
)
|
|
||||||
turned = about_pitch @ about_yaw @ unit
|
|
||||||
return float(
|
return float(
|
||||||
np.degrees(np.arccos(np.clip(float(turned @ stack.tool_direction), -1.0, 1.0)))
|
np.degrees(np.arccos(np.clip(float(turned @ stack.tool_direction), -1.0, 1.0)))
|
||||||
)
|
)
|
||||||
@@ -363,6 +410,42 @@ def test_the_yaw_limit_is_respected():
|
|||||||
assert abs(pose.yaw_deg) <= 90.0 + 1e-6
|
assert abs(pose.yaw_deg) <= 90.0 + 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_full_yaw_travel_keeps_the_second_solution_of_a_measured_point():
|
||||||
|
"""±180 is what keeps "cannot reach" from being reported for an aligned pose.
|
||||||
|
|
||||||
|
``docs/motion-correction-plan.zh.md`` measures a point at (-2.698, 5.186, 18.228)
|
||||||
|
with normal (-0.343, 0.699, 0.627). Under the drive's rotation it has two exact
|
||||||
|
solutions, -28.681° and +151.319° of yaw, and the second sits outside the ±90
|
||||||
|
note this bench was once configured with. At ±180 both branches are reachable,
|
||||||
|
so a change in which branch is preferred cannot strand the point on "cannot reach".
|
||||||
|
"""
|
||||||
|
point = np.array([-2.698, 5.186, 18.228])
|
||||||
|
normal = np.array([-0.343, 0.699, 0.627])
|
||||||
|
wide = PlatformStack(yaw_limit_deg=180.0, pitch_limit_deg=90.0)
|
||||||
|
narrow = PlatformStack(yaw_limit_deg=90.0, pitch_limit_deg=90.0)
|
||||||
|
|
||||||
|
unit = wide.setup_normal(normal)
|
||||||
|
unit = unit / float(np.linalg.norm(unit))
|
||||||
|
wide_branches = wide.alignment_branches(unit, wide.tool_direction)
|
||||||
|
narrow_branches = narrow.alignment_branches(unit, narrow.tool_direction)
|
||||||
|
|
||||||
|
assert {round(branch.yaw_deg, 3) for branch in wide_branches} == {-28.681, 151.319}
|
||||||
|
assert all(branch.in_range for branch in wide_branches)
|
||||||
|
assert sum(branch.in_range for branch in narrow_branches) == 1, (
|
||||||
|
"±90 was expected to drop one of the two exact solutions"
|
||||||
|
)
|
||||||
|
assert wide.solve(point, normal).in_range
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_yaw_limit_past_the_trunnion_travel_is_capped():
|
||||||
|
"""The stage turns ±180. A stored 360 must not widen the envelope."""
|
||||||
|
stack = PlatformStack(yaw_limit_deg=360.0, pitch_limit_deg=90.0)
|
||||||
|
assert stack._yaw_limit_effective() == 180.0 # the cap lives here, not in the caller
|
||||||
|
for normal in NORMALS:
|
||||||
|
pose = stack.solve(np.array([0.0, 5.0, 0.0]), normal)
|
||||||
|
assert abs(pose.yaw_deg) <= 180.0 + 1e-6
|
||||||
|
|
||||||
|
|
||||||
def test_the_pitch_limit_is_respected():
|
def test_the_pitch_limit_is_respected():
|
||||||
stack = PlatformStack(pitch_limit_deg=45.0)
|
stack = PlatformStack(pitch_limit_deg=45.0)
|
||||||
for normal in NORMALS:
|
for normal in NORMALS:
|
||||||
@@ -456,6 +539,20 @@ def test_the_default_tool_is_above_the_table_origin():
|
|||||||
assert pose.table_xy_mm[1] == pytest.approx(-pose.target_xy_mm[1])
|
assert pose.table_xy_mm[1] == pytest.approx(-pose.target_xy_mm[1])
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_drive_x_is_opposite_the_model_slide_and_a_little_shorter():
|
||||||
|
"""The X motor is opposite the model, and the slide is 10/11 of it.
|
||||||
|
|
||||||
|
The preview keeps the full model slide, so the drawn contact stays on the tool.
|
||||||
|
"""
|
||||||
|
from orisys_autocal.sensor.frames import TABLE_X_GAIN, machine_table_xy
|
||||||
|
|
||||||
|
pose = PlatformStack().solve(np.array([3.0, 8.0, 1.0]), np.array([0.0, 1.0, 0.0]))
|
||||||
|
drive_x, drive_y = machine_table_xy(pose.table_xy_mm)
|
||||||
|
assert drive_x == pytest.approx(-TABLE_X_GAIN * pose.table_xy_mm[0])
|
||||||
|
assert drive_y == pytest.approx(pose.table_xy_mm[1])
|
||||||
|
assert f"{drive_x:+.3f}" in pose.describe()
|
||||||
|
|
||||||
|
|
||||||
def test_the_table_move_lands_the_contact_on_the_tool():
|
def test_the_table_move_lands_the_contact_on_the_tool():
|
||||||
"""The one invariant the whole machine exists to satisfy."""
|
"""The one invariant the whole machine exists to satisfy."""
|
||||||
stack = PlatformStack(
|
stack = PlatformStack(
|
||||||
@@ -476,6 +573,10 @@ def test_the_table_move_lands_the_contact_on_the_tool():
|
|||||||
final = landed + np.array([pose.table_xy_mm[0], pose.table_xy_mm[1], 0.0])
|
final = landed + np.array([pose.table_xy_mm[0], pose.table_xy_mm[1], 0.0])
|
||||||
assert final[0] == pytest.approx(stack.tool_xy_mm[0], abs=1e-9)
|
assert final[0] == pytest.approx(stack.tool_xy_mm[0], abs=1e-9)
|
||||||
assert final[1] == pytest.approx(stack.tool_xy_mm[1], abs=1e-9)
|
assert final[1] == pytest.approx(stack.tool_xy_mm[1], abs=1e-9)
|
||||||
|
# The reported contact is that same landing point, height included — the
|
||||||
|
# whole point of the move is to arrive there, not merely at its xy.
|
||||||
|
assert pose.contact_point_mm == pytest.approx(tuple(final), abs=1e-9)
|
||||||
|
assert pose.contact_z_mm == pytest.approx(float(final[2]), abs=1e-9)
|
||||||
|
|
||||||
|
|
||||||
def test_the_mounting_offset_moves_the_point_and_the_mounting_yaw_the_whole_frame():
|
def test_the_mounting_offset_moves_the_point_and_the_mounting_yaw_the_whole_frame():
|
||||||
@@ -490,7 +591,11 @@ def test_the_mounting_offset_moves_the_point_and_the_mounting_yaw_the_whole_fram
|
|||||||
moved = shifted.solve(point, normal)
|
moved = shifted.solve(point, normal)
|
||||||
assert moved.target_xy_mm[0] - base.target_xy_mm[0] == pytest.approx(1.5)
|
assert moved.target_xy_mm[0] - base.target_xy_mm[0] == pytest.approx(1.5)
|
||||||
assert moved.target_xy_mm[1] - base.target_xy_mm[1] == pytest.approx(-2.5)
|
assert moved.target_xy_mm[1] - base.target_xy_mm[1] == pytest.approx(-2.5)
|
||||||
assert moved.contact_z_mm == pytest.approx(base.contact_z_mm)
|
# An in-plane offset leaves the mounting height alone. The stages then present
|
||||||
|
# a different height, because the same angles tilt a contact that sits somewhere
|
||||||
|
# else relative to the pitch axis.
|
||||||
|
assert moved.mounted_point_mm[2] == pytest.approx(base.mounted_point_mm[2])
|
||||||
|
assert abs(moved.contact_z_mm - base.contact_z_mm) > 0.1
|
||||||
|
|
||||||
turned = dataclasses.replace(stack, sensor_yaw_deg=35.0)
|
turned = dataclasses.replace(stack, sensor_yaw_deg=35.0)
|
||||||
yawed = turned.solve(point, normal)
|
yawed = turned.solve(point, normal)
|
||||||
@@ -500,9 +605,19 @@ def test_the_mounting_offset_moves_the_point_and_the_mounting_yaw_the_whole_fram
|
|||||||
)
|
)
|
||||||
assert yawed.contact_z_mm == pytest.approx(base.contact_z_mm)
|
assert yawed.contact_z_mm == pytest.approx(base.contact_z_mm)
|
||||||
# The mounting yaw is absorbed by the stage: it has to turn that much less.
|
# The mounting yaw is absorbed by the stage: it has to turn that much less.
|
||||||
difference = (base.yaw_deg - yawed.yaw_deg) % 360.0
|
# Mounting yaw is right-handed about Z. Positive stage yaw is the opposite
|
||||||
assert min(difference, 360.0 - difference) == pytest.approx(35.0, abs=1e-9)
|
# sense, so the same-pitch branch's command rises by the mounting angle.
|
||||||
assert yawed.pitch_deg == pytest.approx(base.pitch_deg)
|
# The shorter branch may be the other one, a half-turn away.
|
||||||
|
unit = turned.setup_normal(normal)
|
||||||
|
unit = unit / float(np.linalg.norm(unit))
|
||||||
|
same_pitch = [
|
||||||
|
branch
|
||||||
|
for branch in turned.alignment_branches(unit, turned.tool_direction)
|
||||||
|
if branch.pitch_deg == pytest.approx(base.pitch_deg)
|
||||||
|
]
|
||||||
|
assert len(same_pitch) == 1
|
||||||
|
shift = (same_pitch[0].yaw_deg - base.yaw_deg + 180.0) % 360.0 - 180.0
|
||||||
|
assert shift == pytest.approx(35.0, abs=1e-9)
|
||||||
assert yawed.residual_deg == pytest.approx(base.residual_deg, abs=1e-9)
|
assert yawed.residual_deg == pytest.approx(base.residual_deg, abs=1e-9)
|
||||||
|
|
||||||
|
|
||||||
@@ -516,7 +631,11 @@ def test_the_setup_z_is_the_mounting_plate():
|
|||||||
|
|
||||||
lifted = dataclasses.replace(stack, sensor_offset_mm=(0.0, 0.0, 4.25))
|
lifted = dataclasses.replace(stack, sensor_offset_mm=(0.0, 0.0, 4.25))
|
||||||
raised = lifted.solve(point, normal)
|
raised = lifted.solve(point, normal)
|
||||||
assert raised.contact_z_mm - plain.contact_z_mm == pytest.approx(4.25)
|
# The plate lifts the mounting pose by exactly its thickness. The height the
|
||||||
|
# stages present rises by less, because the lift is applied before the stage
|
||||||
|
# rotation and the rotation tilts it — 4.2285 mm of the 4.25 mm here.
|
||||||
|
assert raised.mounted_point_mm[2] - plain.mounted_point_mm[2] == pytest.approx(4.25)
|
||||||
|
assert 0.0 < raised.contact_z_mm - plain.contact_z_mm < 4.25
|
||||||
assert raised.target_xy_mm == pytest.approx(plain.target_xy_mm)
|
assert raised.target_xy_mm == pytest.approx(plain.target_xy_mm)
|
||||||
# And it is the same number as the plate parameter it replaced.
|
# And it is the same number as the plate parameter it replaced.
|
||||||
assert lifted.setup_point(point)[2] == pytest.approx(
|
assert lifted.setup_point(point)[2] == pytest.approx(
|
||||||
@@ -524,12 +643,22 @@ def test_the_setup_z_is_the_mounting_plate():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_bench_mount_points_the_tip_forward():
|
||||||
|
"""Yaw 180 put the tip on the near side. Zero yaw points it away."""
|
||||||
|
stack = PlatformStack(sensor_yaw_deg=0.0)
|
||||||
|
tip = stack.setup_point(np.array([0.0, 0.0, 1.0]))
|
||||||
|
cable = stack.setup_point(np.array([0.0, 0.0, -1.0]))
|
||||||
|
assert tip[:2] == pytest.approx((0.0, 1.0))
|
||||||
|
assert cable[:2] == pytest.approx((0.0, -1.0))
|
||||||
|
assert stack.setup_point(np.array([0.0, 1.0, 0.0]))[2] == pytest.approx(1.0)
|
||||||
|
|
||||||
|
|
||||||
def test_the_setup_normal_is_the_mounting_yaw_applied_to_the_mount():
|
def test_the_setup_normal_is_the_mounting_yaw_applied_to_the_mount():
|
||||||
stack = PlatformStack(sensor_yaw_deg=90.0)
|
stack = PlatformStack(sensor_yaw_deg=90.0)
|
||||||
normal = np.array([1.0, 0.0, 0.0])
|
normal = np.array([1.0, 0.0, 0.0])
|
||||||
# Sensor +x is platform +x; a 90 degree mounting yaw sends it to platform +y.
|
# Sensor +x is platform −x; a +90° mounting yaw about Z sends it to platform −y.
|
||||||
assert np.allclose(stack.setup_normal(normal), [0.0, 1.0, 0.0], atol=1e-12)
|
assert np.allclose(stack.setup_normal(normal), [0.0, -1.0, 0.0], atol=1e-12)
|
||||||
assert stack.normal_to_platform(normal) == pytest.approx([1.0, 0.0, 0.0])
|
assert stack.normal_to_platform(normal) == pytest.approx([-1.0, 0.0, 0.0])
|
||||||
|
|
||||||
|
|
||||||
def test_a_non_finite_sensor_yaw_is_refused():
|
def test_a_non_finite_sensor_yaw_is_refused():
|
||||||
@@ -569,3 +698,184 @@ def test_an_aligned_pose_reports_that_it_reached_tolerance():
|
|||||||
assert loose.reachable
|
assert loose.reachable
|
||||||
far = Pose(ok=True, residual_deg=30.0)
|
far = Pose(ok=True, residual_deg=30.0)
|
||||||
assert not far.reached_tolerance and not far.reachable
|
assert not far.reached_tolerance and not far.reachable
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------- the branch, the height and the up axis
|
||||||
|
|
||||||
|
|
||||||
|
def _independent_rotation(yaw_deg: float, pitch_deg: float) -> np.ndarray:
|
||||||
|
"""``R_pitch @ R_yaw`` for the drive directions, not through ``rotation_about``.
|
||||||
|
|
||||||
|
Positive yaw is clockwise from above. Positive pitch nods ``+Z`` toward ``−Y``.
|
||||||
|
"""
|
||||||
|
yaw, pitch = np.radians(yaw_deg), np.radians(pitch_deg)
|
||||||
|
about_yaw = np.array(
|
||||||
|
[
|
||||||
|
[np.cos(yaw), np.sin(yaw), 0.0],
|
||||||
|
[-np.sin(yaw), np.cos(yaw), 0.0],
|
||||||
|
[0.0, 0.0, 1.0],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
about_pitch = np.array(
|
||||||
|
[
|
||||||
|
[1.0, 0.0, 0.0],
|
||||||
|
[0.0, np.cos(pitch), -np.sin(pitch)],
|
||||||
|
[0.0, np.sin(pitch), np.cos(pitch)],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return about_pitch @ about_yaw
|
||||||
|
|
||||||
|
|
||||||
|
#: The two measured points from the bench screenshots, in the sensor frame:
|
||||||
|
#: point, normal, mounting height, and the height the stages present. The mounting
|
||||||
|
#: height is the plate (8 mm) over the point's own height; the presented height is
|
||||||
|
#: what the plan records as 19.051 and 21.578 mm.
|
||||||
|
MEASURED = (
|
||||||
|
((4.872, 5.444, 16.176), (0.595, 0.673, 0.439), 13.444, 19.051),
|
||||||
|
((-2.698, 5.186, 18.228), (-0.343, 0.699, 0.627), 13.186, 21.578),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _measured_stack() -> PlatformStack:
|
||||||
|
"""The bench's mounting: the plate is the mounting pose's z, 8 mm."""
|
||||||
|
return PlatformStack(sensor_offset_mm=(0.0, 0.0, 8.0))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_contact_height_is_the_height_after_the_stages_turn():
|
||||||
|
"""Defect: the pose reported the contact's height *before* the stages turned.
|
||||||
|
|
||||||
|
On the bench that is the difference between 13.444 mm and 19.051 mm — the
|
||||||
|
stages lift the contact several millimetres, and any press-axis target is
|
||||||
|
derived from the height the contact is *presented* at, not from where it sits
|
||||||
|
with the stages at zero.
|
||||||
|
"""
|
||||||
|
stack = _measured_stack()
|
||||||
|
for point, normal, mounting, presented in MEASURED:
|
||||||
|
pose = stack.solve(np.array(point), np.array(normal) / np.linalg.norm(normal))
|
||||||
|
assert pose.mounted_point_mm[2] == pytest.approx(mounting, abs=5e-4)
|
||||||
|
assert pose.contact_z_mm == pytest.approx(presented, abs=5e-4)
|
||||||
|
|
||||||
|
# The same height again, from a rotation built here rather than by the
|
||||||
|
# solver: the landing point of the reported angles.
|
||||||
|
landed = _independent_rotation(pose.yaw_deg, pose.pitch_deg) @ stack.setup_point(
|
||||||
|
np.array(point)
|
||||||
|
)
|
||||||
|
assert pose.contact_z_mm == pytest.approx(float(landed[2]), abs=1e-9)
|
||||||
|
assert pose.contact_point_mm == pytest.approx(
|
||||||
|
(stack.tool_xy_mm[0], stack.tool_xy_mm[1], float(landed[2])), abs=1e-9
|
||||||
|
)
|
||||||
|
assert pose.contact_point_mm[2] > pose.mounted_point_mm[2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_pose_aligns_the_normal_and_keeps_the_body_up():
|
||||||
|
"""Task-1 acceptance: independent rotation, tool axis and the platform's up.
|
||||||
|
|
||||||
|
"Up" is the direction the sensing face nominally aims — platform +Z, which is
|
||||||
|
where the mount sends the sensor's own up axis — not a horizontal axis of the
|
||||||
|
platform frame.
|
||||||
|
"""
|
||||||
|
stack = _measured_stack()
|
||||||
|
up = np.array([0.0, 0.0, 1.0])
|
||||||
|
for point, normal, _mounting, _presented in MEASURED:
|
||||||
|
pose = stack.solve(np.array(point), np.array(normal) / np.linalg.norm(normal))
|
||||||
|
rotate = _independent_rotation(pose.yaw_deg, pose.pitch_deg)
|
||||||
|
|
||||||
|
aligned = rotate @ stack.setup_normal(np.array(normal))
|
||||||
|
angle = np.degrees(np.arccos(np.clip(float(aligned @ up), -1.0, 1.0)))
|
||||||
|
assert angle <= 1e-5
|
||||||
|
|
||||||
|
# ``uprightness`` is that same rotation carried onto the up axis: the
|
||||||
|
# component left pointing up, 1.0 when nothing has tipped at all.
|
||||||
|
assert stack.uprightness(pose.yaw_deg, pose.pitch_deg) == pytest.approx(
|
||||||
|
float((rotate @ up)[2]), abs=1e-12
|
||||||
|
)
|
||||||
|
assert 0.0 < stack.uprightness(pose.yaw_deg, pose.pitch_deg) <= 1.0
|
||||||
|
assert pose.upright
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_exact_poses_of_a_measured_point_leave_the_sensor_face_up():
|
||||||
|
"""Defect: the up test measured the body against a *horizontal* axis.
|
||||||
|
|
||||||
|
The two exact poses of a measured point are one pose rolled 180 degrees about
|
||||||
|
the vertical: same contact, same height, mirrored azimuth. Neither turns the
|
||||||
|
sensing face over, so flagging one of them inverted was the bug — and the
|
||||||
|
panel, the preview and the guards all read that flag.
|
||||||
|
"""
|
||||||
|
stack = PlatformStack()
|
||||||
|
first = _independent_rotation(-143.580, 47.692)
|
||||||
|
second = _independent_rotation(36.420, -47.692)
|
||||||
|
assert second == pytest.approx(rotation_about("z", 180.0) @ first, abs=1e-9)
|
||||||
|
|
||||||
|
for yaw_deg, pitch_deg in (
|
||||||
|
(36.420, -47.692),
|
||||||
|
(-143.580, 47.692),
|
||||||
|
(-61.319, 45.636),
|
||||||
|
(118.681, -45.636),
|
||||||
|
):
|
||||||
|
assert stack.uprightness(yaw_deg, pitch_deg) > 0.0
|
||||||
|
assert stack.ends_upright(yaw_deg, pitch_deg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_equivalent_poses_are_chosen_by_the_machine_not_by_the_residual():
|
||||||
|
"""Two exact poses differ by float noise; that must not decide the answer.
|
||||||
|
|
||||||
|
The right-hand screenshot point, both ways round: 84 degrees of stage travel
|
||||||
|
against 191, and the shorter one carries the marginally *larger* residual.
|
||||||
|
"""
|
||||||
|
short = Branch(
|
||||||
|
yaw_deg=36.420, pitch_deg=-47.692, residual_deg=1e-9, upright=True, in_range=True
|
||||||
|
)
|
||||||
|
long_way = Branch(
|
||||||
|
yaw_deg=-143.580, pitch_deg=47.692, residual_deg=0.0, upright=True, in_range=True
|
||||||
|
)
|
||||||
|
assert PlatformStack.select_branch([long_way, short]) is short
|
||||||
|
assert PlatformStack.branch_reason([long_way, short], short) == "smallest"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_materially_better_alignment_still_wins():
|
||||||
|
"""Equivalent is a tolerance, not an excuse to ignore the residual."""
|
||||||
|
coarse = Branch(yaw_deg=0.0, pitch_deg=0.0, residual_deg=3.0, upright=True, in_range=True)
|
||||||
|
exact = Branch(yaw_deg=80.0, pitch_deg=40.0, residual_deg=0.05, upright=True, in_range=True)
|
||||||
|
assert PlatformStack.select_branch([coarse, exact]) is exact
|
||||||
|
assert PlatformStack.branch_reason([coarse, exact], exact) == "alignment"
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_roundoff_does_not_choose_a_half_turn_over_the_shorter_pose():
|
||||||
|
stack = PlatformStack(pitch_limit_deg=90)
|
||||||
|
normal = np.array([-0.499590704094966, 0.6734102426572587, -0.5449107940444822])
|
||||||
|
unit = stack.setup_normal(normal)
|
||||||
|
branches = stack.alignment_branches(unit, stack.tool_direction)
|
||||||
|
short = min(branches, key=lambda branch: branch.travel_deg)
|
||||||
|
for branch in branches:
|
||||||
|
turned = stack.platform_rotation(branch.yaw_deg, branch.pitch_deg) @ unit
|
||||||
|
np.testing.assert_allclose(turned, [0, 0, 1], atol=1e-12)
|
||||||
|
assert stack.select_branch(branches) is short
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_branch_keys_are_applied_in_the_order_the_machine_cares_about():
|
||||||
|
"""The envelope, then which way the body ends up, then the shorter turn."""
|
||||||
|
inside = Branch(
|
||||||
|
yaw_deg=-61.319, pitch_deg=45.636, residual_deg=0.0, upright=True, in_range=True
|
||||||
|
)
|
||||||
|
outside = Branch(
|
||||||
|
yaw_deg=0.0, pitch_deg=0.0, residual_deg=0.0, upright=True, in_range=False
|
||||||
|
)
|
||||||
|
assert PlatformStack.select_branch([inside, outside]) is inside
|
||||||
|
assert PlatformStack.branch_reason([inside, outside], inside) == "in_range"
|
||||||
|
|
||||||
|
upright = Branch(
|
||||||
|
yaw_deg=118.681, pitch_deg=-45.636, residual_deg=0.0, upright=True, in_range=True
|
||||||
|
)
|
||||||
|
rolled = Branch(
|
||||||
|
yaw_deg=10.0, pitch_deg=10.0, residual_deg=0.0, upright=False, in_range=True
|
||||||
|
)
|
||||||
|
assert PlatformStack.select_branch([upright, rolled]) is upright
|
||||||
|
assert PlatformStack.branch_reason([upright, rolled], upright) == "upright"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_lone_branch_is_its_own_reason_and_an_empty_list_is_refused():
|
||||||
|
only = Branch(yaw_deg=10.0, pitch_deg=10.0, residual_deg=0.0, upright=True, in_range=True)
|
||||||
|
assert PlatformStack.select_branch([only]) is only
|
||||||
|
assert PlatformStack.branch_reason([only], only) == "only"
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
PlatformStack.select_branch([])
|
||||||
|
|||||||
@@ -122,6 +122,41 @@ def test_a_stored_block_without_a_new_limit_still_gets_it():
|
|||||||
assert "press_axis" not in prefs["sensor_setup"]
|
assert "press_axis" not in prefs["sensor_setup"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stored_quarter_turn_yaw_limit_is_normalised_to_the_bench_travel():
|
||||||
|
"""A stored ±90 makes a solvable pose read as unreachable.
|
||||||
|
|
||||||
|
The yaw stage turns ±180, and a measured point's second exact solution sits past
|
||||||
|
118° of yaw: at ±90 the solver is left with one branch and the move guard refuses
|
||||||
|
the other, so a pose the preview aligned reports "cannot reach". Both spellings of
|
||||||
|
the limit — the solver's ``finger_yaw_limit_deg`` and the guard's
|
||||||
|
``safety_limits.max_yaw_deg`` — are the same bench fact, so both are normalised.
|
||||||
|
"""
|
||||||
|
from orisys_app_shared.paths import preference_path
|
||||||
|
|
||||||
|
from orisys_autocal.preferences import load_preferences
|
||||||
|
|
||||||
|
path = preference_path()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"safety_limits": {"max_yaw_deg": 90.0, "max_pitch_deg": 360.0},
|
||||||
|
"finger_yaw_limit_deg": 90.0,
|
||||||
|
"finger_pitch_limit_deg": 360.0,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
prefs = load_preferences()
|
||||||
|
|
||||||
|
assert prefs["safety_limits"]["max_yaw_deg"] == 180.0
|
||||||
|
assert prefs["finger_yaw_limit_deg"] == 180.0
|
||||||
|
# The pitch envelope keeps the correction it already had, so the two do not drift.
|
||||||
|
assert prefs["safety_limits"]["max_pitch_deg"] == 90.0
|
||||||
|
assert prefs["finger_pitch_limit_deg"] == 90.0
|
||||||
|
|
||||||
|
|
||||||
def _walk(node, prefix: str = ""):
|
def _walk(node, prefix: str = ""):
|
||||||
for key, value in node.items():
|
for key, value in node.items():
|
||||||
path = f"{prefix}{key}"
|
path = f"{prefix}{key}"
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
"""A manually confirmed site produces one traceable trial directory."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from dataclasses import replace
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from orisys_app_shared.acquisition.models import SdkPacket
|
||||||
|
from orisys_autocal.trial import (
|
||||||
|
REFERENCE_HEADER,
|
||||||
|
ManualTrial,
|
||||||
|
load_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
from orisys_autocal.sensor.uv_flat import FlatSurface, site_at, uv_origin
|
||||||
|
|
||||||
|
|
||||||
|
class Worker:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.is_running = True
|
||||||
|
self.sinks = []
|
||||||
|
self.packet = SdkPacket(
|
||||||
|
raw_frame=np.zeros((32, 32, 3), dtype=np.uint8),
|
||||||
|
processed_frame=np.zeros((32, 32, 3), dtype=np.uint8),
|
||||||
|
timestamp=time.time(),
|
||||||
|
fps=30,
|
||||||
|
process_ms=1,
|
||||||
|
session_id=2,
|
||||||
|
frame_id=1,
|
||||||
|
mono_t=1.0,
|
||||||
|
capture_mono_ns=time.perf_counter_ns(),
|
||||||
|
capture_utc_ns=time.time_ns(),
|
||||||
|
source_frame_id=7,
|
||||||
|
time_basis="host_camera",
|
||||||
|
normal_force_raw=1.25,
|
||||||
|
)
|
||||||
|
|
||||||
|
def latest(self):
|
||||||
|
return self.packet
|
||||||
|
|
||||||
|
def add_sink(self, sink):
|
||||||
|
self.sinks.append(sink)
|
||||||
|
|
||||||
|
def remove_sink(self, sink):
|
||||||
|
self.sinks.remove(sink)
|
||||||
|
|
||||||
|
def emit(self):
|
||||||
|
for sink in self.sinks:
|
||||||
|
sink(self.packet)
|
||||||
|
|
||||||
|
def stop(self, *, wait=True):
|
||||||
|
self.is_running = False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def flat():
|
||||||
|
mask = np.full((5, 7), -1, dtype=np.int32)
|
||||||
|
mask[1:4, 2:5] = 2
|
||||||
|
mask[4, 2] = 2 # A valid pixel may be below the centre-column origin.
|
||||||
|
positions = np.zeros((5, 7, 3), dtype=np.float64)
|
||||||
|
for y in range(5):
|
||||||
|
for x in range(7):
|
||||||
|
positions[y, x] = (x, y, 3)
|
||||||
|
normals = np.zeros_like(positions)
|
||||||
|
normals[:, :, 2] = 1
|
||||||
|
return FlatSurface((5, 7), np.zeros((5, 7, 3), dtype=np.uint8), positions,
|
||||||
|
normals, mask, (0, 0), (1, 1))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def clock(monkeypatch):
|
||||||
|
from orisys_autocal import trial as trial_module
|
||||||
|
|
||||||
|
class Clock:
|
||||||
|
now = time.perf_counter_ns()
|
||||||
|
|
||||||
|
def advance(self, seconds):
|
||||||
|
self.now += round(seconds * 1e9)
|
||||||
|
|
||||||
|
value = Clock()
|
||||||
|
monkeypatch.setattr(trial_module, "time", SimpleNamespace(
|
||||||
|
perf_counter_ns=lambda: value.now, time_ns=time.time_ns))
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def trial_setup(tmp_path, flat):
|
||||||
|
bundle = tmp_path / "bundle"
|
||||||
|
bundle.mkdir()
|
||||||
|
(bundle / "mesh.npz").write_bytes(b"mesh")
|
||||||
|
config = tmp_path / "sensor.json"
|
||||||
|
config.write_text("{}", encoding="utf-8")
|
||||||
|
return dict(root=tmp_path, site=site_at(flat, -1, 2), levels=[5, 10],
|
||||||
|
hold_s=20, sensor_model="Finger D40", sensor_sn="F-001",
|
||||||
|
bundle_path=str(bundle), source={"kind": "camera", "value": 0},
|
||||||
|
config_ref=str(config), fps=30)
|
||||||
|
|
||||||
|
|
||||||
|
def test_uv_coordinates_are_centered_and_masked(flat):
|
||||||
|
assert uv_origin(flat) == (3, 3)
|
||||||
|
site = site_at(flat, -1, 2)
|
||||||
|
assert site.raster_xy_px == (2, 1)
|
||||||
|
assert site.position_sensor_mm == (2.0, 1.0, 3.0)
|
||||||
|
assert site_at(flat, -1, -1).raster_xy_px == (2, 4)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
site_at(flat, -3, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trial_persists_colour_region_outline_and_rejects_mismatched_mapping(trial_setup, flat):
|
||||||
|
from orisys_autocal.sensor.uv_outline import outline_snapshot
|
||||||
|
|
||||||
|
flat.textured = True
|
||||||
|
flat.image_rgb[:] = 255
|
||||||
|
flat.image_rgb[2:4, 2:5] = [20, 80, 200]
|
||||||
|
snapshot = outline_snapshot(flat)
|
||||||
|
worker = Worker()
|
||||||
|
trial = ManualTrial(worker)
|
||||||
|
with pytest.raises(ValueError, match="outline"):
|
||||||
|
trial.start(**trial_setup, uv_outline={**snapshot, "mapping_sha256": "wrong"})
|
||||||
|
directory = trial.start(**trial_setup, uv_outline=snapshot)
|
||||||
|
worker.emit()
|
||||||
|
trial.finish(aborted=True, reason="test ended")
|
||||||
|
trial.recorder.stop(wait=True)
|
||||||
|
trial.poll()
|
||||||
|
manifest = json.loads((directory.parent.parent / "session.json").read_text(encoding="utf-8"))
|
||||||
|
assert manifest["uv_outline"] == snapshot and snapshot["patch"]
|
||||||
|
trial.finish_session()
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_trial_records_video_stamps_and_load_unload_events(trial_setup, clock):
|
||||||
|
worker = Worker()
|
||||||
|
trial = ManualTrial(worker)
|
||||||
|
assert load_path([5, 10]) == [
|
||||||
|
("zero", 0), ("load", 5), ("load", 10), ("unload", 5), ("unload", 0)]
|
||||||
|
directory = trial.start(**trial_setup)
|
||||||
|
worker.emit()
|
||||||
|
for index, value in enumerate((0, 5, 10, 5, 0)):
|
||||||
|
assert trial.step_index == index
|
||||||
|
trial.confirm(value)
|
||||||
|
assert trial.step_index == index
|
||||||
|
with pytest.raises(RuntimeError, match="hold"):
|
||||||
|
trial.confirm(value)
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
trial.finish()
|
||||||
|
clock.advance(19)
|
||||||
|
trial.poll()
|
||||||
|
assert trial.holding
|
||||||
|
assert trial.remaining_s == pytest.approx(1)
|
||||||
|
clock.advance(3) # Late UI polling must not extend the reference window.
|
||||||
|
trial.poll()
|
||||||
|
assert trial.step_index == index + 1
|
||||||
|
assert not trial.holding
|
||||||
|
clock.advance(4) # Adjustment time must have no constant-force label.
|
||||||
|
trial.finish()
|
||||||
|
assert trial.recorder.stop(wait=True).ok
|
||||||
|
assert trial.poll() == "complete"
|
||||||
|
assert directory.name == "trial-001"
|
||||||
|
assert "Finger-D40_F-001" in directory.parent.parent.name
|
||||||
|
assert len(list(directory.glob("raw_*.mp4"))) == 1
|
||||||
|
with (directory / "frames.csv").open(encoding="utf-8", newline="") as handle:
|
||||||
|
row = next(csv.DictReader(handle))
|
||||||
|
assert row["t_host_mono_ns"] == str(worker.packet.capture_mono_ns)
|
||||||
|
assert row["source_frame_id"] == "7"
|
||||||
|
assert row["time_basis"] == "host_camera"
|
||||||
|
with (directory / "reference.csv").open(encoding="utf-8", newline="") as handle:
|
||||||
|
reader = csv.DictReader(handle)
|
||||||
|
assert tuple(reader.fieldnames) == REFERENCE_HEADER
|
||||||
|
references = list(reader)
|
||||||
|
assert [row["branch"] for row in references] == ["zero", "load", "load", "unload", "unload"]
|
||||||
|
assert [float(row["F_ref_normal_N"]) for row in references] == [0, 5, 10, 5, 0]
|
||||||
|
assert all(row["F_ref_x_N"] == row["F_ref_y_N"] == row["F_ref_z_N"] == "" for row in references)
|
||||||
|
assert all(row["stability_verified"] == "0" and row["hold_complete"] == "1" for row in references)
|
||||||
|
assert all(int(r["t_end_host_mono_ns"]) - int(r["t_start_host_mono_ns"]) == 20_000_000_000 for r in references)
|
||||||
|
assert all(int(a["t_end_host_mono_ns"]) < int(b["t_start_host_mono_ns"]) for a, b in zip(references, references[1:]))
|
||||||
|
with (directory / "session.json").open(encoding="utf-8") as handle:
|
||||||
|
manifest = json.load(handle)
|
||||||
|
assert manifest["trial_status"] == "complete"
|
||||||
|
assert manifest["site"]["uv_xy_px"] == [-1, 2]
|
||||||
|
assert len(manifest["steps"]) == 5
|
||||||
|
assert manifest["uv_definition"]["mapping_sha256"] == trial_setup["site"].mapping_sha256
|
||||||
|
session_path = directory.parent.parent / "session.json"
|
||||||
|
root = json.loads(session_path.read_text(encoding="utf-8"))
|
||||||
|
assert root["status"] == "in_progress"
|
||||||
|
assert root["trials"][0]["status"] == "complete"
|
||||||
|
assert trial.finish_session()[1] == "complete"
|
||||||
|
assert json.loads(session_path.read_text(encoding="utf-8"))["status"] == "complete"
|
||||||
|
|
||||||
|
|
||||||
|
def test_retries_and_other_sites_share_one_session(trial_setup, clock):
|
||||||
|
worker = Worker()
|
||||||
|
trial = ManualTrial(worker)
|
||||||
|
directories = []
|
||||||
|
for site in (trial_setup["site"], trial_setup["site"],
|
||||||
|
replace(trial_setup["site"], u_px=0, raster_xy_px=(3, 1), position_sensor_mm=(3, 1, 3))):
|
||||||
|
directory = trial.start(**{**trial_setup, "site": site})
|
||||||
|
directories.append(directory)
|
||||||
|
worker.emit()
|
||||||
|
trial.confirm(0)
|
||||||
|
clock.advance(3)
|
||||||
|
trial.finish(aborted=True, reason="operator ended early")
|
||||||
|
trial.recorder.stop(wait=True)
|
||||||
|
assert trial.poll() == "incomplete"
|
||||||
|
rows = list(csv.DictReader((directory / "reference.csv").open(encoding="utf-8")))
|
||||||
|
assert rows[0]["hold_complete"] == "0"
|
||||||
|
assert len({d.parent.parent for d in directories}) == 1
|
||||||
|
root = trial.session.document
|
||||||
|
assert len(root["sites"]) == 2
|
||||||
|
assert [t["repeat_index"] for t in root["trials"]] == [1, 2, 1]
|
||||||
|
assert [t["site_id"] for t in root["trials"]] == ["site-001", "site-001", "site-002"]
|
||||||
|
with pytest.raises(ValueError, match="finish this session"):
|
||||||
|
trial.start(**{**trial_setup, "sensor_sn": "F-002"})
|
||||||
|
with pytest.raises(ValueError, match="finish this session"):
|
||||||
|
trial.start(**{**trial_setup, "site": replace(site, mapping_sha256="other")})
|
||||||
|
assert trial.finish_session()[1] == "incomplete"
|
||||||
|
new_directory = trial.start(**trial_setup)
|
||||||
|
assert new_directory.parent.parent != directories[0].parent.parent
|
||||||
|
trial.finish(aborted=True)
|
||||||
|
trial.recorder.stop(wait=True)
|
||||||
|
trial.poll()
|
||||||
|
trial.finish_session()
|
||||||
|
|
||||||
|
|
||||||
|
def test_input_end_aborts_active_hold(trial_setup, clock):
|
||||||
|
worker = Worker()
|
||||||
|
worker.packet = replace(worker.packet, time_basis="host_replay")
|
||||||
|
trial = ManualTrial(worker)
|
||||||
|
directory = trial.start(**trial_setup)
|
||||||
|
assert not trial.reference_alignment_possible
|
||||||
|
worker.emit()
|
||||||
|
trial.confirm(0)
|
||||||
|
clock.advance(2)
|
||||||
|
worker.is_running = False
|
||||||
|
trial.poll()
|
||||||
|
trial.recorder.stop(wait=True)
|
||||||
|
assert trial.poll() == "incomplete"
|
||||||
|
assert trial.entry["reason"] == "input or writer stopped"
|
||||||
|
assert json.loads((directory / "session.json").read_text(encoding="utf-8"))["steps"][0]["status"] == "incomplete"
|
||||||
|
trial.finish_session()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_trial_recorded_without_video_finishes_complete(trial_setup, clock):
|
||||||
|
"""Data-only recording: the force rows are the evidence and the verdict says so."""
|
||||||
|
from orisys_autocal.recording_options import RecordingOptions
|
||||||
|
|
||||||
|
worker = Worker()
|
||||||
|
trial = ManualTrial(worker)
|
||||||
|
directory = trial.start(**{**trial_setup, "recording": RecordingOptions(video=False)})
|
||||||
|
|
||||||
|
for index, (_branch, target) in enumerate(trial.steps):
|
||||||
|
trial.confirm(target)
|
||||||
|
# Two frames inside the second half of the hold: the summary's own rule.
|
||||||
|
# Each frame carries its own id: a repeated id has no single acquisition
|
||||||
|
# time and the review would exclude it.
|
||||||
|
for delay, frame_id in ((15, index * 2 + 2), (1, index * 2 + 3)):
|
||||||
|
clock.advance(delay)
|
||||||
|
worker.packet = replace(
|
||||||
|
worker.packet, capture_mono_ns=clock.now,
|
||||||
|
frame_id=frame_id, source_frame_id=frame_id,
|
||||||
|
)
|
||||||
|
worker.emit()
|
||||||
|
clock.advance(4)
|
||||||
|
trial.poll()
|
||||||
|
assert trial.step_index == index + 1
|
||||||
|
|
||||||
|
trial.finish()
|
||||||
|
assert trial.recorder.stop(wait=True).ok
|
||||||
|
assert trial.poll() == "complete"
|
||||||
|
assert not list(directory.glob("raw_*.mp4"))
|
||||||
|
root = json.loads(
|
||||||
|
(directory.parent.parent / "session.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
assert root["recording"] == {
|
||||||
|
"video": False, "codec": "mp4v", "lossless_frames": False}
|
||||||
|
|
||||||
|
from orisys_autocal.summary import build_summary
|
||||||
|
|
||||||
|
summary = build_summary(directory.parent.parent)
|
||||||
|
assert all(row["eligible"] for row in summary["windows"]), [
|
||||||
|
(row["excluded_reason"], row["capture_status"], row["samples_valid"])
|
||||||
|
for row in summary["windows"]
|
||||||
|
]
|
||||||
|
assert all(row["capture_status"] == "complete" for row in summary["windows"])
|
||||||
|
trial.finish_session()
|
||||||
|
|
||||||
|
|
||||||
|
def test_recording_settings_are_locked_by_the_first_trial(trial_setup, clock):
|
||||||
|
"""One session, one recording choice: the manifest must not change mid-session."""
|
||||||
|
from orisys_autocal.recording_options import RecordingOptions
|
||||||
|
|
||||||
|
worker = Worker()
|
||||||
|
trial = ManualTrial(worker)
|
||||||
|
directory = trial.start(
|
||||||
|
**{**trial_setup, "recording": RecordingOptions(video=False)}
|
||||||
|
)
|
||||||
|
root = json.loads(
|
||||||
|
(directory.parent.parent / "session.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
assert root["recording"] == {
|
||||||
|
"video": False, "codec": "mp4v", "lossless_frames": False}
|
||||||
|
|
||||||
|
worker.emit()
|
||||||
|
trial.finish(aborted=True, reason="test ended")
|
||||||
|
trial.recorder.stop(wait=True)
|
||||||
|
trial.poll()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="locked"):
|
||||||
|
trial.start(**{**trial_setup, "recording": RecordingOptions(video=True)})
|
||||||
|
# The locked settings are what a later trial of this session uses.
|
||||||
|
second = trial.start(
|
||||||
|
**{**trial_setup, "recording": RecordingOptions(video=False)}
|
||||||
|
)
|
||||||
|
assert trial.recording.video is False
|
||||||
|
assert second.parent.parent == directory.parent.parent
|
||||||
|
trial.recorder.stop(wait=True)
|
||||||
|
trial.poll()
|
||||||
|
trial.finish_session()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_capture_page_no_longer_drives_a_manual_trial(make_window):
|
||||||
|
"""D1: a new capture is a one-point queue in the ``sites/`` format.
|
||||||
|
|
||||||
|
The ManualTrial flow stays only so old ``trials/`` sessions keep reading; the
|
||||||
|
capture page owns a Campaign plus the workflow controller. The two-page
|
||||||
|
single/multi-point split it used to switch between is gone.
|
||||||
|
"""
|
||||||
|
window = make_window()
|
||||||
|
page = window.calibration_panel
|
||||||
|
assert not hasattr(page, "trial")
|
||||||
|
assert not hasattr(page, "multipoint")
|
||||||
|
assert page.campaign is not None
|
||||||
|
assert page.controller is not None
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_capture_page_keeps_the_picked_uv_point_while_the_live_view_is_visible(
|
||||||
|
make_window, qt_app
|
||||||
|
):
|
||||||
|
"""Both images are on the page at once; picking on the map keeps the point."""
|
||||||
|
from orisys_autocal.sensor.uv_flat import FlatSurface
|
||||||
|
|
||||||
|
window = make_window()
|
||||||
|
window.resize(1440, 900)
|
||||||
|
window.show()
|
||||||
|
qt_app.processEvents()
|
||||||
|
window.workspace.set_current_key("sensor")
|
||||||
|
qt_app.processEvents()
|
||||||
|
mask = np.full((5, 7), -1, dtype=np.int32)
|
||||||
|
mask[1:4, 2:5] = 2
|
||||||
|
positions = np.zeros((5, 7, 3), dtype=np.float64)
|
||||||
|
positions[1, 2] = (2, 1, 3)
|
||||||
|
flat = FlatSurface(
|
||||||
|
size_hw=(5, 7),
|
||||||
|
image_rgb=np.zeros((5, 7, 3), dtype=np.uint8),
|
||||||
|
positions_mm=positions,
|
||||||
|
normals=np.zeros_like(positions),
|
||||||
|
face_index=mask,
|
||||||
|
uv_min=(0, 0),
|
||||||
|
uv_max=(1, 1),
|
||||||
|
)
|
||||||
|
page = window.calibration_panel
|
||||||
|
page.set_flat(flat, "bundle")
|
||||||
|
page._picked(2, 1)
|
||||||
|
assert (page.u_spin.value(), page.v_spin.value()) == (-1, 2)
|
||||||
|
assert page._site.raster_xy_px == (2, 1)
|
||||||
|
assert "2.000" in page.position_value.text()
|
||||||
|
# The UV map and the live 2-D image are visible in the same row: selecting one
|
||||||
|
# used to hide the other behind a tab.
|
||||||
|
assert page.flat_view.isVisible() and page.live_view.isVisible()
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_session_row_opens_the_folder_the_summary_and_the_path(
|
||||||
|
make_window, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""The operator can reach the recording without copying a path by hand."""
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
|
from evidence_helpers import multipoint_session
|
||||||
|
from orisys_autocal.summary import build_summary
|
||||||
|
from orisys_autocal.summary_export import publish_summary
|
||||||
|
from orisys_autocal.ui import calibration_panel as panel_module
|
||||||
|
|
||||||
|
window = make_window()
|
||||||
|
panel = window.calibration_panel
|
||||||
|
opened: list = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
panel_module, "reveal", lambda path, select=None: opened.append((path, select)) or True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Nothing has been recorded yet: each action is disabled and says why.
|
||||||
|
panel._refresh()
|
||||||
|
assert not panel.open_session_button.isEnabled()
|
||||||
|
assert not panel.open_summary_button.isEnabled()
|
||||||
|
assert not panel.copy_path_button.isEnabled()
|
||||||
|
assert panel.open_session_button.toolTip() == t("cap.block.no_directory")
|
||||||
|
assert panel.open_summary_button.toolTip() == t("cap.block.no_summary")
|
||||||
|
|
||||||
|
root = multipoint_session(tmp_path / "session", sites=("P1",), repeats=1, levels=(5.0,))
|
||||||
|
publish_summary(root, build_summary(root))
|
||||||
|
# The page finds the session the campaign is recording into.
|
||||||
|
panel.campaign.directory = root
|
||||||
|
panel._refresh()
|
||||||
|
|
||||||
|
assert panel._session_directory() == root
|
||||||
|
assert panel.open_session_button.isEnabled()
|
||||||
|
assert panel.open_summary_button.isEnabled()
|
||||||
|
assert panel.copy_path_button.isEnabled()
|
||||||
|
assert panel.open_session_button.toolTip() == t("cap.action.open_session")
|
||||||
|
|
||||||
|
panel.open_session_button.click()
|
||||||
|
assert opened[-1][0] == root
|
||||||
|
panel.open_summary_button.click()
|
||||||
|
assert opened[-1][0] == root / "summary"
|
||||||
|
panel.copy_path_button.click()
|
||||||
|
assert QApplication.clipboard().text() == str(root)
|
||||||
|
window.close()
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"""Keep commissioned command numbers; reject unverified physical-unit moves."""
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from orisys_autocal.motion.models import (
|
||||||
|
AxisParams,
|
||||||
|
AxisState,
|
||||||
|
AxisStatusBits,
|
||||||
|
BusStatus,
|
||||||
|
ConnectionSpec,
|
||||||
|
MotionResult,
|
||||||
|
Snapshot,
|
||||||
|
)
|
||||||
|
from orisys_autocal.motion.pose_move import PoseMover, plan_pose_move
|
||||||
|
from orisys_autocal.motion.service import MotionService
|
||||||
|
from orisys_autocal.motion.transport import DemoTransport
|
||||||
|
from orisys_autocal.motion.units import deg_to_user_units, user_units_to_deg
|
||||||
|
from orisys_app_shared.zmotion import ZAuxError
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingTransport(DemoTransport):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.moves = []
|
||||||
|
|
||||||
|
def move_abs(self, axis, value):
|
||||||
|
self.moves.append((axis, value))
|
||||||
|
return super().move_abs(axis, value)
|
||||||
|
|
||||||
|
|
||||||
|
def service():
|
||||||
|
transport = RecordingTransport()
|
||||||
|
instance = MotionService(
|
||||||
|
transport,
|
||||||
|
language="en_US",
|
||||||
|
limits={"max_travel_mm": 100.0, "max_yaw_deg": 180.0, "max_pitch_deg": 90.0},
|
||||||
|
)
|
||||||
|
instance.connect(ConnectionSpec(mode="demo"))
|
||||||
|
instance.poll()
|
||||||
|
instance._bus = BusStatus(init_status=1, total_axes=5)
|
||||||
|
return instance, transport
|
||||||
|
|
||||||
|
|
||||||
|
def mover_and_actions(instance, targets):
|
||||||
|
actions = []
|
||||||
|
|
||||||
|
def submit(action):
|
||||||
|
actions.append(action)
|
||||||
|
return len(actions)
|
||||||
|
|
||||||
|
mover = PoseMover(instance, submit)
|
||||||
|
plan = plan_pose_move(instance, targets, together=True)
|
||||||
|
assert mover.start(plan).ok
|
||||||
|
return mover, actions
|
||||||
|
|
||||||
|
|
||||||
|
def test_commissioned_command_numbers_and_order_remain_exact(qt_app):
|
||||||
|
instance, transport = service()
|
||||||
|
mover, actions = mover_and_actions(
|
||||||
|
instance, {"x": -10.0, "y": 2.0, "yaw": 90.0, "pitch": -45.0, "z": 3.0}
|
||||||
|
)
|
||||||
|
assert actions[0](instance).ok
|
||||||
|
assert transport.moves == [
|
||||||
|
(2, 12500.0),
|
||||||
|
(3, -14750.0),
|
||||||
|
(0, -5000.0),
|
||||||
|
(1, 1000.0),
|
||||||
|
(4, 1500.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_readback_mismatch_refuses_instead_of_changing_the_command():
|
||||||
|
instance, transport = service()
|
||||||
|
transport.set_units(0, 5.0)
|
||||||
|
instance.poll([0])
|
||||||
|
instance._bus = BusStatus(init_status=1, total_axes=5)
|
||||||
|
refusal = instance.check_axes_move({"x": 2.0})
|
||||||
|
assert refusal is not None and "UNITS" in refusal.message
|
||||||
|
assert math.isnan(instance.to_mm(0, 1000.0))
|
||||||
|
assert transport.moves == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_matching_linear_units_are_supported_without_rescaling_other_axes():
|
||||||
|
instance, transport = service()
|
||||||
|
assert instance.set_axis_params(0, AxisParams(units=5.0)).ok
|
||||||
|
instance.poll([0])
|
||||||
|
instance._bus = BusStatus(init_status=1, total_axes=5)
|
||||||
|
assert instance.check_axes_move({"x": 2.0}) is None
|
||||||
|
assert instance.to_units("x", 2.0) == 2000.0
|
||||||
|
assert instance.to_mm(0, 1000.0) == 1.0
|
||||||
|
assert instance.to_units("yaw", 90.0) == 12500.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("units", [0.0, -1.0, math.nan, math.inf])
|
||||||
|
def test_invalid_unit_readback_is_not_a_physical_measurement(units):
|
||||||
|
instance, transport = service()
|
||||||
|
transport.set_units(0, units)
|
||||||
|
instance.poll([0])
|
||||||
|
instance._bus = BusStatus(init_status=1, total_axes=5)
|
||||||
|
assert instance.check_axes_move({"x": 2.0}) is not None
|
||||||
|
assert math.isnan(instance.to_mm(0, 1000.0))
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_or_failed_readback_cannot_reuse_the_last_good_scale():
|
||||||
|
instance, transport = service()
|
||||||
|
instance._last_axes.clear()
|
||||||
|
assert instance.check_axes_move({"x": 2.0}) is not None
|
||||||
|
instance.poll([0])
|
||||||
|
instance._last_axes[0] = replace(instance._last_axes[0], read_ok=False)
|
||||||
|
assert instance.check_axes_move({"x": 2.0}) is not None
|
||||||
|
assert math.isnan(instance.to_mm(0, 1000.0))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("axis,role", [(2, "yaw"), (3, "pitch")])
|
||||||
|
def test_changed_rotary_pulse_equivalent_requires_new_calibration(axis, role):
|
||||||
|
instance, transport = service()
|
||||||
|
assert instance.set_axis_params(axis, AxisParams(units=5.0)).ok
|
||||||
|
instance.poll([axis])
|
||||||
|
instance._bus = BusStatus(init_status=1, total_axes=5)
|
||||||
|
refusal = instance.check_axes_move({role: 45.0})
|
||||||
|
assert refusal is not None and "calibrat" in refusal.message
|
||||||
|
assert math.isnan(instance.to_units(role, 45.0))
|
||||||
|
assert math.isnan(instance.to_deg(axis, 1000.0))
|
||||||
|
assert math.isnan(user_units_to_deg(axis, 1000.0, units=5.0))
|
||||||
|
assert math.isnan(deg_to_user_units(axis, 45.0, units=5.0))
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_after_queueing_is_refused_before_any_axis_moves(qt_app):
|
||||||
|
instance, transport = service()
|
||||||
|
mover, actions = mover_and_actions(instance, {"x": 2.0, "yaw": 10.0})
|
||||||
|
# No poll between the external parameter change and worker execution.
|
||||||
|
transport.set_units(0, 5.0)
|
||||||
|
result = actions[0](instance)
|
||||||
|
assert not result.ok
|
||||||
|
mover.on_command_finished(1, result)
|
||||||
|
assert not mover.running and "UNITS" in mover.state.failure
|
||||||
|
assert transport.moves == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrelated_command_result_does_not_cancel_a_pose_move(qt_app):
|
||||||
|
from orisys_autocal.motion.models import MotionResult
|
||||||
|
|
||||||
|
instance, transport = service()
|
||||||
|
mover, actions = mover_and_actions(instance, {"x": 2.0})
|
||||||
|
mover.on_command_finished(99, MotionResult.refused("another command"))
|
||||||
|
assert mover.running
|
||||||
|
assert actions[0](instance).ok
|
||||||
|
mover.on_command_failed(1, "read failed")
|
||||||
|
assert not mover.running and mover.state.failure == "read failed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("change", ["parameters", "mapping"])
|
||||||
|
def test_a_queued_plan_cannot_be_reinterpreted_under_new_settings(qt_app, change):
|
||||||
|
instance, transport = service()
|
||||||
|
mover, actions = mover_and_actions(instance, {"x": 2.0, "y": 1.0})
|
||||||
|
if change == "parameters":
|
||||||
|
instance.set_axis_params(0, AxisParams(units=5.0))
|
||||||
|
else:
|
||||||
|
instance.set_axis_roles({"x": 1, "y": 0, "yaw": 2, "pitch": 3, "z": 4})
|
||||||
|
assert not actions[0](instance).ok
|
||||||
|
assert transport.moves == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stale_plan_is_refused_before_it_is_queued(qt_app):
|
||||||
|
instance, transport = service()
|
||||||
|
plan = plan_pose_move(instance, {"x": 2.0}, together=True)
|
||||||
|
instance.set_axis_params(0, AxisParams(units=5.0))
|
||||||
|
instance.poll([0])
|
||||||
|
instance._bus = BusStatus(init_status=1, total_axes=5)
|
||||||
|
actions = []
|
||||||
|
mover = PoseMover(instance, lambda action: actions.append(action))
|
||||||
|
assert not mover.start(plan).ok
|
||||||
|
assert actions == [] and transport.moves == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_read_error_before_execution_refuses_the_entire_group(qt_app):
|
||||||
|
instance, transport = service()
|
||||||
|
mover, actions = mover_and_actions(instance, {"x": 2.0, "yaw": 10.0})
|
||||||
|
|
||||||
|
def unreadable(axis):
|
||||||
|
raise ZAuxError(30500, "unit read failed")
|
||||||
|
|
||||||
|
transport.get_units = unreadable
|
||||||
|
assert not actions[0](instance).ok
|
||||||
|
assert transport.moves == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_debug_moves_keep_the_existing_user_unit_contract():
|
||||||
|
instance, transport = service()
|
||||||
|
transport.set_units(2, 5.0)
|
||||||
|
assert instance.move_abs(2, 90.0).ok
|
||||||
|
assert transport.moves == [(2, 90.0)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("together", [False, True])
|
||||||
|
@pytest.mark.parametrize("failure", ["refused", "exception"])
|
||||||
|
def test_arrival_before_command_failure_never_reports_success(qt_app, together, failure):
|
||||||
|
instance, transport = service()
|
||||||
|
actions = []
|
||||||
|
mover = PoseMover(instance, lambda action: actions.append(action) or len(actions))
|
||||||
|
finished = []
|
||||||
|
mover.finished.connect(finished.append)
|
||||||
|
assert mover.start(plan_pose_move(instance, {"x": 0.0}, together=together)).ok
|
||||||
|
mover.on_snapshot(Snapshot(axes=(instance._last_axes[0],)))
|
||||||
|
assert not finished and mover.running
|
||||||
|
|
||||||
|
if failure == "refused":
|
||||||
|
transport.set_units(0, 5.0)
|
||||||
|
result = actions[0](instance)
|
||||||
|
assert not result.ok and "UNITS" in result.message
|
||||||
|
mover.on_command_finished(1, result)
|
||||||
|
else:
|
||||||
|
mover.on_command_failed(1, "read failed")
|
||||||
|
assert mover.state.failure and not mover.state.finished
|
||||||
|
assert not finished and not mover.running
|
||||||
|
assert transport.moves == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("together", [False, True])
|
||||||
|
def test_only_matching_success_and_a_later_snapshot_allow_arrival(qt_app, together):
|
||||||
|
instance, _transport = service()
|
||||||
|
actions = []
|
||||||
|
mover = PoseMover(instance, lambda action: actions.append(action) or len(actions))
|
||||||
|
assert mover.start(plan_pose_move(instance, {"x": 0.0}, together=together)).ok
|
||||||
|
snapshot = Snapshot(axes=(instance._last_axes[0],))
|
||||||
|
mover.on_command_finished(99, MotionResult.success())
|
||||||
|
mover.on_snapshot(snapshot)
|
||||||
|
assert mover.running and not mover.state.finished
|
||||||
|
result = actions[0](instance)
|
||||||
|
assert result.ok
|
||||||
|
mover.on_command_finished(1, result)
|
||||||
|
assert mover.running, "a pre-acknowledgement snapshot was reused"
|
||||||
|
mover.on_snapshot(Snapshot(axes=(instance._last_axes[0],)))
|
||||||
|
assert mover.state.finished and not mover.running
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_sequential_axis_waits_for_its_own_command_success(qt_app):
|
||||||
|
instance, _transport = service()
|
||||||
|
actions = []
|
||||||
|
mover = PoseMover(instance, lambda action: actions.append(action) or len(actions))
|
||||||
|
assert mover.start(plan_pose_move(instance, {"x": 0.0, "y": 0.0})).ok
|
||||||
|
snapshot = Snapshot(axes=tuple(instance._last_axes[axis] for axis in (0, 1)))
|
||||||
|
mover.on_snapshot(snapshot)
|
||||||
|
assert len(actions) == 1 and mover.state.index == 0
|
||||||
|
result = actions[0](instance)
|
||||||
|
assert result.ok
|
||||||
|
mover.on_command_finished(1, result)
|
||||||
|
mover.on_snapshot(snapshot)
|
||||||
|
assert len(actions) == 2 and mover.state.index == 1
|
||||||
|
mover.on_command_finished(1, MotionResult.success())
|
||||||
|
mover.on_snapshot(snapshot)
|
||||||
|
assert mover.running and not mover.state.finished
|
||||||
|
result = actions[1](instance)
|
||||||
|
assert result.ok
|
||||||
|
mover.on_command_finished(2, result)
|
||||||
|
mover.on_snapshot(snapshot)
|
||||||
|
assert mover.state.finished
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("together", [False, True])
|
||||||
|
@pytest.mark.parametrize("fault", ["alarm", "read"])
|
||||||
|
def test_axis_faults_remain_visible_while_command_is_pending(qt_app, together, fault):
|
||||||
|
instance, _transport = service()
|
||||||
|
mover = PoseMover(instance, lambda _action: 1)
|
||||||
|
targets = {"x": 0.0, "y": 0.0} if together else {"y": 0.0}
|
||||||
|
assert mover.start(plan_pose_move(instance, targets, together=together)).ok
|
||||||
|
bad_axis = AxisState(
|
||||||
|
axis=1,
|
||||||
|
units=10.0,
|
||||||
|
status=AxisStatusBits(raw=512 if fault == "alarm" else 0),
|
||||||
|
read_ok=fault != "read",
|
||||||
|
)
|
||||||
|
mover.on_snapshot(Snapshot(axes=(AxisState(axis=0, units=10.0), bad_axis)))
|
||||||
|
assert not mover.running and not mover.state.finished
|
||||||
|
assert ("alarm" if fault == "alarm" else "read failed") in mover.state.failure
|
||||||
@@ -19,7 +19,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
from orisys_autocal.motion import BusStatus
|
from orisys_autocal.motion import AxisState, BusStatus, Snapshot
|
||||||
from orisys_autocal.motion.pose_move import plan_pose_move
|
from orisys_autocal.motion.pose_move import plan_pose_move
|
||||||
|
|
||||||
|
|
||||||
@@ -34,9 +34,49 @@ def _connected(make_window, *, confirm: bool = False):
|
|||||||
service = window.poller.service
|
service = window.poller.service
|
||||||
service._spec = object() # noqa: SLF001 - connected, without opening anything
|
service._spec = object() # noqa: SLF001 - connected, without opening anything
|
||||||
service._bus = BusStatus(bus_type="ecat", init_status=1, total_axes=5) # noqa: SLF001
|
service._bus = BusStatus(bus_type="ecat", init_status=1, total_axes=5) # noqa: SLF001
|
||||||
|
# A ready physical-unit move also has a successful unit readback.
|
||||||
|
service._last_axes = {axis: service._read_axis(axis) for axis in range(5)}
|
||||||
return window
|
return window
|
||||||
|
|
||||||
|
|
||||||
|
def test_axis_4_controls_stay_available_when_the_bus_count_is_short(make_window):
|
||||||
|
"""A mapped axis 4 uses the same buttons as axes 0-3.
|
||||||
|
|
||||||
|
The bus-count global can still say 4 after the drive is working again.
|
||||||
|
Type 0 stays disabled, so an axis the controller has not mapped is not
|
||||||
|
given a button.
|
||||||
|
"""
|
||||||
|
window = make_window()
|
||||||
|
try:
|
||||||
|
mapped = tuple(AxisState(axis=index, atype=65, read_ok=True) for index in range(5))
|
||||||
|
window.axis_panel.update_snapshot(
|
||||||
|
Snapshot(
|
||||||
|
connected=True,
|
||||||
|
bus=BusStatus(bus_type="ecat", init_status=1, total_axes=4),
|
||||||
|
axes=mapped,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = window.axis_panel._rows[4] # noqa: SLF001
|
||||||
|
assert row["enable_button"].isEnabled()
|
||||||
|
assert row["fwd_button"].isEnabled()
|
||||||
|
assert row["rev_button"].isEnabled()
|
||||||
|
assert row["stop_button"].isEnabled()
|
||||||
|
|
||||||
|
unmapped = mapped[:-1] + (AxisState(axis=4, atype=0, read_ok=True),)
|
||||||
|
window.axis_panel.update_snapshot(
|
||||||
|
Snapshot(
|
||||||
|
connected=True,
|
||||||
|
bus=BusStatus(bus_type="ecat", init_status=1, total_axes=4),
|
||||||
|
axes=unmapped,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = window.axis_panel._rows[4] # noqa: SLF001
|
||||||
|
assert not row["fwd_button"].isEnabled()
|
||||||
|
assert row["stop_button"].isEnabled() is False
|
||||||
|
finally:
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
|
||||||
def test_execute_lives_with_the_point_it_executes(make_window):
|
def test_execute_lives_with_the_point_it_executes(make_window):
|
||||||
window = make_window()
|
window = make_window()
|
||||||
try:
|
try:
|
||||||
@@ -164,8 +204,14 @@ def test_accepting_the_prompt_starts_the_move(make_window, monkeypatch):
|
|||||||
)
|
)
|
||||||
window._on_return_to_initial() # noqa: SLF001
|
window._on_return_to_initial() # noqa: SLF001
|
||||||
assert window.mover.running, "accepting the prompt did not start the move"
|
assert window.mover.running, "accepting the prompt did not start the move"
|
||||||
assert [step.role for step in window.mover.state.plan.steps][0] == "z"
|
assert [step.role for step in window.mover.state.plan.steps] == [
|
||||||
# The status line carries the plan, so the operator sees what is moving.
|
"yaw",
|
||||||
|
"pitch",
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z",
|
||||||
|
]
|
||||||
|
assert window.mover.state.plan.together
|
||||||
assert "z" in window.finger_panel.move_label.text()
|
assert "z" in window.finger_panel.move_label.text()
|
||||||
finally:
|
finally:
|
||||||
window.mover.cancel("test")
|
window.mover.cancel("test")
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user