commit fa9c43fae868398f6a5f5f45dcef455205b4966e Author: Nick Hermes Date: Tue May 26 16:23:04 2026 +0000 docs: scaffold polisher-control foundation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1529fdf --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Python +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.venv/ +venv/ +*.egg-info/ + +# PlatformIO / embedded +.pio/ +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json + +# Runtime data: never commit real run data +/data/ +data/runs/ +data/manual/ +*.parquet +*.npz + +# Local config/secrets +.env +*.local.* +secrets.* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..55e1e30 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,42 @@ +# AGENTS.md — Polisher-Control Repo Rules + +This repository is an external-collaborator technical workspace for the P11 / Fullum / GigaBIT polisher-control implementation. + +## Scope + +Allowed work: +- Teensy firmware +- Raspberry Pi / host controller +- touchscreen/manual-mode workflow support +- host↔Teensy protocol +- telemetry/logging/run artifacts +- safety/interlocks/alarms/watchdogs +- machine capability profile +- tests, commissioning scripts, and technical docs + +Out of scope: +- optical figuring strategy +- metrology interpretation +- optimization / calibration logic +- client/commercial context +- private Antoine notes / unrelated projects +- credentials or secrets + +## Architecture rule + +`polisher-control` executes. It does not decide strategy. + +If a change affects architecture, telemetry names/units/rates, safety behavior, +protocol semantics, or v1 scope, log it as a proposal and ask Antoine before +treating it as accepted direction. + +## Commit hygiene + +Prefer small commits: +- `docs: ...` +- `firmware: ...` +- `host: ...` +- `test: ...` +- `config: ...` + +Never commit real machine data, secrets, invoices, tokens, or private notes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..89b9365 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,16 @@ +# Contributing + +## Working style + +- Keep changes small and reviewable. +- Add or update docs when changing protocol, telemetry, state machine, safety, or data layout. +- Add tests for host-side state-machine and contract behavior before relying on hardware. +- Never hide clipping, rejected commands, or safety changes; emit explicit events/NACKs. + +## Definition of done for a feature + +- Implementation exists. +- Test or bench verification exists. +- Relevant docs are updated. +- Safety or contract impact is stated. +- Antoine-facing open questions are listed if the decision is not locked. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ceb3137 --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONY: test validate-json tree + +test: + python -m pytest -q + +validate-json: + python scripts/validate_json.py + +tree: + python scripts/print_tree.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..48b5148 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# Polisher-Control + +Machine-side execution stack for the Fullum / GigaBIT swing-arm polisher. + +This repository is for Cédric's implementation work on the **controller, firmware, +manual-mode UI support, telemetry, safety/interlocks, and run artifacts**. It is +aligned with the broader Polisher Software Suite: + +```text +polisher-sim -> planning / digital twin / calibration / metrology intelligence +polisher-post -> validation + packaging of controller-safe jobs +polisher-control -> safe machine execution + telemetry + operator workflow +shared contracts -> schemas, logs, machine capabilities, provenance +``` + +## v1 finish line + +Normand can operate the polisher manually from the touchscreen, with: + +- safe force/table/spindle control; +- KWR75B-CAN force/torque sensor integration; +- ODrive S1 + M8325s spindle drive integration; +- Teensy-side fast safety and setpoint/telemetry loop; +- host-side state machine, logs, status, and data export; +- synchronized telemetry at **>=100 Hz**; +- manual-session logs usable by `polisher-sim` / `polisher-post` later; +- safety/interlocks enforced and visible in logs. + +## What this repo must NOT become + +`polisher-control` does **not** own optical strategy. It must not interpret +interferograms, optimize polishing passes, learn calibration parameters, or invent +correction programs. Those decisions belong upstream in `polisher-sim` and +`polisher-post`. + +The controller should be boring, deterministic, auditable, and conservative. + +## Start here + +1. Read [`docs/00-start-here.md`](docs/00-start-here.md). +2. Read [`docs/02-v1-scope.md`](docs/02-v1-scope.md). +3. Review the state machine and safety rules in [`docs/03-architecture.md`](docs/03-architecture.md). +4. Use [`docs/08-commissioning-checklist.md`](docs/08-commissioning-checklist.md) during hardware bring-up. +5. Track implementation using [`ROADMAP.md`](ROADMAP.md). + +## Repository map + +```text +firmware/teensy/ Teensy 4.1 firmware scaffold and protocol/telemetry structs +host/ Python host-controller scaffold for RPi/PC side +shared/schemas/ Contract schemas mirrored from the current software suite +shared/machine/ Fullum-alpha machine capability profile draft +docs/ Cédric-facing implementation docs and checklists +docs/nick-generated/ Nick-generated source-grounded guidance notes +scripts/ Utility/validation scripts +tests/ Host-side contract/state-machine tests +``` + +## Current source alignment + +Generated: 2026-05-26. This foundation is a repo scaffold and implementation aid, +not a replacement for the approved P11 source specifications. If this repo and +the source specs disagree, ask Antoine before changing architecture, safety, +telemetry contracts, or scope. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..e8d04e3 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,49 @@ +# Polisher-Control Roadmap + +## Phase 0 — Repository foundation + +- [x] Create repository structure. +- [x] Add ecosystem boundary docs. +- [x] Add v1 scope and acceptance checklist. +- [x] Add host/firmware scaffolds. +- [x] Mirror current shared schemas. + +## Phase 1 — Contracts first + +- [ ] Lock protocol frame format: message IDs, payload layout, CRC, ACK/NACK. +- [ ] Lock telemetry channel list, units, rates, validity encoding. +- [ ] Lock event/alarm code table. +- [ ] Validate `controller-job.v1` and `run-log.v1` fixtures. +- [ ] Implement host-side state machine tests before hardware logic grows. + +## Phase 2 — Teensy bench bring-up + +- [ ] Teensy boot/status heartbeat. +- [ ] Host↔Teensy serial link with ACK/NACK and CRC. +- [ ] KWR75B-CAN receive path with measured sample rate and stale-frame watchdog. +- [ ] Encoder acquisition for table and arm. +- [ ] ODrive command/telemetry interface selected and documented. +- [ ] Force actuator command path selected and documented. + +## Phase 3 — Manual mode MVP + +- [ ] Host UI/manual-mode workflow with geometric gate. +- [ ] Manual setpoints: force, table RPM, spindle RPM, optional modulation. +- [ ] Teensy inner loop: setpoint ramping, force PID, telemetry, fast interlocks. +- [ ] Manual-session log and telemetry CSV written to `/data/manual/{session_id}/`. +- [ ] Status file updated at `/data/status.json`. + +## Phase 4 — Safety and commissioning + +- [ ] E-stop behavior verified independently of software state. +- [ ] Force over-limit, encoder loss, drive fault, F/T invalid/stale faults verified. +- [ ] Host watchdog and firmware heartbeat verified. +- [ ] FAULTED exits only by explicit operator reset. +- [ ] Tool-weight compensation offset commissioned and logged. + +## Phase 5 — Controller-job dry run + +- [ ] `controller-job.v1` 4-gate intake implemented. +- [ ] Segment lifecycle exercised on bench/simulator. +- [ ] `run-log.v1` emitted with commanded-vs-actual structure. +- [ ] Program execution remains disabled for production until Antoine explicitly accepts readiness. diff --git a/docs/00-start-here.md b/docs/00-start-here.md new file mode 100644 index 0000000..e9a1331 --- /dev/null +++ b/docs/00-start-here.md @@ -0,0 +1,33 @@ +# Start Here — Cédric Build Brief + +## Mission + +Build the machine-side control stack for the Fullum swing-arm polisher so Normand can run manual polishing sessions safely while producing clean telemetry for the rest of the software suite. + +## v1 priority order + +1. Safety/interlocks and deterministic state machine. +2. Manual mode from the touchscreen / host UI. +3. Stable host↔Teensy setpoint + telemetry protocol. +4. KWR75B-CAN force/torque acquisition and stale-frame detection. +5. Table/arm encoder acquisition and synchronized timestamps. +6. ODrive spindle command/telemetry path. +7. Run/manual logs and `/data/` file layout. +8. Controller-job intake dry-run for future program execution. + +## Mental model + +The host says: "run these approved setpoints and log what happens." + +The Teensy says: "accepted/rejected, here is measured reality, here are events/faults." + +Neither side invents polishing strategy. + +## Ask Nick for + +- protocol notes; +- firmware module breakdowns; +- test checklists; +- telemetry schema clarifications; +- state-machine/safety edge cases; +- concise implementation notes in `docs/nick-generated/`. diff --git a/docs/01-ecosystem-boundaries.md b/docs/01-ecosystem-boundaries.md new file mode 100644 index 0000000..cbdd75b --- /dev/null +++ b/docs/01-ecosystem-boundaries.md @@ -0,0 +1,48 @@ +# Ecosystem Boundaries + +## polisher-sim + +Owns planning intelligence: +- metrology ingestion; +- mirror state and residuals; +- removal prediction; +- dwell/preset planning; +- calibration and uncertainty; +- campaign reports. + +## polisher-post + +Owns translation and validation: +- schema validation; +- machine capability checks; +- unit normalization; +- segmentation; +- packaging `controller-job.v1`; +- run-log normalization back into analysis format. + +## polisher-control + +Owns machine execution: +- state machine; +- manual mode; +- setpoint execution; +- force loop interface; +- telemetry; +- alarms/interlocks; +- pause/resume/abort; +- run/manual-session logs; +- operator workflow. + +## Shared contracts + +Own schemas and names that must not drift silently: +- `controller-job.v1`; +- `run-log.v1`; +- `manual-session-log.v1`; +- `machine-capabilities.v1`; +- telemetry channel names; +- event/alarm codes. + +## Boundary rule + +If you are about to add planning, optimization, calibration, or metrology interpretation to this repo, stop and ask Antoine. That likely belongs upstream. diff --git a/docs/02-v1-scope.md b/docs/02-v1-scope.md new file mode 100644 index 0000000..f33dac7 --- /dev/null +++ b/docs/02-v1-scope.md @@ -0,0 +1,30 @@ +# v1 Scope + +## In scope + +- Manual mode with live operator setpoints. +- Full safety/interlock handling. +- Host↔Teensy command/telemetry protocol. +- KWR75B-CAN force/torque sensor integration. +- Table and arm encoder acquisition. +- ODrive S1 + M8325s spindle control/telemetry path. +- Force actuator command path and tool-weight compensation. +- Synchronized telemetry at >=100 Hz. +- Manual-session logs and raw telemetry CSV. +- USB SSD `/data/` storage layout. +- `status.json` machine summary. +- Controller-job validation path for future use. + +## Explicitly not in v1 + +- Figuring strategy. +- Metrology import/interpretation. +- Calibration updates. +- Autonomous optimization. +- Powered Zero-G/admittance manipulation mode. +- Remote control of the machine. +- Multi-pass orchestration inside the controller. + +## First success condition + +A real manual polishing session can be run safely, and the resulting telemetry/logs are coherent enough for Antoine to analyze downstream. diff --git a/docs/03-architecture.md b/docs/03-architecture.md new file mode 100644 index 0000000..54fa846 --- /dev/null +++ b/docs/03-architecture.md @@ -0,0 +1,64 @@ +# Architecture + +## Runtime split + +### Host controller — Python on RPi/PC + +Responsibilities: +- operator workflow and manual-mode UI integration; +- state machine orchestration; +- controller-job intake validation; +- geometry gate; +- setpoint command stream; +- telemetry capture; +- logs, manifests, hashes, status file; +- slow watchdogs and data export. + +### Teensy firmware — C++ on Teensy 4.x + +Responsibilities: +- inner loop around ~1 kHz; +- KWR75B-CAN F/T receive path; +- encoder acquisition; +- force PID output; +- table/spindle/actuator command outputs; +- fast safety checks; +- heartbeat supervision; +- telemetry frames at >=100 Hz; +- ACK/NACK and event/fault frames. + +## State machine + +Current accepted state names: + +```text +IDLE +JOB_LOADED +READY +RUNNING +PAUSED +ABORTING +COMPLETED +ABORTED +FAULTED +MANUAL +``` + +Important rules: +- `FAULTED` exits only through explicit operator reset. +- `JOB_LOADED -> READY` requires operator acknowledge. +- `IDLE -> MANUAL` is allowed only with no job loaded. +- Illegal transitions are errors and events, never silent ignores. +- Manual mode uses the same safety/interlocks as job mode. + +## Manual mode sequence + +1. Machine starts in `IDLE`. +2. Operator opens Manual Mode. +3. Host enforces the geometric gate. +4. Host sends `MANUAL_START` to Teensy. +5. Teensy ACKs or NACKs with a machine-readable reason. +6. Host sends live `SETPOINT` updates. +7. Teensy emits telemetry and events. +8. Operator stops; host sends `MANUAL_STOP`. +9. Host writes manual-session log and artifacts. diff --git a/docs/04-host-teensy-protocol-v1.md b/docs/04-host-teensy-protocol-v1.md new file mode 100644 index 0000000..8e3c81f --- /dev/null +++ b/docs/04-host-teensy-protocol-v1.md @@ -0,0 +1,60 @@ +# Host ↔ Teensy Protocol v1 — Working Spec + +Status: draft scaffold. Cédric should finalize the exact wire format before implementation locks. + +## Philosophy + +This is a setpoint/telemetry protocol, not G-code. + +Requirements: +- versioned frames; +- length-delimited or COBS-delimited framing; +- CRC-16 or CRC-32 on every frame; +- deterministic parsing; +- ACK/NACK for every command; +- machine-readable NACK reason codes; +- no text parsing in the control path. + +## Host → Teensy messages + +- `HEARTBEAT` +- `MANUAL_START` +- `SETPOINT` +- `MANUAL_STOP` +- `SEGMENT_START` +- `PAUSE` +- `RESUME` +- `ABORT` +- `ESTOP` + +## Teensy → Host messages + +- `ACK` +- `NACK` +- `TELEMETRY` +- `EVENT` +- `SEGMENT_DONE` +- `ABORT_COMPLETE` + +## v1 production subset + +Manual v1 only needs: +- `HEARTBEAT` +- `MANUAL_START` +- `SETPOINT` +- `MANUAL_STOP` +- `ACK` / `NACK` +- `TELEMETRY` +- `EVENT` + +But define and parse the future segment messages now so Phase 3 does not require a protocol reset. + +## Open protocol choices + +- JSON-lines, MessagePack, or compact binary structs? +- CRC-16 vs CRC-32? +- COBS framing vs length-prefix + CRC? +- Serial baud rate and reconnect behavior? +- Whether heartbeat is host-only or bidirectional? + +Recommendation: choose the simplest robust format Cédric is comfortable debugging on the bench. diff --git a/docs/05-telemetry-channel-spec-v1.md b/docs/05-telemetry-channel-spec-v1.md new file mode 100644 index 0000000..5facf64 --- /dev/null +++ b/docs/05-telemetry-channel-spec-v1.md @@ -0,0 +1,56 @@ +# Telemetry Channel Spec v1 + +## Core channels — required at >=100 Hz + +```text +timestamp_us +table_angle_deg +arm_angle_deg +fz_n +mx +my +mz +spindle_rpm_actual +table_rpm_actual +arm_amplitude_deg_derived +arm_center_deg_derived +machine_state +force_setpoint_n +``` + +## Strongly recommended channels + +```text +fx_n +fy_n +ft_status +z_servo_iq_v +z_brake_engaged +spindle_drive_state +spindle_drive_error +spindle_bus_voltage_v +spindle_iq_a +spindle_motor_temp_c +arm_angle_linearized_deg +table_rpm_setpoint +spindle_rpm_setpoint +force_actuator_cmd +estop_active +interlock_state +mode +fz_raw_n +fz_contact_n +``` + +## Principles + +- One monotonic Teensy timestamp source. +- Raw values, not only filtered values. +- Commanded and actual values are both present. +- Sensor validity is explicit; never substitute fake good values. +- Gaps are detectable from timestamps. +- Header names are stable because downstream analysis will depend on them. + +## CSV baseline + +First-write compatibility format is CSV. Later processing may produce Parquet/downsampled traces, but raw CSV must be preserved during commissioning. diff --git a/docs/06-event-alarm-codes-v1.md b/docs/06-event-alarm-codes-v1.md new file mode 100644 index 0000000..a61f818 --- /dev/null +++ b/docs/06-event-alarm-codes-v1.md @@ -0,0 +1,46 @@ +# Event and Alarm Codes v1 + +Status: draft canonical table for implementation. + +## Hard-stop faults — reset required + +- `ESTOP_ACTIVATED` +- `FORCE_OVER_LIMIT` +- `ENCODER_LOST` +- `DRIVE_FAULT` +- `FT_SENSOR_INVALID` + +## Recoverable pauses / warnings + +- `FORCE_UNDER_LIMIT` +- `SPINDLE_RPM_DEVIATION` +- `TABLE_RPM_DEVIATION` +- `HOST_COMMS_TIMEOUT` +- `ARM_GEOMETRY_DIVERGENCE` + +## Refused transitions / blocked starts + +- `GEOMETRY_NOT_VALIDATED` +- `ARM_HANDLING_INTERLOCK` +- `WRONG_ARTIFACT_TYPE` +- `SCHEMA_VALIDATION_FAILED` +- `WRONG_MACHINE_ID` +- `CONTROLLER_VERSION_MISMATCH` +- `UNSUPPORTED_CAPABILITY` +- `ILLEGAL_TRANSITION` + +## Event fields + +Every event should include: + +```json +{ + "timestamp": "ISO-8601 or host monotonic mapping", + "code": "GEOMETRY_NOT_VALIDATED", + "severity": "info|warning|critical", + "state": "IDLE|MANUAL|...", + "detail": "short machine-readable or operator-readable detail" +} +``` + +Do not use free text as the only control signal. Human messages are for UI; codes are for software. diff --git a/docs/07-manual-mode-workflow.md b/docs/07-manual-mode-workflow.md new file mode 100644 index 0000000..038d1d4 --- /dev/null +++ b/docs/07-manual-mode-workflow.md @@ -0,0 +1,32 @@ +# Manual Mode Workflow + +## Operator flow + +1. Hardware HOA selector in Auto. +2. Machine in `IDLE`. +3. Operator selects Manual Mode. +4. UI presents the geometric gate: + - `r_menante` + - `L_menee` + - `R_tool` + - configured arm amplitude + - configured arm center +5. Operator confirms values. +6. Operator enters setpoints: + - force N + - table RPM + - spindle RPM + - optional force modulation harmonic/amplitude/phase +7. Operator presses Start. +8. Host sends `MANUAL_START`. +9. Operator may adjust setpoints live. +10. Operator presses Stop. +11. Host writes the manual-session log, telemetry CSV, events, manifest, and status. + +## Hard rules + +- Manual mode cannot start with stale geometry. +- Manual mode uses the same safety/interlocks as job mode. +- Every setpoint change is logged. +- Telemetry always runs while force/motion is active. +- Tool removal uses the documented mechanical sequence; no powered Zero-G in v1. diff --git a/docs/08-commissioning-checklist.md b/docs/08-commissioning-checklist.md new file mode 100644 index 0000000..291bd36 --- /dev/null +++ b/docs/08-commissioning-checklist.md @@ -0,0 +1,45 @@ +# Commissioning Checklist + +## Bench — no machine motion + +- [ ] Host can open serial link to Teensy. +- [ ] HEARTBEAT / ACK / NACK verified. +- [ ] Bad CRC/frame rejected with NACK or ignored safely. +- [ ] State machine illegal transitions logged. +- [ ] Telemetry frames parse at expected rate. + +## Sensor bring-up + +- [ ] KWR75B-CAN isolated CAN wiring verified. +- [ ] CAN bit rate confirmed. +- [ ] Vendor frame map decoded into Fx/Fy/Fz/Mx/My/Mz/status. +- [ ] F/T stale-frame watchdog tested. +- [ ] Table encoder angle stable and monotonic. +- [ ] Arm encoder angle stable. + +## Drive bring-up + +- [ ] ODrive runtime interface selected and documented. +- [ ] ODrive enable/disable/fault reset path verified. +- [ ] Spindle RPM command and actual feedback verified. +- [ ] Z force actuator command path verified with safe limits. +- [ ] Brake engaged feedback verified if installed. + +## Safety + +- [ ] E-stop hard circuit tested independent of software state. +- [ ] Force over-limit response tested. +- [ ] Encoder-loss response tested. +- [ ] Drive-fault response tested. +- [ ] Host timeout -> pause -> abort behavior tested. +- [ ] FAULTED requires explicit reset. + +## Manual run + +- [ ] Geometric gate blocks stale/missing geometry. +- [ ] Manual start succeeds after valid geometry. +- [ ] Force/table/spindle setpoints ramp smoothly. +- [ ] Live setpoint changes are logged. +- [ ] Telemetry CSV has required header and >=100 Hz samples. +- [ ] Manual-session log includes setpoint history and actual summary. +- [ ] Artifacts are saved under `/data/manual/{session_id}/`. diff --git a/docs/09-acceptance-checklist.md b/docs/09-acceptance-checklist.md new file mode 100644 index 0000000..a3a2e1c --- /dev/null +++ b/docs/09-acceptance-checklist.md @@ -0,0 +1,53 @@ +# Acceptance Checklist + +This checklist condenses the current P11 firmware/control spec for implementation tracking. + +## Input contract + +- [ ] Accepts `controller-job.v1` only. +- [ ] Rejects `job.v1` and malformed input. +- [ ] Runs 4-gate intake validation. +- [ ] Rejects wrong `machine_id`. +- [ ] Rejects incompatible `controller_version` or unsupported capability. + +## Execution + +- [ ] Sequential segment execution exists for future job mode. +- [ ] Force PID tracks setpoint within agreed commissioning tolerance. +- [ ] Force modulation uses live table encoder angle. +- [ ] Table and spindle RPM follow commands. +- [ ] Pause time excluded from segment polishing time. + +## Telemetry + +- [ ] Core channels logged at >=100 Hz. +- [ ] Single monotonic timestamp source. +- [ ] Commanded and actual values both present. +- [ ] Sensor faults are detectable. +- [ ] CSV parseable by standard tools. + +## State machine and safety + +- [ ] All accepted states/transitions implemented. +- [ ] Illegal transitions logged. +- [ ] Operator acknowledge cannot be bypassed. +- [ ] E-stop all-stop independent of software state. +- [ ] Interlocks implemented with documented thresholds. +- [ ] Watchdog timeout causes pause, then abort. +- [ ] `FAULTED` exits only through explicit reset. + +## Manual mode + +- [ ] `MANUAL` reachable from `IDLE`. +- [ ] Live setpoint adjustment works. +- [ ] Geometric gate blocks stale geometry. +- [ ] Setpoint changes are timestamped events. +- [ ] Manual-session log emitted on exit. +- [ ] Safety behavior identical to job mode. + +## Data + +- [ ] `/data/manual/{session_id}/` and `/data/runs/{run_id}/` layouts respected. +- [ ] `status.json` maintained. +- [ ] Hashes/manifests emitted. +- [ ] Raw telemetry preserved during commissioning. diff --git a/docs/10-open-questions-for-cedric.md b/docs/10-open-questions-for-cedric.md new file mode 100644 index 0000000..e5996bc --- /dev/null +++ b/docs/10-open-questions-for-cedric.md @@ -0,0 +1,28 @@ +# Open Questions for Cédric + +## Table interface + +- Confirm KBSI-240D opto-isolated interface or replacement. +- Confirm table encoder transport and transceiver. +- Confirm whether the table drive or command path changes electrically. + +## Spindle / ODrive + +- Define ODrive runtime command and telemetry path; CAN 2.0B at 1 Mbps is preferred unless replaced. +- Define command scaling for velocity, enable/disable, fault reset, and safe stop. +- Define telemetry subset: actual RPM, drive state/error, DC bus voltage, q-axis current, motor temperature if available. +- Export final ODrive config with release. +- Define spindle enable/fault I/O into HOA + safety chain. + +## Z-axis / force actuator + +- Confirm SV2A-2150 torque/current command mode and scaling. +- Confirm enable/fault wiring and Iq/current monitor path. +- Define Z limit-switch / hard-stop wiring into safety chain. +- Define NC 24 VDC brake coil driver and brake-engaged diagnostic feedback. + +## Cross-cutting + +- Select safety relay model. +- Confirm KWR75B-CAN frame map, byte order, scaling, status bits, and update rate. +- Confirm final serial/protocol wire format. diff --git a/docs/nick-generated/2026-05-26-polisher-control-foundation.md b/docs/nick-generated/2026-05-26-polisher-control-foundation.md new file mode 100644 index 0000000..1fbba1e --- /dev/null +++ b/docs/nick-generated/2026-05-26-polisher-control-foundation.md @@ -0,0 +1,37 @@ +--- +title: Polisher-Control Repository Foundation +status: draft +requested_by: Antoine Letarte +generated_by: Nick / Hermes +project: P11-Polisher-Fullum +repo: polisher-control +source_truth: false +created: 2026-05-26 +privacy: technical-only +--- + +# Polisher-Control Repository Foundation + +## Purpose + +Give Cédric a clean starting point for the machine-side control implementation without mixing in upstream planning or private project context. + +## What to build first + +1. Host↔Teensy protocol and ACK/NACK behavior. +2. State machine and illegal-transition tests. +3. KWR75B-CAN receive path and F/T validity/stale-frame watchdog. +4. Manual mode with geometric gate. +5. Telemetry CSV and manual-session log. +6. Safety interlock behavior. + +## Key instruction + +Manual mode is not a throwaway mode. Manual telemetry is the calibration dataset for the entire future digital-twin loop. Keep channel names, timestamps, setpoint history, and force/angle data stable from the start. + +## Acceptance checks + +- [ ] Normand can run manual mode safely. +- [ ] Every session produces telemetry and a manual-session log. +- [ ] Every fault or rejected command is visible as an event/alarm. +- [ ] The controller never invents polishing strategy. diff --git a/docs/nick-generated/README.md b/docs/nick-generated/README.md new file mode 100644 index 0000000..5637c25 --- /dev/null +++ b/docs/nick-generated/README.md @@ -0,0 +1,15 @@ +# Nick-Generated Notes + +This folder is for source-grounded technical notes Nick generates for Cédric. + +Rules: +- technical-only; +- no invoices/rates/private context/secrets; +- mark generated notes `source_truth: false`; +- if a note changes architecture, safety, telemetry contracts, protocol semantics, or v1 scope, ask Antoine before treating it as accepted. + +Suggested filename: + +```text +YYYY-MM-DD-short-topic.md +``` diff --git a/docs/reference/source-map.md b/docs/reference/source-map.md new file mode 100644 index 0000000..48b6d66 --- /dev/null +++ b/docs/reference/source-map.md @@ -0,0 +1,17 @@ +# Source Map + +This repo foundation is derived from the current P11 / Fullum polisher-control planning context. + +Primary source documents: + +- Fullum Polisher — Machine Control & Firmware Specification v1 +- Controller / Bridge / Digital Twin Architecture Plan +- polisher-control — System Definition +- polisher-control — Roadmap +- Polisher Software Suite index and shared schema bundle + +Source-truth rule: + +- These repo docs are implementation aids. +- Approved P11 source specs and schemas outrank generated summaries. +- If anything here conflicts with a source spec, ask Antoine before changing technical direction. diff --git a/firmware/README.md b/firmware/README.md new file mode 100644 index 0000000..85a9c1c --- /dev/null +++ b/firmware/README.md @@ -0,0 +1,12 @@ +# Firmware + +Teensy 4.x firmware owns the fast loop: + +- sensor acquisition; +- force PID; +- command output; +- fast safety/interlocks; +- host heartbeat supervision; +- telemetry/event emission. + +Use `firmware/teensy/` as the first implementation target. diff --git a/firmware/teensy/README.md b/firmware/teensy/README.md new file mode 100644 index 0000000..2170f2b --- /dev/null +++ b/firmware/teensy/README.md @@ -0,0 +1,17 @@ +# Teensy Firmware Scaffold + +Suggested starting point: Teensy 4.1 using PlatformIO/Arduino framework. + +Core modules to implement: + +- protocol framing + CRC; +- message decode/encode; +- state machine mirror / safety state; +- KWR75B-CAN decoder via isolated CAN transceiver; +- table and arm encoder readers; +- force PID and actuator output; +- ODrive command/telemetry path; +- telemetry packet builder; +- fast interlocks and watchdogs. + +The included `src/main.cpp` is intentionally minimal. Replace with real modules as hardware decisions close. diff --git a/firmware/teensy/include/polisher_protocol.h b/firmware/teensy/include/polisher_protocol.h new file mode 100644 index 0000000..0cc6c80 --- /dev/null +++ b/firmware/teensy/include/polisher_protocol.h @@ -0,0 +1,31 @@ +#pragma once +#include + +enum class MessageType : uint8_t { + HEARTBEAT = 1, + MANUAL_START = 2, + SETPOINT = 3, + MANUAL_STOP = 4, + SEGMENT_START = 5, + PAUSE = 6, + RESUME = 7, + ABORT = 8, + ESTOP = 9, + ACK = 100, + NACK = 101, + TELEMETRY = 102, + EVENT = 103, + SEGMENT_DONE = 104, + ABORT_COMPLETE = 105, +}; + +enum class NackReason : uint16_t { + NONE = 0, + BAD_CRC = 1, + BAD_VERSION = 2, + ILLEGAL_TRANSITION = 3, + GEOMETRY_NOT_VALIDATED = 4, + SAFETY_INTERLOCK_ACTIVE = 5, + UNSUPPORTED_COMMAND = 6, + VALUE_OUT_OF_RANGE = 7, +}; diff --git a/firmware/teensy/include/polisher_telemetry.h b/firmware/teensy/include/polisher_telemetry.h new file mode 100644 index 0000000..e2bc4fb --- /dev/null +++ b/firmware/teensy/include/polisher_telemetry.h @@ -0,0 +1,28 @@ +#pragma once +#include + +struct FT_LoadSample { + float Fx; + float Fy; + float Fz; + float Mx; + float My; + float Mz; + uint32_t status; +}; + +struct TelemetrySample { + uint64_t timestamp_us; + float table_angle_deg; + float arm_angle_deg; + float fz_n; + float mx; + float my; + float mz; + float spindle_rpm_actual; + float table_rpm_actual; + float arm_amplitude_deg_derived; + float arm_center_deg_derived; + uint8_t machine_state; + float force_setpoint_n; +}; diff --git a/firmware/teensy/platformio.ini b/firmware/teensy/platformio.ini new file mode 100644 index 0000000..f468310 --- /dev/null +++ b/firmware/teensy/platformio.ini @@ -0,0 +1,9 @@ +[env:teensy41] +platform = teensy +board = teensy41 +framework = arduino +monitor_speed = 115200 +build_flags = + -D POLISHER_CONTROL_PROTOCOL_VERSION=1 +lib_deps = + tonton81/FlexCAN_T4 diff --git a/firmware/teensy/src/main.cpp b/firmware/teensy/src/main.cpp new file mode 100644 index 0000000..618cdef --- /dev/null +++ b/firmware/teensy/src/main.cpp @@ -0,0 +1,30 @@ +#include +#include "polisher_protocol.h" +#include "polisher_telemetry.h" + +static constexpr uint32_t HEARTBEAT_LED_MS = 500; +uint32_t lastLedToggleMs = 0; +bool ledState = false; + +void setup() { + pinMode(LED_BUILTIN, OUTPUT); + Serial.begin(115200); + // TODO: initialize protocol parser, CAN, encoders, drive I/O, watchdogs. +} + +void loop() { + const uint32_t now = millis(); + if (now - lastLedToggleMs >= HEARTBEAT_LED_MS) { + lastLedToggleMs = now; + ledState = !ledState; + digitalWrite(LED_BUILTIN, ledState ? HIGH : LOW); + } + + // TODO v1 loop order: + // 1. Read host frames and validate CRC/version. + // 2. Read KWR75B-CAN and encoder inputs. + // 3. Update state/safety/watchdogs. + // 4. Compute setpoint ramp and force PID. + // 5. Apply actuator/drive outputs. + // 6. Emit telemetry >=100 Hz and events on transitions/faults. +} diff --git a/host/README.md b/host/README.md new file mode 100644 index 0000000..85da3f2 --- /dev/null +++ b/host/README.md @@ -0,0 +1,14 @@ +# Host Controller + +Python scaffold for the Raspberry Pi / host-side controller. + +Host responsibilities: +- state machine orchestration; +- geometric gate; +- manual-mode UI integration; +- serial/protocol session with Teensy; +- controller-job validation; +- telemetry capture; +- logs, manifests, hashes, and `/data/status.json`. + +This package is intentionally small for now. Add real hardware adapters behind interfaces and keep state-machine/contract tests fast. diff --git a/host/polisher_control/__init__.py b/host/polisher_control/__init__.py new file mode 100644 index 0000000..3c95f9f --- /dev/null +++ b/host/polisher_control/__init__.py @@ -0,0 +1,3 @@ +"""Host-side scaffold for Fullum polisher-control.""" + +__version__ = "0.1.0" diff --git a/host/polisher_control/adapters/.gitkeep b/host/polisher_control/adapters/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/host/polisher_control/contracts.py b/host/polisher_control/contracts.py new file mode 100644 index 0000000..784220f --- /dev/null +++ b/host/polisher_control/contracts.py @@ -0,0 +1,6 @@ +CONTROLLER_SCHEMA_VERSION = "controller-job.v1" +RUN_LOG_SCHEMA_VERSION = "run-log.v1" +MANUAL_SESSION_SCHEMA_VERSION = "manual-session-log.v1" +MACHINE_CAPABILITIES_SCHEMA_VERSION = "machine-capabilities.v1" +MACHINE_ID = "fullum-alpha" +CONTROLLER_VERSION_PREFIX = "polisher-control/" diff --git a/host/polisher_control/data_layout.py b/host/polisher_control/data_layout.py new file mode 100644 index 0000000..00035ed --- /dev/null +++ b/host/polisher_control/data_layout.py @@ -0,0 +1,17 @@ +from pathlib import Path + + +def manual_session_dir(data_root: Path, session_id: str) -> Path: + return data_root / "manual" / session_id + + +def run_dir(data_root: Path, run_id: str) -> Path: + return data_root / "runs" / run_id + + +def expected_manual_files(session_id: str) -> list[str]: + return [ + "manual-session-log.v1.json", + "telemetry.csv", + "manifest.json", + ] diff --git a/host/polisher_control/protocol.py b/host/polisher_control/protocol.py new file mode 100644 index 0000000..bd72bb4 --- /dev/null +++ b/host/polisher_control/protocol.py @@ -0,0 +1,30 @@ +from enum import IntEnum + + +class MessageType(IntEnum): + HEARTBEAT = 1 + MANUAL_START = 2 + SETPOINT = 3 + MANUAL_STOP = 4 + SEGMENT_START = 5 + PAUSE = 6 + RESUME = 7 + ABORT = 8 + ESTOP = 9 + ACK = 100 + NACK = 101 + TELEMETRY = 102 + EVENT = 103 + SEGMENT_DONE = 104 + ABORT_COMPLETE = 105 + + +class NackReason(IntEnum): + NONE = 0 + BAD_CRC = 1 + BAD_VERSION = 2 + ILLEGAL_TRANSITION = 3 + GEOMETRY_NOT_VALIDATED = 4 + SAFETY_INTERLOCK_ACTIVE = 5 + UNSUPPORTED_COMMAND = 6 + VALUE_OUT_OF_RANGE = 7 diff --git a/host/polisher_control/state_machine.py b/host/polisher_control/state_machine.py new file mode 100644 index 0000000..2c45783 --- /dev/null +++ b/host/polisher_control/state_machine.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + + +class MachineState(StrEnum): + IDLE = "IDLE" + JOB_LOADED = "JOB_LOADED" + READY = "READY" + RUNNING = "RUNNING" + PAUSED = "PAUSED" + ABORTING = "ABORTING" + COMPLETED = "COMPLETED" + ABORTED = "ABORTED" + FAULTED = "FAULTED" + MANUAL = "MANUAL" + + +TRANSITIONS: dict[tuple[MachineState, str], MachineState] = { + (MachineState.IDLE, "load_job"): MachineState.JOB_LOADED, + (MachineState.IDLE, "enter_manual"): MachineState.MANUAL, + (MachineState.JOB_LOADED, "operator_acknowledge"): MachineState.READY, + (MachineState.JOB_LOADED, "unload_job"): MachineState.IDLE, + (MachineState.READY, "start"): MachineState.RUNNING, + (MachineState.RUNNING, "pause"): MachineState.PAUSED, + (MachineState.RUNNING, "segment_complete"): MachineState.RUNNING, + (MachineState.RUNNING, "all_segments_complete"): MachineState.COMPLETED, + (MachineState.RUNNING, "abort"): MachineState.ABORTING, + (MachineState.RUNNING, "fault"): MachineState.FAULTED, + (MachineState.PAUSED, "resume"): MachineState.RUNNING, + (MachineState.PAUSED, "abort"): MachineState.ABORTING, + (MachineState.MANUAL, "exit_manual"): MachineState.IDLE, + (MachineState.MANUAL, "fault"): MachineState.FAULTED, + (MachineState.ABORTING, "abort_complete"): MachineState.ABORTED, + (MachineState.COMPLETED, "reset"): MachineState.IDLE, + (MachineState.ABORTED, "reset"): MachineState.IDLE, + (MachineState.FAULTED, "reset"): MachineState.IDLE, +} + + +class IllegalTransitionError(RuntimeError): + pass + + +@dataclass +class TransitionEvent: + from_state: MachineState + trigger: str + to_state: MachineState + + +class StateMachine: + def __init__(self, initial: MachineState = MachineState.IDLE) -> None: + self.state = initial + self.events: list[TransitionEvent] = [] + + def trigger(self, name: str) -> MachineState: + key = (self.state, name) + if key not in TRANSITIONS: + raise IllegalTransitionError(f"Illegal transition: {self.state} + {name}") + previous = self.state + self.state = TRANSITIONS[key] + self.events.append(TransitionEvent(previous, name, self.state)) + return self.state diff --git a/host/polisher_control/telemetry_channels.py b/host/polisher_control/telemetry_channels.py new file mode 100644 index 0000000..cd9e7b9 --- /dev/null +++ b/host/polisher_control/telemetry_channels.py @@ -0,0 +1,39 @@ +CORE_CHANNELS = [ + "timestamp_us", + "table_angle_deg", + "arm_angle_deg", + "fz_n", + "mx", + "my", + "mz", + "spindle_rpm_actual", + "table_rpm_actual", + "arm_amplitude_deg_derived", + "arm_center_deg_derived", + "machine_state", + "force_setpoint_n", +] + +RECOMMENDED_CHANNELS = [ + "fx_n", + "fy_n", + "ft_status", + "z_servo_iq_v", + "z_brake_engaged", + "spindle_drive_state", + "spindle_drive_error", + "spindle_bus_voltage_v", + "spindle_iq_a", + "spindle_motor_temp_c", + "arm_angle_linearized_deg", + "table_rpm_setpoint", + "spindle_rpm_setpoint", + "force_actuator_cmd", + "estop_active", + "interlock_state", + "mode", + "fz_raw_n", + "fz_contact_n", +] + +CSV_HEADER = CORE_CHANNELS diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4285686 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "polisher-control-host" +version = "0.1.0" +description = "Host-side scaffold for Fullum polisher-control" +requires-python = ">=3.11" +dependencies = [] + +[project.optional-dependencies] +dev = ["pytest>=8", "jsonschema>=4"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["host"] diff --git a/scripts/print_tree.py b/scripts/print_tree.py new file mode 100644 index 0000000..9ba023e --- /dev/null +++ b/scripts/print_tree.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +for path in sorted(ROOT.rglob("*")): + if ".git" in path.parts or "__pycache__" in path.parts: + continue + rel = path.relative_to(ROOT) + if len(rel.parts) > 4: + continue + print(rel) diff --git a/scripts/validate_json.py b/scripts/validate_json.py new file mode 100644 index 0000000..6a0451e --- /dev/null +++ b/scripts/validate_json.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def main() -> int: + errors = 0 + for path in sorted(ROOT.rglob("*.json")): + if ".git" in path.parts: + continue + try: + json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + print(f"JSON ERROR {path.relative_to(ROOT)}: {exc}") + errors += 1 + if errors: + return 1 + print("OK: all JSON files parse") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/shared/machine/fullum-alpha.capabilities.v1.json b/shared/machine/fullum-alpha.capabilities.v1.json new file mode 100644 index 0000000..2a37f75 --- /dev/null +++ b/shared/machine/fullum-alpha.capabilities.v1.json @@ -0,0 +1,130 @@ +{ + "schema_version": "machine-capabilities.v1", + "machine_id": "fullum-alpha", + "machine_family": "swing-arm", + "machine_name": "Fullum swing-arm polisher", + "controller_version": "polisher-control/0.1.0", + "supported_motion_families": [ + "swing-arm-rosette" + ], + "force_range_n": [ + 0, + 80 + ], + "table_rpm_range": [ + 0, + 5.0 + ], + "spindle_rpm_range": [ + 0, + 80 + ], + "cam_amplitude_range_deg": [ + 0, + 45 + ], + "cam_offset_range_deg": [ + -30, + 30 + ], + "force_modulation": { + "supported": true, + "max_harmonics": 3, + "max_amplitude_n": 20.0, + "notes": "Angle-synchronized force modulation from live table encoder angle; validate during commissioning." + }, + "supported_dither_profiles": [ + "none", + "default" + ], + "segment_duration_limits": { + "min_s": 10, + "max_s": 7200 + }, + "pause_resume_support": true, + "telemetry_channels": [ + { + "name": "timestamp_us", + "unit": "see docs/05-telemetry-channel-spec-v1.md", + "sample_rate_hz": 100 + }, + { + "name": "table_angle_deg", + "unit": "see docs/05-telemetry-channel-spec-v1.md", + "sample_rate_hz": 100 + }, + { + "name": "arm_angle_deg", + "unit": "see docs/05-telemetry-channel-spec-v1.md", + "sample_rate_hz": 100 + }, + { + "name": "fz_n", + "unit": "see docs/05-telemetry-channel-spec-v1.md", + "sample_rate_hz": 100 + }, + { + "name": "mx", + "unit": "see docs/05-telemetry-channel-spec-v1.md", + "sample_rate_hz": 100 + }, + { + "name": "my", + "unit": "see docs/05-telemetry-channel-spec-v1.md", + "sample_rate_hz": 100 + }, + { + "name": "mz", + "unit": "see docs/05-telemetry-channel-spec-v1.md", + "sample_rate_hz": 100 + }, + { + "name": "spindle_rpm_actual", + "unit": "see docs/05-telemetry-channel-spec-v1.md", + "sample_rate_hz": 100 + }, + { + "name": "table_rpm_actual", + "unit": "see docs/05-telemetry-channel-spec-v1.md", + "sample_rate_hz": 100 + }, + { + "name": "arm_amplitude_deg_derived", + "unit": "see docs/05-telemetry-channel-spec-v1.md", + "sample_rate_hz": 100 + }, + { + "name": "arm_center_deg_derived", + "unit": "see docs/05-telemetry-channel-spec-v1.md", + "sample_rate_hz": 100 + }, + { + "name": "machine_state", + "unit": "see docs/05-telemetry-channel-spec-v1.md", + "sample_rate_hz": 100 + }, + { + "name": "force_setpoint_n", + "unit": "see docs/05-telemetry-channel-spec-v1.md", + "sample_rate_hz": 100 + } + ], + "safety_limits": { + "max_force_n": 80, + "max_table_rpm": 5.0, + "max_spindle_rpm": 80, + "notes": "Initial source-aligned limits; final hardware commissioning must verify." + }, + "known_constraints": [ + "Manual mode is the first production target.", + "No powered Zero-G/admittance mode in v1.", + "Oscillation amplitude/center are operator-entered configuration in v1, not actuated cam commands.", + "Controller rejects unknown controller-job fields rather than silently ignoring them." + ], + "unknowns": [ + "Exact KWR75B-CAN frame map, scaling, status bits, and update rate.", + "Final ODrive runtime interface and command scaling.", + "Final SV2A-2150 torque/current command scaling and Iq monitor mapping.", + "Final safety relay model and wiring details." + ] +} diff --git a/shared/protocol/.gitkeep b/shared/protocol/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/shared/schemas/README.md b/shared/schemas/README.md new file mode 100644 index 0000000..00e8205 --- /dev/null +++ b/shared/schemas/README.md @@ -0,0 +1,11 @@ +# Shared Schemas + +Mirrored from the current Polisher Software Suite schema bundle for implementation alignment. + +Important: +- `controller-job.v1` is what this controller accepts from `polisher-post`. +- `job.v1` is upstream planning input and must be rejected by `polisher-control`. +- `run-log.v1` is emitted by `polisher-control` after program execution. +- `manual-session-log.v1` is currently documented in repo docs/spec; formal schema can be added when Antoine locks it. + +Do not rename fields locally without coordinating with Antoine because downstream tools depend on stable names. diff --git a/shared/schemas/controller-job.schema.json b/shared/schemas/controller-job.schema.json new file mode 100644 index 0000000..3ee7c85 --- /dev/null +++ b/shared/schemas/controller-job.schema.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://polisher-suite.local/schemas/controller-job.schema.json", + "title": "Controller Job Package", + "description": "Translated execution package produced by polisher-post for polisher-control. Represents translated truth.", + "type": "object", + "required": [ + "schema_version", + "controller_job_id", + "job_id", + "machine_id", + "machine_capabilities_ref", + "controller_version", + "translated_at", + "translated_by", + "segments", + "translation_losses" + ], + "properties": { + "schema_version": { + "type": "string", + "const": "controller-job.v1" + }, + "controller_job_id": { + "type": "string", + "description": "Stable unique identifier for this translated package." + }, + "job_id": { + "type": "string", + "description": "Back-reference to the source planning job." + }, + "machine_id": { + "type": "string", + "description": "Target machine identifier." + }, + "machine_capabilities_ref": { + "type": "string", + "description": "Reference to the machine-capabilities document used for translation." + }, + "controller_version": { + "type": "string", + "description": "Target controller software version." + }, + "translated_at": { + "type": "string", + "format": "date-time" + }, + "translated_by": { + "type": "string", + "description": "polisher-post version that performed the translation." + }, + "translation_notes": { + "type": "string", + "description": "Summary of constraints applied during translation." + }, + "segments": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/segment" + } + }, + "translation_losses": { + "type": "array", + "description": "Explicit record of every constraint, clip, or approximation applied.", + "items": { + "type": "object", + "required": ["source_pass_id", "field", "description"], + "properties": { + "source_pass_id": { "type": "string" }, + "field": { "type": "string" }, + "planned_value": {}, + "translated_value": {}, + "description": { "type": "string" } + } + } + } + }, + "$defs": { + "segment": { + "type": "object", + "required": [ + "segment_id", + "sequence_index", + "source_pass_id", + "duration_s", + "commanded_force_n", + "commanded_table_rpm", + "commanded_spindle_rpm" + ], + "properties": { + "segment_id": { "type": "string" }, + "sequence_index": { "type": "integer", "minimum": 1 }, + "source_pass_id": { + "type": "string", + "description": "Back-reference to the planning pass this segment came from." + }, + "duration_s": { "type": "number", "minimum": 0 }, + "commanded_force_n": { "type": "number", "minimum": 0 }, + "commanded_table_rpm": { "type": "number", "minimum": 0 }, + "commanded_spindle_rpm": { "type": "number", "minimum": 0 }, + "commanded_cam_amplitude_deg": { "type": "number" }, + "commanded_cam_offset_deg": { "type": "number" }, + "force_modulation": { + "type": "object", + "properties": { + "harmonic": { "type": "integer" }, + "amplitude_n": { "type": "number" }, + "phase_deg": { "type": "number" } + } + }, + "dither_profile": { "type": "string" }, + "notes": { "type": "string" } + } + } + } +} diff --git a/shared/schemas/examples/controller-job.example.json b/shared/schemas/examples/controller-job.example.json new file mode 100644 index 0000000..dff3f0d --- /dev/null +++ b/shared/schemas/examples/controller-job.example.json @@ -0,0 +1,32 @@ +{ + "schema_version": "controller-job.v1", + "controller_job_id": "cjob-2026-07-001", + "job_id": "job-2026-07-001", + "machine_id": "fullum-alpha", + "machine_capabilities_ref": "machine-capabilities-fullum-alpha-v1", + "controller_version": "polisher-control/0.1.0", + "translated_at": "2026-07-15T10:15:00Z", + "translated_by": "polisher-post/0.1.0", + "translation_notes": "All setpoints within machine limits. No clipping required.", + "segments": [ + { + "segment_id": "seg-01", + "sequence_index": 1, + "source_pass_id": "pass-01", + "duration_s": 1200, + "commanded_force_n": 45.0, + "commanded_table_rpm": 4.0, + "commanded_spindle_rpm": 40.0, + "commanded_cam_amplitude_deg": 31.3, + "commanded_cam_offset_deg": 0.0, + "force_modulation": { + "harmonic": 2, + "amplitude_n": 5.0, + "phase_deg": 37.2 + }, + "dither_profile": "none", + "notes": "Direct translation — no segmentation needed for 1200s duration." + } + ], + "translation_losses": [] +} diff --git a/shared/schemas/examples/job.example.json b/shared/schemas/examples/job.example.json new file mode 100644 index 0000000..beaa01f --- /dev/null +++ b/shared/schemas/examples/job.example.json @@ -0,0 +1,71 @@ +{ + "schema_version": "job.v1", + "job_id": "job-2026-07-001", + "mirror_id": "gigabit-m1", + "machine_family": "swing-arm", + "source_map_id": "ifg-2026-07-14-pre", + "target_id": "target-m1-final-figure", + "created_at": "2026-07-15T09:30:00Z", + "created_by": "antoine", + "planner_version": "polisher-sim/0.6.0", + "calibration_state_ref": "cal-2026-06-fullum-alpha", + "strategy_summary": { + "intent": "Reduce dominant astigmatism using m=2 force modulation on 280mm tool", + "confidence": 0.72, + "notes": "Single-pass conservative correction. Dither disabled for baseline comparison." + }, + "passes": [ + { + "pass_id": "pass-01", + "sequence_index": 1, + "objective": "Astigmatism reduction via force modulation", + "preset_name": "figuring_standard", + "duration_s": 1200, + "force_n": 45.0, + "table_rpm": 4.0, + "spindle_rpm": 40.0, + "motion_family": "swing-arm-rosette", + "motion_params": { + "cam_amplitude_deg": 31.3, + "cam_offset_deg": 0.0, + "osc_hz": 0.25 + }, + "dither_profile": "none", + "force_modulation": { + "harmonic": 2, + "amplitude_n": 5.0, + "phase_deg": 37.2 + }, + "notes": "Phase derived from Zernike fit of pre-polish interferogram." + } + ], + "predicted_outcome": { + "removal_rms_nm": 18.5, + "removal_pv_nm": 62.0, + "residual_zernikes": { + "Z4": -2.1, + "Z5": 12.3, + "Z6": -8.7, + "Z7": 15.1, + "Z8": -3.2, + "Z9": 4.5, + "Z10": -2.8 + } + }, + "uncertainty": { + "removal_rms_nm_range": [14.0, 24.0], + "notes": "Uncertainty dominated by Preston coefficient calibration spread." + }, + "attachments": [ + { + "filename": "predicted_removal.csv", + "role": "predicted_removal_map", + "description": "2D removal map in nm, 256x256 grid" + }, + { + "filename": "pre_surface.csv", + "role": "input_surface_map", + "description": "Pre-polish surface map used as planning input" + } + ] +} diff --git a/shared/schemas/examples/machine-capabilities.example.json b/shared/schemas/examples/machine-capabilities.example.json new file mode 100644 index 0000000..892a375 --- /dev/null +++ b/shared/schemas/examples/machine-capabilities.example.json @@ -0,0 +1,51 @@ +{ + "schema_version": "machine-capabilities.v1", + "machine_id": "fullum-alpha", + "machine_family": "swing-arm", + "machine_name": "Fullum Swing-Arm Polisher", + "controller_version": "polisher-control/0.1.0", + "last_verified": "2026-06-01T00:00:00Z", + "supported_motion_families": ["swing-arm-rosette"], + "force_range_n": [5, 200], + "table_rpm_range": [0.5, 10.0], + "spindle_rpm_range": [10, 120], + "cam_amplitude_range_deg": [1.0, 31.3], + "cam_offset_range_deg": [-30.0, 30.0], + "force_modulation": { + "supported": true, + "max_harmonics": 3, + "max_amplitude_n": 24.0, + "notes": "m=2 astigmatism and m=3 trefoil are proven channels. m=1 coma is unreliable (score 0.09)." + }, + "supported_dither_profiles": ["none", "default"], + "segment_duration_limits": { + "min_s": 30, + "max_s": 7200 + }, + "pause_resume_support": true, + "telemetry_channels": [ + { "name": "table_rpm", "unit": "RPM", "sample_rate_hz": 100 }, + { "name": "spindle_rpm", "unit": "RPM", "sample_rate_hz": 100 }, + { "name": "force_n", "unit": "N", "sample_rate_hz": 100 }, + { "name": "arm_angle_deg", "unit": "degrees", "sample_rate_hz": 100 }, + { "name": "table_angle_deg", "unit": "degrees", "sample_rate_hz": 100 }, + { "name": "timestamp_ms", "unit": "ms", "sample_rate_hz": 100 } + ], + "safety_limits": { + "max_force_n": 200, + "max_table_rpm": 10.0, + "max_spindle_rpm": 120, + "notes": "Force hard-limited by load cell interlock. RPM limits are VFD/servo configured." + }, + "known_constraints": [ + "Cam amplitude is not servo-programmable — must be set mechanically before run.", + "Force modulation bandwidth limited to ~5 Hz by actuator response.", + "Table encoder is absolute but may have 1-2 count jitter.", + "Serial interface to Teensy limits command update rate to ~50 Hz effective." + ], + "unknowns": [ + "Exact force modulation phase accuracy at m=3 harmonic — not yet validated on hardware.", + "Thermal drift effect on force sensor over runs longer than 1 hour.", + "Arm pivot play magnitude under varying force loads." + ] +} diff --git a/shared/schemas/examples/manifest.example.json b/shared/schemas/examples/manifest.example.json new file mode 100644 index 0000000..71ee7f6 --- /dev/null +++ b/shared/schemas/examples/manifest.example.json @@ -0,0 +1,43 @@ +{ + "schema_version": "manifest.v1", + "manifest_id": "manifest-job-2026-07-001", + "bundle_type": "job", + "source_id": "job-2026-07-001", + "created_at": "2026-07-15T09:31:00Z", + "created_by": "polisher-sim/0.6.0", + "entries": [ + { + "filename": "job.json", + "role": "primary", + "hash_sha256": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "size_bytes": 2007, + "content_type": "application/json", + "description": "Frozen planning job package." + }, + { + "filename": "predicted_removal.csv", + "role": "attachment", + "hash_sha256": "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", + "size_bytes": 524288, + "content_type": "text/csv", + "description": "Predicted removal map, 256x256 grid, nm." + }, + { + "filename": "pre_surface.csv", + "role": "attachment", + "hash_sha256": "c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", + "size_bytes": 524288, + "content_type": "text/csv", + "description": "Pre-polish surface map used as planning input." + }, + { + "filename": "manifest.json", + "role": "manifest", + "hash_sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "size_bytes": 0, + "content_type": "application/json", + "description": "This manifest (self-reference, hash zeroed by convention)." + } + ], + "notes": "July proving scenario — single-pass astigmatism correction job bundle." +} diff --git a/shared/schemas/examples/run-log.example.json b/shared/schemas/examples/run-log.example.json new file mode 100644 index 0000000..447682b --- /dev/null +++ b/shared/schemas/examples/run-log.example.json @@ -0,0 +1,64 @@ +{ + "schema_version": "run-log.v1", + "run_id": "run-2026-07-001", + "job_id": "job-2026-07-001", + "controller_job_id": "cjob-2026-07-001", + "controller_version": "polisher-control/0.1.0", + "machine_id": "fullum-alpha", + "started_at": "2026-07-17T14:00:00Z", + "ended_at": "2026-07-17T14:20:35Z", + "result_state": "completed", + "segments": [ + { + "segment_id": "seg-01", + "source_pass_id": "pass-01", + "commanded_duration_s": 1200, + "actual_duration_s": 1203.5, + "result_state": "completed", + "commanded": { + "force_n": 45.0, + "table_rpm": 4.0, + "spindle_rpm": 40.0, + "cam_amplitude_deg": 31.3, + "cam_offset_deg": 0.0 + }, + "actual": { + "force_n_mean": 44.7, + "force_n_min": 41.2, + "force_n_max": 49.8, + "table_rpm_mean": 3.98, + "spindle_rpm_mean": 39.9 + }, + "pause_windows": [], + "anomaly_flags": [], + "notes": "Clean run. Minor force excursion at arm reversal points." + } + ], + "commanded_summary": { + "force_n": 45.0, + "table_rpm": 4.0, + "spindle_rpm": 40.0 + }, + "actual_summary": { + "force_n_mean": 44.7, + "force_n_min": 41.2, + "force_n_max": 49.8, + "table_rpm_mean": 3.98, + "spindle_rpm_mean": 39.9 + }, + "alarms": [], + "events": [ + { + "timestamp": "2026-07-17T14:00:00Z", + "type": "run_started", + "detail": "Operator acknowledged start." + }, + { + "timestamp": "2026-07-17T14:20:35Z", + "type": "run_completed", + "detail": "All segments completed normally." + } + ], + "telemetry_ref": "telemetry-run-2026-07-001.csv", + "operator_notes": "Smooth run. No intervention needed." +} diff --git a/shared/schemas/job.schema.json b/shared/schemas/job.schema.json new file mode 100644 index 0000000..d707419 --- /dev/null +++ b/shared/schemas/job.schema.json @@ -0,0 +1,158 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://polisher-suite.local/schemas/job.schema.json", + "title": "Polisher Job Package", + "description": "Frozen planning artifact emitted by polisher-sim. Represents planning truth.", + "type": "object", + "required": [ + "schema_version", + "job_id", + "mirror_id", + "machine_family", + "planner_version", + "created_at", + "strategy_summary", + "passes" + ], + "properties": { + "schema_version": { + "type": "string", + "const": "job.v1" + }, + "job_id": { + "type": "string", + "description": "Stable unique identifier for this job." + }, + "mirror_id": { + "type": "string" + }, + "machine_family": { + "type": "string", + "description": "Target machine family (e.g. swing-arm)." + }, + "source_map_id": { + "type": "string", + "description": "Reference to the input surface measurement." + }, + "target_id": { + "type": "string", + "description": "Reference to the target surface specification." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": "string" + }, + "planner_version": { + "type": "string", + "description": "Software version of polisher-sim that generated this job." + }, + "calibration_state_ref": { + "type": "string", + "description": "Reference to the calibration state used during planning." + }, + "strategy_summary": { + "type": "object", + "required": ["intent"], + "properties": { + "intent": { "type": "string" }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "notes": { "type": "string" } + } + }, + "passes": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/pass" + } + }, + "predicted_outcome": { + "type": "object", + "description": "Summary of predicted post-polish surface state.", + "properties": { + "removal_rms_nm": { "type": "number" }, + "removal_pv_nm": { "type": "number" }, + "residual_zernikes": { + "type": "object", + "additionalProperties": { "type": "number" } + } + } + }, + "uncertainty": { + "type": "object", + "description": "Planner's uncertainty estimates.", + "properties": { + "removal_rms_nm_range": { + "type": "array", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2 + }, + "notes": { "type": "string" } + } + }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "required": ["filename", "role"], + "properties": { + "filename": { "type": "string" }, + "role": { "type": "string" }, + "description": { "type": "string" } + } + } + } + }, + "$defs": { + "pass": { + "type": "object", + "required": [ + "pass_id", + "sequence_index", + "duration_s", + "force_n", + "table_rpm", + "spindle_rpm", + "motion_family" + ], + "properties": { + "pass_id": { "type": "string" }, + "sequence_index": { "type": "integer", "minimum": 1 }, + "objective": { "type": "string" }, + "preset_name": { "type": "string" }, + "duration_s": { "type": "number", "minimum": 0 }, + "force_n": { "type": "number", "minimum": 0 }, + "table_rpm": { "type": "number", "minimum": 0 }, + "spindle_rpm": { "type": "number", "minimum": 0 }, + "motion_family": { "type": "string" }, + "motion_params": { + "type": "object", + "additionalProperties": true + }, + "zone": { "type": "string" }, + "dither_profile": { "type": "string" }, + "force_modulation": { + "type": "object", + "properties": { + "harmonic": { "type": "integer" }, + "amplitude_n": { "type": "number" }, + "phase_deg": { "type": "number" } + } + }, + "acceptance": { + "type": "object", + "additionalProperties": true + }, + "notes": { "type": "string" } + } + } + } +} diff --git a/shared/schemas/machine-capabilities.schema.json b/shared/schemas/machine-capabilities.schema.json new file mode 100644 index 0000000..d548cdd --- /dev/null +++ b/shared/schemas/machine-capabilities.schema.json @@ -0,0 +1,135 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://polisher-suite.local/schemas/machine-capabilities.schema.json", + "title": "Machine Capabilities", + "description": "Conservative declaration of what a machine+controller can safely execute. Unknown capabilities must be explicitly marked rather than assumed.", + "type": "object", + "required": [ + "schema_version", + "machine_id", + "machine_family", + "controller_version", + "supported_motion_families", + "force_range_n", + "table_rpm_range", + "spindle_rpm_range" + ], + "properties": { + "schema_version": { + "type": "string", + "const": "machine-capabilities.v1" + }, + "machine_id": { + "type": "string" + }, + "machine_family": { + "type": "string" + }, + "machine_name": { + "type": "string" + }, + "controller_version": { + "type": "string" + }, + "last_verified": { + "type": "string", + "format": "date-time", + "description": "When these capabilities were last confirmed on the real machine." + }, + "supported_motion_families": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "force_range_n": { + "$ref": "#/$defs/range_or_unknown" + }, + "table_rpm_range": { + "$ref": "#/$defs/range_or_unknown" + }, + "spindle_rpm_range": { + "$ref": "#/$defs/range_or_unknown" + }, + "cam_amplitude_range_deg": { + "$ref": "#/$defs/range_or_unknown" + }, + "cam_offset_range_deg": { + "$ref": "#/$defs/range_or_unknown" + }, + "force_modulation": { + "type": "object", + "description": "Force modulation capabilities. Omit entirely if unknown.", + "properties": { + "supported": { "type": "boolean" }, + "max_harmonics": { "type": "integer" }, + "max_amplitude_n": { "type": "number" }, + "notes": { "type": "string" } + } + }, + "supported_dither_profiles": { + "type": "array", + "items": { "type": "string" }, + "description": "List of dither profile names the controller can handle. Empty = none supported." + }, + "segment_duration_limits": { + "type": "object", + "properties": { + "min_s": { "type": "number" }, + "max_s": { "type": "number" } + } + }, + "pause_resume_support": { + "type": "boolean" + }, + "telemetry_channels": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "unit"], + "properties": { + "name": { "type": "string" }, + "unit": { "type": "string" }, + "sample_rate_hz": { "type": "number" }, + "notes": { "type": "string" } + } + } + }, + "safety_limits": { + "type": "object", + "description": "Hard safety limits that the controller enforces regardless of job requests.", + "properties": { + "max_force_n": { "type": "number" }, + "max_table_rpm": { "type": "number" }, + "max_spindle_rpm": { "type": "number" }, + "notes": { "type": "string" } + } + }, + "known_constraints": { + "type": "array", + "items": { "type": "string" }, + "description": "Free-text list of known limitations, quirks, or warnings." + }, + "unknowns": { + "type": "array", + "items": { "type": "string" }, + "description": "Capabilities that have NOT been verified. Explicit unknowns prevent fake certainty." + } + }, + "$defs": { + "range_or_unknown": { + "oneOf": [ + { + "type": "array", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2, + "description": "[min, max] range." + }, + { + "type": "string", + "const": "unknown" + } + ] + } + } +} diff --git a/shared/schemas/manifest.schema.json b/shared/schemas/manifest.schema.json new file mode 100644 index 0000000..83e9e57 --- /dev/null +++ b/shared/schemas/manifest.schema.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://polisher-suite.local/schemas/manifest.schema.json", + "title": "Artifact Bundle Manifest", + "description": "Manifest for a portable artifact bundle. Lists all files with hashes for integrity verification.", + "type": "object", + "required": [ + "schema_version", + "manifest_id", + "bundle_type", + "created_at", + "entries" + ], + "properties": { + "schema_version": { + "type": "string", + "const": "manifest.v1" + }, + "manifest_id": { + "type": "string", + "description": "Unique identifier for this manifest." + }, + "bundle_type": { + "type": "string", + "enum": ["job", "controller-job", "run-log"], + "description": "What kind of artifact bundle this manifest describes." + }, + "source_id": { + "type": "string", + "description": "The primary artifact ID (job_id, controller_job_id, or run_id)." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": "string", + "description": "Software/version that created this bundle." + }, + "entries": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["filename", "role", "hash_sha256"], + "properties": { + "filename": { + "type": "string", + "description": "Relative path within the bundle." + }, + "role": { + "type": "string", + "description": "Semantic role: primary, attachment, telemetry, report, etc." + }, + "hash_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "size_bytes": { + "type": "integer", + "minimum": 0 + }, + "content_type": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + }, + "notes": { + "type": "string" + } + } +} diff --git a/shared/schemas/run-log.schema.json b/shared/schemas/run-log.schema.json new file mode 100644 index 0000000..4bb48a8 --- /dev/null +++ b/shared/schemas/run-log.schema.json @@ -0,0 +1,171 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://polisher-suite.local/schemas/run-log.schema.json", + "title": "Run Log", + "description": "Canonical execution record emitted by polisher-control. Represents executed truth.", + "type": "object", + "required": [ + "schema_version", + "run_id", + "job_id", + "controller_job_id", + "controller_version", + "machine_id", + "started_at", + "ended_at", + "result_state", + "segments" + ], + "properties": { + "schema_version": { + "type": "string", + "const": "run-log.v1" + }, + "run_id": { + "type": "string", + "description": "Stable unique identifier for this execution run." + }, + "job_id": { + "type": "string", + "description": "Back-reference to the source planning job." + }, + "controller_job_id": { + "type": "string", + "description": "Back-reference to the translated controller-job package." + }, + "controller_version": { + "type": "string" + }, + "machine_id": { + "type": "string" + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "ended_at": { + "type": "string", + "format": "date-time" + }, + "result_state": { + "type": "string", + "enum": ["completed", "completed_with_pause", "aborted", "faulted"] + }, + "segments": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/executed_segment" + } + }, + "commanded_summary": { + "type": "object", + "properties": { + "force_n": { "type": "number" }, + "table_rpm": { "type": "number" }, + "spindle_rpm": { "type": "number" } + } + }, + "actual_summary": { + "type": "object", + "properties": { + "force_n_mean": { "type": "number" }, + "force_n_min": { "type": "number" }, + "force_n_max": { "type": "number" }, + "table_rpm_mean": { "type": "number" }, + "spindle_rpm_mean": { "type": "number" } + } + }, + "alarms": { + "type": "array", + "items": { + "type": "object", + "required": ["timestamp", "code", "message"], + "properties": { + "timestamp": { "type": "string", "format": "date-time" }, + "code": { "type": "string" }, + "message": { "type": "string" }, + "severity": { "type": "string", "enum": ["info", "warning", "critical"] } + } + } + }, + "events": { + "type": "array", + "items": { + "type": "object", + "required": ["timestamp", "type"], + "properties": { + "timestamp": { "type": "string", "format": "date-time" }, + "type": { "type": "string" }, + "detail": { "type": "string" } + } + } + }, + "telemetry_ref": { + "type": "string", + "description": "Filename or path to the raw telemetry data file." + }, + "operator_notes": { + "type": "string" + } + }, + "$defs": { + "executed_segment": { + "type": "object", + "required": [ + "segment_id", + "source_pass_id", + "commanded_duration_s", + "actual_duration_s", + "result_state" + ], + "properties": { + "segment_id": { "type": "string" }, + "source_pass_id": { "type": "string" }, + "commanded_duration_s": { "type": "number" }, + "actual_duration_s": { "type": "number" }, + "result_state": { + "type": "string", + "enum": ["completed", "completed_with_pause", "aborted", "faulted", "skipped"] + }, + "commanded": { + "type": "object", + "properties": { + "force_n": { "type": "number" }, + "table_rpm": { "type": "number" }, + "spindle_rpm": { "type": "number" }, + "cam_amplitude_deg": { "type": "number" }, + "cam_offset_deg": { "type": "number" } + } + }, + "actual": { + "type": "object", + "properties": { + "force_n_mean": { "type": "number" }, + "force_n_min": { "type": "number" }, + "force_n_max": { "type": "number" }, + "table_rpm_mean": { "type": "number" }, + "spindle_rpm_mean": { "type": "number" } + } + }, + "pause_windows": { + "type": "array", + "items": { + "type": "object", + "required": ["paused_at", "resumed_at"], + "properties": { + "paused_at": { "type": "string", "format": "date-time" }, + "resumed_at": { "type": "string", "format": "date-time" }, + "reason": { "type": "string" } + } + } + }, + "anomaly_flags": { + "type": "array", + "items": { "type": "string" } + }, + "notes": { "type": "string" } + } + } + } +} diff --git a/tests/test_contract_constants.py b/tests/test_contract_constants.py new file mode 100644 index 0000000..492570e --- /dev/null +++ b/tests/test_contract_constants.py @@ -0,0 +1,16 @@ +from polisher_control.telemetry_channels import CORE_CHANNELS +from polisher_control.contracts import MACHINE_ID + + +def test_machine_id_is_fullum_alpha(): + assert MACHINE_ID == "fullum-alpha" + + +def test_core_telemetry_channels_are_stable(): + assert CORE_CHANNELS[:4] == [ + "timestamp_us", + "table_angle_deg", + "arm_angle_deg", + "fz_n", + ] + assert "force_setpoint_n" in CORE_CHANNELS diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py new file mode 100644 index 0000000..b4d662d --- /dev/null +++ b/tests/test_state_machine.py @@ -0,0 +1,26 @@ +import pytest + +from polisher_control.state_machine import IllegalTransitionError, MachineState, StateMachine + + +def test_manual_mode_path_returns_to_idle(): + sm = StateMachine() + assert sm.trigger("enter_manual") == MachineState.MANUAL + assert sm.trigger("exit_manual") == MachineState.IDLE + + +def test_faulted_requires_reset(): + sm = StateMachine(MachineState.MANUAL) + assert sm.trigger("fault") == MachineState.FAULTED + with pytest.raises(IllegalTransitionError): + sm.trigger("enter_manual") + assert sm.trigger("reset") == MachineState.IDLE + + +def test_operator_acknowledge_required_before_running(): + sm = StateMachine() + sm.trigger("load_job") + with pytest.raises(IllegalTransitionError): + sm.trigger("start") + sm.trigger("operator_acknowledge") + assert sm.trigger("start") == MachineState.RUNNING