diff --git a/README.md b/README.md index ca60f24..b240f73 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ shared contracts -> schemas, logs, machine capabilities, provenance Normand can operate the polisher manually from the touchscreen, with: -- safe force/table/spindle control; +- safe force/table/spindle control, including clockwise/counter-clockwise toolhead rotation selection; - KWR75B-CAN force/torque sensor integration; - ODrive S1 + M8325s spindle drive integration; - Teensy-side fast safety and setpoint/telemetry loop; diff --git a/ROADMAP.md b/ROADMAP.md index e8d04e3..8c1a049 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,13 +22,13 @@ - [ ] 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. +- [ ] ODrive command/telemetry interface selected and documented, including sign mapping for `cw`/`ccw` physical tool rotation. - [ ] 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. +- [ ] Manual setpoints: force, table RPM, spindle RPM, spindle direction (`cw`/`ccw`), 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`. diff --git a/docs/04-host-teensy-protocol-v1.md b/docs/04-host-teensy-protocol-v1.md index 8e3c81f..46669ba 100644 --- a/docs/04-host-teensy-protocol-v1.md +++ b/docs/04-host-teensy-protocol-v1.md @@ -36,6 +36,15 @@ Requirements: - `SEGMENT_DONE` - `ABORT_COMPLETE` +## Spindle direction field + +`MANUAL_START`, `SETPOINT`, and future `SEGMENT_START` payloads include `spindle_direction` / `commanded_spindle_direction` with stable values: + +- `cw` — clockwise +- `ccw` — counter-clockwise + +Physical convention: direction is observed from above the toolhead looking down toward the mirror/tool contact. The ODrive sign mapping is a commissioning item and must be configured so UI/protocol values match physical rotation. Direction changes are explicit setpoint changes; do not infer direction from a signed RPM. RPM remains a non-negative magnitude. + ## v1 production subset Manual v1 only needs: diff --git a/docs/05-telemetry-channel-spec-v1.md b/docs/05-telemetry-channel-spec-v1.md index 5facf64..eb95339 100644 --- a/docs/05-telemetry-channel-spec-v1.md +++ b/docs/05-telemetry-channel-spec-v1.md @@ -34,6 +34,8 @@ spindle_motor_temp_c arm_angle_linearized_deg table_rpm_setpoint spindle_rpm_setpoint +spindle_direction_setpoint +spindle_direction_actual force_actuator_cmd estop_active interlock_state @@ -50,6 +52,7 @@ fz_contact_n - 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. +- Spindle RPM remains a non-negative magnitude; spindle direction is logged separately as `cw` / `ccw` rather than encoded as signed RPM. ## CSV baseline diff --git a/docs/07-manual-mode-workflow.md b/docs/07-manual-mode-workflow.md index 038d1d4..af26591 100644 --- a/docs/07-manual-mode-workflow.md +++ b/docs/07-manual-mode-workflow.md @@ -16,6 +16,7 @@ - force N - table RPM - spindle RPM + - spindle rotation direction: clockwise (`cw`) or counter-clockwise (`ccw`) as viewed from above the toolhead looking down toward the mirror/tool contact - optional force modulation harmonic/amplitude/phase 7. Operator presses Start. 8. Host sends `MANUAL_START`. @@ -27,6 +28,6 @@ - Manual mode cannot start with stale geometry. - Manual mode uses the same safety/interlocks as job mode. -- Every setpoint change is logged. +- Every setpoint change is logged, including spindle direction changes. - 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/09-acceptance-checklist.md b/docs/09-acceptance-checklist.md index a3a2e1c..cb80300 100644 --- a/docs/09-acceptance-checklist.md +++ b/docs/09-acceptance-checklist.md @@ -16,6 +16,7 @@ This checklist condenses the current P11 firmware/control spec for implementatio - [ ] Force PID tracks setpoint within agreed commissioning tolerance. - [ ] Force modulation uses live table encoder angle. - [ ] Table and spindle RPM follow commands. +- [ ] Toolhead spindle direction follows clockwise/counter-clockwise operator/job selection and is logged explicitly. - [ ] Pause time excluded from segment polishing time. ## Telemetry @@ -39,7 +40,7 @@ This checklist condenses the current P11 firmware/control spec for implementatio ## Manual mode - [ ] `MANUAL` reachable from `IDLE`. -- [ ] Live setpoint adjustment works. +- [ ] Live setpoint adjustment works for force, table RPM, spindle RPM, spindle direction, and optional modulation. - [ ] Geometric gate blocks stale geometry. - [ ] Setpoint changes are timestamped events. - [ ] Manual-session log emitted on exit. diff --git a/docs/LLM_CONTEXT.md b/docs/LLM_CONTEXT.md index 9bb846d..fcbebc3 100644 --- a/docs/LLM_CONTEXT.md +++ b/docs/LLM_CONTEXT.md @@ -141,7 +141,7 @@ Program execution exists as a contract scaffold and future path, but the v1 prod ### v1 must include - `MANUAL` state reachable from `IDLE` only. -- Operator live controls for force, table RPM, spindle RPM, and optional force modulation. +- Operator live controls for force, table RPM, spindle RPM, spindle rotation direction (`cw`/`ccw`), and optional force modulation. - Mandatory geometric gate before `MANUAL` or `RUNNING`. - Full telemetry at **≥100 Hz** with a single Teensy monotonic timestamp source. - Manual-session log emitted on exit. @@ -214,7 +214,7 @@ Shop-floor sequence: - `configured_arm_amplitude_deg` - `configured_arm_center_deg` 5. Operator confirms each value; no skip path. -6. Operator enters initial force, table RPM, spindle RPM, optional modulation. +6. Operator enters initial force, table RPM, spindle RPM, spindle rotation direction (`cw`/`ccw`), optional modulation. 7. Host sends `MANUAL_START`; Teensy ACK/NACKs. 8. Telemetry begins at ≥100 Hz. 9. Operator adjusts live setpoints; each change is logged as an event. @@ -263,6 +263,8 @@ Shop-floor sequence: - `force_setpoint_n` - `table_rpm_setpoint` - `spindle_rpm_setpoint` +- `spindle_direction_setpoint` +- `spindle_direction_actual` - `force_actuator_cmd` - `estop_active` - `interlock_state` diff --git a/docs/nick-generated/2026-06-02-spindle-direction-selection.md b/docs/nick-generated/2026-06-02-spindle-direction-selection.md new file mode 100644 index 0000000..8a2aa86 --- /dev/null +++ b/docs/nick-generated/2026-06-02-spindle-direction-selection.md @@ -0,0 +1,95 @@ +--- +title: Feature Intake — Toolhead Spindle Direction Selection +status: draft +requested_by: Antoine Letarte / Normand Fullum +generated_by: Nick / Hermes +project: P11-Polisher-Fullum +repo: polisher-control +source_truth: false +created: 2026-06-02 +privacy: technical-only +--- + +# Feature Intake — Toolhead Spindle Direction Selection + +## 1. Requested behavior + +Normand wants the operator to select the toolhead/tool spindle rotation direction between clockwise and counter-clockwise. + +The operator-facing control belongs in manual mode first, and the same field should be carried in future controller-job segments so program execution does not require a protocol/schema redesign. + +## 2. Classification + +- [x] Execution feature in `polisher-control` +- [x] Contract feature touching schemas/protocol/telemetry/logs +- [ ] Planning/intelligence feature that belongs upstream +- [ ] Safety/scope feature requiring Antoine approval beyond this explicit request + +Rationale: the controller is not deciding polishing strategy. It is executing/logging an operator- or job-selected spindle direction. Because this changes the host↔Teensy payload and controller-job/log shape, it is also a contract feature. + +## 3. Operator-visible behavior + +- Manual UI exposes a spindle direction selector next to spindle RPM. +- Allowed values are: + - `cw` — clockwise + - `ccw` — counter-clockwise +- Physical convention: direction is viewed from above the toolhead looking down toward the mirror/tool contact. +- Default for existing/manual startup can be `cw` unless Antoine/Cédric chooses another default. +- Any direction change while running is logged as a timestamped setpoint event. + +## 4. Affected layers + +- Host controller: normalize/validate direction aliases and include direction in manual setpoints/log events. +- Teensy firmware: accept direction in `MANUAL_START`, `SETPOINT`, and future `SEGMENT_START`; map to ODrive command sign. +- Host ↔ Teensy protocol: add `spindle_direction` / `commanded_spindle_direction` enum values `cw` / `ccw`. +- Telemetry channels: add `spindle_direction_setpoint` and `spindle_direction_actual` as recommended channels. +- Run/manual-session logs: record direction separately from RPM; RPM remains a non-negative magnitude. +- Machine capability profile: advertise `supported_spindle_directions: ["cw", "ccw"]`. +- Shared schemas: add direction fields to job/controller-job/run-log contracts. +- Tests: assert stable enum values, schema fields, and telemetry channel presence. + +## 5. Safety implications + +- Direction must not be inferred from signed RPM; use explicit enum plus non-negative RPM magnitude. +- Direction reversal while spindle is moving should be ramped/handled safely by firmware/ODrive implementation. The scaffold currently defines the contract; Cédric should decide whether live reversal is allowed directly or requires ramp-to-zero before applying the new direction. +- ODrive sign mapping is a commissioning item: firmware must be configured so UI `cw`/`ccw` matches physical observed tool rotation. + +Safety decision: + +- [x] No new optical strategy or autonomous behavior. +- [x] Implementation should add a safe reversal rule before hardware use. + +## 6. Contract implications + +- New controller-job segment field: `commanded_spindle_direction` enum `cw|ccw`. +- New planning job pass field: `spindle_direction` enum `cw|ccw`. +- New run-log commanded fields: `spindle_direction`. +- New capability field: `supported_spindle_directions`. +- New telemetry context channels: `spindle_direction_setpoint`, `spindle_direction_actual`. + +## 7. Acceptance checks + +- [x] Host contract tests for enum values and alias normalization. +- [x] Schema field test for `commanded_spindle_direction`. +- [x] Telemetry channel test for direction channels. +- [ ] Firmware compile/build check once Cédric maps this into Teensy/ODrive code. +- [ ] Bench test confirms UI `cw` physically rotates clockwise by the documented convention. +- [ ] Bench test confirms UI `ccw` physically rotates counter-clockwise by the documented convention. +- [ ] Bench test confirms live reversal behavior is safe or blocked until spindle reaches zero. + +## 8. Open questions + +- Cédric: confirm the ODrive command/sign mapping and whether live direction reversal requires ramp-to-zero. +- Antoine/Normand: confirm the physical viewing convention: current draft uses “viewed from above the toolhead looking down toward the mirror/tool contact.” + +## 9. Implementation notes + +Initial scaffold update created on branch `docs/nick-llm-context-20260602`: + +- `host/polisher_control/contracts.py` +- `host/polisher_control/telemetry_channels.py` +- `tests/test_spindle_direction_contract.py` +- `shared/schemas/*.schema.json` +- `shared/schemas/examples/*.json` +- `shared/machine/fullum-alpha.capabilities.v1.json` +- affected docs/checklists diff --git a/docs/reference/source-map.md b/docs/reference/source-map.md index 48b6d66..94ab9b3 100644 --- a/docs/reference/source-map.md +++ b/docs/reference/source-map.md @@ -4,7 +4,8 @@ This repo foundation is derived from the current P11 / Fullum polisher-control p Primary source documents: -- Fullum Polisher — Machine Control & Firmware Specification v1 +- `software-suite/control/firmware/Fullum-Polisher-Machine-Control-Firmware-Spec-Report.md` / `.pdf` — Cédric-facing report wrapper. +- `software-suite/control/firmware/Fullum-Polisher-Machine-Control-Firmware-Spec-v1.md` — transcluded implementation spec body. - Controller / Bridge / Digital Twin Architecture Plan - polisher-control — System Definition - polisher-control — Roadmap diff --git a/host/polisher_control/contracts.py b/host/polisher_control/contracts.py index 784220f..b78b5f7 100644 --- a/host/polisher_control/contracts.py +++ b/host/polisher_control/contracts.py @@ -1,6 +1,50 @@ +from enum import StrEnum + + 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/" + + +class SpindleDirection(StrEnum): + """Toolhead spindle rotation direction using stable protocol/schema values. + + Physical convention: clockwise/counter-clockwise are as viewed from above the + toolhead looking down toward the mirror/tool contact. ODrive sign mapping is + a commissioning item and must be configured so these UI/protocol values match + the observed tool rotation. + """ + + CLOCKWISE = "cw" + COUNTER_CLOCKWISE = "ccw" + + @classmethod + def allowed_values(cls) -> list[str]: + return [direction.value for direction in cls] + + +_SPINDLE_DIRECTION_ALIASES = { + "cw": SpindleDirection.CLOCKWISE, + "clockwise": SpindleDirection.CLOCKWISE, + "c.w.": SpindleDirection.CLOCKWISE, + "ccw": SpindleDirection.COUNTER_CLOCKWISE, + "counterclockwise": SpindleDirection.COUNTER_CLOCKWISE, + "counter-clockwise": SpindleDirection.COUNTER_CLOCKWISE, + "counter_clockwise": SpindleDirection.COUNTER_CLOCKWISE, + "anticlockwise": SpindleDirection.COUNTER_CLOCKWISE, +} + + +def normalize_spindle_direction(value: str | SpindleDirection) -> SpindleDirection: + """Normalize UI/protocol aliases to a SpindleDirection enum.""" + if isinstance(value, SpindleDirection): + return value + key = value.strip().lower() + try: + return _SPINDLE_DIRECTION_ALIASES[key] + except KeyError as exc: + allowed = ", ".join(SpindleDirection.allowed_values()) + raise ValueError(f"Unsupported spindle direction {value!r}; expected one of: {allowed}") from exc diff --git a/host/polisher_control/telemetry_channels.py b/host/polisher_control/telemetry_channels.py index cd9e7b9..c90d656 100644 --- a/host/polisher_control/telemetry_channels.py +++ b/host/polisher_control/telemetry_channels.py @@ -28,6 +28,8 @@ RECOMMENDED_CHANNELS = [ "arm_angle_linearized_deg", "table_rpm_setpoint", "spindle_rpm_setpoint", + "spindle_direction_setpoint", + "spindle_direction_actual", "force_actuator_cmd", "estop_active", "interlock_state", diff --git a/shared/machine/fullum-alpha.capabilities.v1.json b/shared/machine/fullum-alpha.capabilities.v1.json index 2a37f75..8d82a28 100644 --- a/shared/machine/fullum-alpha.capabilities.v1.json +++ b/shared/machine/fullum-alpha.capabilities.v1.json @@ -107,6 +107,16 @@ "name": "force_setpoint_n", "unit": "see docs/05-telemetry-channel-spec-v1.md", "sample_rate_hz": 100 + }, + { + "name": "spindle_direction_setpoint", + "unit": "enum(cw|ccw)", + "sample_rate_hz": 10 + }, + { + "name": "spindle_direction_actual", + "unit": "enum(cw|ccw)", + "sample_rate_hz": 10 } ], "safety_limits": { @@ -126,5 +136,9 @@ "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." + ], + "supported_spindle_directions": [ + "cw", + "ccw" ] } diff --git a/shared/schemas/controller-job.schema.json b/shared/schemas/controller-job.schema.json index 3ee7c85..b9f6429 100644 --- a/shared/schemas/controller-job.schema.json +++ b/shared/schemas/controller-job.schema.json @@ -65,13 +65,23 @@ "description": "Explicit record of every constraint, clip, or approximation applied.", "items": { "type": "object", - "required": ["source_pass_id", "field", "description"], + "required": [ + "source_pass_id", + "field", + "description" + ], "properties": { - "source_pass_id": { "type": "string" }, - "field": { "type": "string" }, + "source_pass_id": { + "type": "string" + }, + "field": { + "type": "string" + }, "planned_value": {}, "translated_value": {}, - "description": { "type": "string" } + "description": { + "type": "string" + } } } } @@ -86,31 +96,71 @@ "duration_s", "commanded_force_n", "commanded_table_rpm", - "commanded_spindle_rpm" + "commanded_spindle_rpm", + "commanded_spindle_direction" ], "properties": { - "segment_id": { "type": "string" }, - "sequence_index": { "type": "integer", "minimum": 1 }, + "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" }, + "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" } + "harmonic": { + "type": "integer" + }, + "amplitude_n": { + "type": "number" + }, + "phase_deg": { + "type": "number" + } } }, - "dither_profile": { "type": "string" }, - "notes": { "type": "string" } + "dither_profile": { + "type": "string" + }, + "notes": { + "type": "string" + }, + "commanded_spindle_direction": { + "type": "string", + "enum": [ + "cw", + "ccw" + ], + "description": "Toolhead spindle rotation direction, as viewed from above the toolhead looking down toward the mirror/tool contact. ODrive sign mapping is commissioned to match this physical convention." + } } } } diff --git a/shared/schemas/examples/controller-job.example.json b/shared/schemas/examples/controller-job.example.json index dff3f0d..299bfea 100644 --- a/shared/schemas/examples/controller-job.example.json +++ b/shared/schemas/examples/controller-job.example.json @@ -25,7 +25,8 @@ "phase_deg": 37.2 }, "dither_profile": "none", - "notes": "Direct translation — no segmentation needed for 1200s duration." + "notes": "Direct translation \u2014 no segmentation needed for 1200s duration.", + "commanded_spindle_direction": "cw" } ], "translation_losses": [] diff --git a/shared/schemas/examples/job.example.json b/shared/schemas/examples/job.example.json index beaa01f..fe8a08a 100644 --- a/shared/schemas/examples/job.example.json +++ b/shared/schemas/examples/job.example.json @@ -36,7 +36,8 @@ "amplitude_n": 5.0, "phase_deg": 37.2 }, - "notes": "Phase derived from Zernike fit of pre-polish interferogram." + "notes": "Phase derived from Zernike fit of pre-polish interferogram.", + "spindle_direction": "cw" } ], "predicted_outcome": { @@ -53,7 +54,10 @@ } }, "uncertainty": { - "removal_rms_nm_range": [14.0, 24.0], + "removal_rms_nm_range": [ + 14.0, + 24.0 + ], "notes": "Uncertainty dominated by Preston coefficient calibration spread." }, "attachments": [ diff --git a/shared/schemas/examples/machine-capabilities.example.json b/shared/schemas/examples/machine-capabilities.example.json index 892a375..b19a1e2 100644 --- a/shared/schemas/examples/machine-capabilities.example.json +++ b/shared/schemas/examples/machine-capabilities.example.json @@ -5,31 +5,85 @@ "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], + "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"], + "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 } + { + "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 + }, + { + "name": "spindle_direction_setpoint", + "unit": "enum(cw|ccw)", + "sample_rate_hz": 10 + }, + { + "name": "spindle_direction_actual", + "unit": "enum(cw|ccw)", + "sample_rate_hz": 10 + } ], "safety_limits": { "max_force_n": 200, @@ -38,14 +92,18 @@ "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.", + "Cam amplitude is not servo-programmable \u2014 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.", + "Exact force modulation phase accuracy at m=3 harmonic \u2014 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." + ], + "supported_spindle_directions": [ + "cw", + "ccw" ] } diff --git a/shared/schemas/examples/run-log.example.json b/shared/schemas/examples/run-log.example.json index 447682b..732efb3 100644 --- a/shared/schemas/examples/run-log.example.json +++ b/shared/schemas/examples/run-log.example.json @@ -20,7 +20,8 @@ "table_rpm": 4.0, "spindle_rpm": 40.0, "cam_amplitude_deg": 31.3, - "cam_offset_deg": 0.0 + "cam_offset_deg": 0.0, + "spindle_direction": "cw" }, "actual": { "force_n_mean": 44.7, @@ -37,7 +38,8 @@ "commanded_summary": { "force_n": 45.0, "table_rpm": 4.0, - "spindle_rpm": 40.0 + "spindle_rpm": 40.0, + "spindle_direction": "cw" }, "actual_summary": { "force_n_mean": 44.7, diff --git a/shared/schemas/job.schema.json b/shared/schemas/job.schema.json index d707419..15d5aa1 100644 --- a/shared/schemas/job.schema.json +++ b/shared/schemas/job.schema.json @@ -55,15 +55,21 @@ }, "strategy_summary": { "type": "object", - "required": ["intent"], + "required": [ + "intent" + ], "properties": { - "intent": { "type": "string" }, + "intent": { + "type": "string" + }, "confidence": { "type": "number", "minimum": 0, "maximum": 1 }, - "notes": { "type": "string" } + "notes": { + "type": "string" + } } }, "passes": { @@ -77,11 +83,17 @@ "type": "object", "description": "Summary of predicted post-polish surface state.", "properties": { - "removal_rms_nm": { "type": "number" }, - "removal_pv_nm": { "type": "number" }, + "removal_rms_nm": { + "type": "number" + }, + "removal_pv_nm": { + "type": "number" + }, "residual_zernikes": { "type": "object", - "additionalProperties": { "type": "number" } + "additionalProperties": { + "type": "number" + } } } }, @@ -91,22 +103,35 @@ "properties": { "removal_rms_nm_range": { "type": "array", - "items": { "type": "number" }, + "items": { + "type": "number" + }, "minItems": 2, "maxItems": 2 }, - "notes": { "type": "string" } + "notes": { + "type": "string" + } } }, "attachments": { "type": "array", "items": { "type": "object", - "required": ["filename", "role"], + "required": [ + "filename", + "role" + ], "properties": { - "filename": { "type": "string" }, - "role": { "type": "string" }, - "description": { "type": "string" } + "filename": { + "type": "string" + }, + "role": { + "type": "string" + }, + "description": { + "type": "string" + } } } } @@ -121,37 +146,81 @@ "force_n", "table_rpm", "spindle_rpm", + "spindle_direction", "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" }, + "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" }, + "zone": { + "type": "string" + }, + "dither_profile": { + "type": "string" + }, "force_modulation": { "type": "object", "properties": { - "harmonic": { "type": "integer" }, - "amplitude_n": { "type": "number" }, - "phase_deg": { "type": "number" } + "harmonic": { + "type": "integer" + }, + "amplitude_n": { + "type": "number" + }, + "phase_deg": { + "type": "number" + } } }, "acceptance": { "type": "object", "additionalProperties": true }, - "notes": { "type": "string" } + "notes": { + "type": "string" + }, + "spindle_direction": { + "type": "string", + "enum": [ + "cw", + "ccw" + ], + "description": "Requested toolhead spindle rotation direction using the same physical convention as polisher-control." + } } } } diff --git a/shared/schemas/machine-capabilities.schema.json b/shared/schemas/machine-capabilities.schema.json index d548cdd..90c7e42 100644 --- a/shared/schemas/machine-capabilities.schema.json +++ b/shared/schemas/machine-capabilities.schema.json @@ -12,7 +12,8 @@ "supported_motion_families", "force_range_n", "table_rpm_range", - "spindle_rpm_range" + "spindle_rpm_range", + "supported_spindle_directions" ], "properties": { "schema_version": { @@ -38,7 +39,9 @@ }, "supported_motion_families": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "minItems": 1 }, "force_range_n": { @@ -60,22 +63,36 @@ "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": { + "type": "boolean" + }, + "max_harmonics": { + "type": "integer" + }, + "max_amplitude_n": { + "type": "number" + }, + "notes": { + "type": "string" + } } }, "supported_dither_profiles": { "type": "array", - "items": { "type": "string" }, + "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" } + "min_s": { + "type": "number" + }, + "max_s": { + "type": "number" + } } }, "pause_resume_support": { @@ -85,12 +102,23 @@ "type": "array", "items": { "type": "object", - "required": ["name", "unit"], + "required": [ + "name", + "unit" + ], "properties": { - "name": { "type": "string" }, - "unit": { "type": "string" }, - "sample_rate_hz": { "type": "number" }, - "notes": { "type": "string" } + "name": { + "type": "string" + }, + "unit": { + "type": "string" + }, + "sample_rate_hz": { + "type": "number" + }, + "notes": { + "type": "string" + } } } }, @@ -98,21 +126,46 @@ "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" } + "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" }, + "items": { + "type": "string" + }, "description": "Free-text list of known limitations, quirks, or warnings." }, "unknowns": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "Capabilities that have NOT been verified. Explicit unknowns prevent fake certainty." + }, + "supported_spindle_directions": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "cw", + "ccw" + ] + }, + "minItems": 1, + "uniqueItems": true, + "description": "Toolhead spindle rotation directions supported by the controller UI/protocol." } }, "$defs": { @@ -120,7 +173,9 @@ "oneOf": [ { "type": "array", - "items": { "type": "number" }, + "items": { + "type": "number" + }, "minItems": 2, "maxItems": 2, "description": "[min, max] range." diff --git a/shared/schemas/run-log.schema.json b/shared/schemas/run-log.schema.json index 4bb48a8..9a3ebc4 100644 --- a/shared/schemas/run-log.schema.json +++ b/shared/schemas/run-log.schema.json @@ -49,7 +49,12 @@ }, "result_state": { "type": "string", - "enum": ["completed", "completed_with_pause", "aborted", "faulted"] + "enum": [ + "completed", + "completed_with_pause", + "aborted", + "faulted" + ] }, "segments": { "type": "array", @@ -61,31 +66,72 @@ "commanded_summary": { "type": "object", "properties": { - "force_n": { "type": "number" }, - "table_rpm": { "type": "number" }, - "spindle_rpm": { "type": "number" } + "force_n": { + "type": "number" + }, + "table_rpm": { + "type": "number" + }, + "spindle_rpm": { + "type": "number" + }, + "spindle_direction": { + "type": "string", + "enum": [ + "cw", + "ccw" + ] + } } }, "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" } + "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"], + "required": [ + "timestamp", + "code", + "message" + ], "properties": { - "timestamp": { "type": "string", "format": "date-time" }, - "code": { "type": "string" }, - "message": { "type": "string" }, - "severity": { "type": "string", "enum": ["info", "warning", "critical"] } + "timestamp": { + "type": "string", + "format": "date-time" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string", + "enum": [ + "info", + "warning", + "critical" + ] + } } } }, @@ -93,11 +139,21 @@ "type": "array", "items": { "type": "object", - "required": ["timestamp", "type"], + "required": [ + "timestamp", + "type" + ], "properties": { - "timestamp": { "type": "string", "format": "date-time" }, - "type": { "type": "string" }, - "detail": { "type": "string" } + "timestamp": { + "type": "string", + "format": "date-time" + }, + "type": { + "type": "string" + }, + "detail": { + "type": "string" + } } } }, @@ -120,51 +176,107 @@ "result_state" ], "properties": { - "segment_id": { "type": "string" }, - "source_pass_id": { "type": "string" }, - "commanded_duration_s": { "type": "number" }, - "actual_duration_s": { "type": "number" }, + "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"] + "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" } + "force_n": { + "type": "number" + }, + "table_rpm": { + "type": "number" + }, + "spindle_rpm": { + "type": "number" + }, + "cam_amplitude_deg": { + "type": "number" + }, + "cam_offset_deg": { + "type": "number" + }, + "spindle_direction": { + "type": "string", + "enum": [ + "cw", + "ccw" + ] + } } }, "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" } + "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"], + "required": [ + "paused_at", + "resumed_at" + ], "properties": { - "paused_at": { "type": "string", "format": "date-time" }, - "resumed_at": { "type": "string", "format": "date-time" }, - "reason": { "type": "string" } + "paused_at": { + "type": "string", + "format": "date-time" + }, + "resumed_at": { + "type": "string", + "format": "date-time" + }, + "reason": { + "type": "string" + } } } }, "anomaly_flags": { "type": "array", - "items": { "type": "string" } + "items": { + "type": "string" + } }, - "notes": { "type": "string" } + "notes": { + "type": "string" + } } } } diff --git a/tests/test_spindle_direction_contract.py b/tests/test_spindle_direction_contract.py new file mode 100644 index 0000000..cb44008 --- /dev/null +++ b/tests/test_spindle_direction_contract.py @@ -0,0 +1,33 @@ +import json +from pathlib import Path + +from polisher_control.contracts import SpindleDirection, normalize_spindle_direction +from polisher_control.telemetry_channels import RECOMMENDED_CHANNELS + +ROOT = Path(__file__).resolve().parents[1] + + +def test_spindle_direction_enum_uses_stable_wire_values(): + assert SpindleDirection.CLOCKWISE.value == "cw" + assert SpindleDirection.COUNTER_CLOCKWISE.value == "ccw" + assert SpindleDirection.allowed_values() == ["cw", "ccw"] + + +def test_normalize_spindle_direction_accepts_operator_aliases(): + assert normalize_spindle_direction("cw") == SpindleDirection.CLOCKWISE + assert normalize_spindle_direction("clockwise") == SpindleDirection.CLOCKWISE + assert normalize_spindle_direction("ccw") == SpindleDirection.COUNTER_CLOCKWISE + assert normalize_spindle_direction("counter-clockwise") == SpindleDirection.COUNTER_CLOCKWISE + + +def test_spindle_direction_is_in_controller_job_segment_schema(): + schema = json.loads((ROOT / "shared/schemas/controller-job.schema.json").read_text()) + segment = schema["$defs"]["segment"] + + assert "commanded_spindle_direction" in segment["required"] + assert segment["properties"]["commanded_spindle_direction"]["enum"] == ["cw", "ccw"] + + +def test_spindle_direction_is_logged_as_telemetry_context(): + assert "spindle_direction_setpoint" in RECOMMENDED_CHANNELS + assert "spindle_direction_actual" in RECOMMENDED_CHANNELS