From b7123d20b7ddc2b0fa1e70063307299a60407da7 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Thu, 24 Sep 2026 00:18:46 +0000 Subject: [PATCH 1/3] feat(control): stage interlock authorization around a PID-family controller Introduce jj_control, a product-local layer over the pinned dragon-core dc_pid, and split jj_interlock into two stages under a single safety owner: - Stage 1 (jj_interlock_evaluate) runs the one set of eligibility predicates and returns a dominant block reason for diagnostics plus a controller disposition aggregated over every applicable inhibit (SUSPEND > HOLD > RUN). Check order and fault latching are unchanged; diagnostic priority no longer decides controller lifecycle. - jj_controller_step consumes that result: SUSPEND idles and resets, HOLD steps with integration frozen, RUN steps with integration. It holds no fan/fault/printer/authority/sensor predicates. - Stage 2 (jj_interlock_authorize) consumes the interlock-retained Stage-1 evaluation exactly once, matched by sequence token, and authorizes only RUN with a VALID controller and a finite 0-100 % request. Stale, replayed, or unknown tokens, and revocation or fault clear between the stages, fail cold. Controller lifecycle is IDLE/VALID/INVALID. requested_duty_pct is the unmodified dc_pid output (0 unless VALID); heater_authorized and allowed_duty_pct replace heater_requested. No product code compares temperature to target. Only fan-proof-pending HOLDs; NOT_COMMISSIONED, MANUAL_TARGET_INVALID, and every other inhibit SUSPEND via an exhaustive switch. AUTOMATIC stays idle with no setpoint. Both stages and the controller run inside the existing authority critical section. Gains are zero and the sample period mirrors the current task cadence; both are provisional pending Phase 3. No heater actuation is added. Host tests cover lifecycle transitions, overlap aggregation, provenance, and a dc_pid shadow trajectory through target crossing; a static contract check guards the controller/safety boundary. Host tests fetch dc_pid at the commit pinned in main/idf_component.yml. --- .github/workflows/firmware-build.yml | 6 + components/jj_authority/CMakeLists.txt | 2 +- .../jj_authority/include/jj_authority.h | 6 + components/jj_authority/jj_authority.c | 12 +- components/jj_control/CMakeLists.txt | 6 + components/jj_control/include/jj_control.h | 50 +++ components/jj_control/jj_control.c | 108 +++++ .../jj_interlock/include/jj_interlock.h | 74 +++- components/jj_interlock/jj_interlock.c | 304 ++++++++++--- components/jj_portal/jj_portal.c | 10 +- main/CMakeLists.txt | 2 +- main/app_main.c | 10 +- main/idf_component.yml | 4 + tests/check_control_safety_contract.sh | 4 +- tests/check_controller_contract.sh | 52 +++ tests/check_ota_contract.sh | 2 +- tests/fetch_dc_pid.sh | 33 ++ tests/jj_authority_host_test.c | 89 ++-- tests/jj_control_host_test.c | 414 ++++++++++++++++++ tests/jj_interlock_host_test.c | 262 ++++++++++- tests/run_authority_host_test.sh | 5 + tests/run_control_host_test.sh | 16 + 22 files changed, 1367 insertions(+), 104 deletions(-) create mode 100644 components/jj_control/CMakeLists.txt create mode 100644 components/jj_control/include/jj_control.h create mode 100644 components/jj_control/jj_control.c create mode 100755 tests/check_controller_contract.sh create mode 100755 tests/fetch_dc_pid.sh create mode 100644 tests/jj_control_host_test.c create mode 100755 tests/run_control_host_test.sh diff --git a/.github/workflows/firmware-build.yml b/.github/workflows/firmware-build.yml index ade2e5a..158b9ca 100644 --- a/.github/workflows/firmware-build.yml +++ b/.github/workflows/firmware-build.yml @@ -32,6 +32,9 @@ jobs: - name: Test control authority state machine run: ./tests/run_authority_host_test.sh + - name: Test PID controller lifecycle and staged authorization + run: ./tests/run_control_host_test.sh + - name: Test browser control client run: node --test tests/control-client.test.cjs @@ -47,6 +50,9 @@ jobs: - name: Check control ordering, locking, and fault policy run: sh tests/check_control_safety_contract.sh + - name: Check controller/safety boundary + run: sh tests/check_controller_contract.sh + - name: Enforce production actuation allowlist run: sh tests/check_actuation_allowlist.sh diff --git a/components/jj_authority/CMakeLists.txt b/components/jj_authority/CMakeLists.txt index d2cd51e..be4d723 100644 --- a/components/jj_authority/CMakeLists.txt +++ b/components/jj_authority/CMakeLists.txt @@ -1,6 +1,6 @@ idf_component_register( SRCS "jj_authority.c" INCLUDE_DIRS "include" - REQUIRES jj_interlock + REQUIRES jj_interlock jj_control ) target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/components/jj_authority/include/jj_authority.h b/components/jj_authority/include/jj_authority.h index 36239d8..6ecf46f 100644 --- a/components/jj_authority/include/jj_authority.h +++ b/components/jj_authority/include/jj_authority.h @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #pragma once +#include "jj_control.h" #include "jj_interlock.h" #include #include @@ -119,8 +120,13 @@ void jj_authority_snapshot( void jj_authority_apply_to_inputs( const jj_control_snapshot_t *authority, jj_inputs_t *inputs); +/* + * One ordered control step under the authority lock: authority -> interlock + * Stage 1 -> controller -> interlock Stage 2. A NULL controller fails cold. + */ jj_outputs_t jj_authority_control_step( jj_authority_t *state, + jj_controller_t *controller, const jj_inputs_t *authoritative_inputs, uint64_t now_ms, jj_control_snapshot_t *out); diff --git a/components/jj_authority/jj_authority.c b/components/jj_authority/jj_authority.c index 4aa4aed..d80ea79 100644 --- a/components/jj_authority/jj_authority.c +++ b/components/jj_authority/jj_authority.c @@ -443,6 +443,7 @@ void jj_authority_apply_to_inputs( jj_outputs_t jj_authority_control_step( jj_authority_t *state, + jj_controller_t *controller, const jj_inputs_t *authoritative_inputs, uint64_t now_ms, jj_control_snapshot_t *out) @@ -454,7 +455,16 @@ jj_outputs_t jj_authority_control_step( AUTHORITY_LOCK(); (void)lease_expire_unlocked(state, now_ms); jj_authority_apply_to_inputs(&state->state, &input); - const jj_outputs_t output = jj_interlock_step(state->interlock, &input); + /* + * Both interlock stages and the controller step run under this one lock, + * so no authority mutation or revocation can interleave between them. + */ + const jj_eligibility_t eligibility = + jj_interlock_evaluate(state->interlock, &input); + const jj_controller_result_t controller_result = + jj_controller_step(controller, &eligibility, &input); + const jj_outputs_t output = jj_interlock_authorize( + state->interlock, controller ? &controller_result.report : NULL); snapshot_unlocked(state, now_ms, out); AUTHORITY_UNLOCK(); return output; diff --git a/components/jj_control/CMakeLists.txt b/components/jj_control/CMakeLists.txt new file mode 100644 index 0000000..e986b41 --- /dev/null +++ b/components/jj_control/CMakeLists.txt @@ -0,0 +1,6 @@ +idf_component_register( + SRCS "jj_control.c" + INCLUDE_DIRS "include" + REQUIRES jj_interlock dc_pid +) +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/components/jj_control/include/jj_control.h b/components/jj_control/include/jj_control.h new file mode 100644 index 0000000..cf79bd6 --- /dev/null +++ b/components/jj_control/include/jj_control.h @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "dc_pid.h" +#include "jj_interlock.h" +#include + +/* + * Jump Jet product control policy around dc_pid. + * + * Owns setpoint resolution, dc_pid state/config lifecycle, integrate + * decisions, and the raw requested duty. Owns no safety eligibility, + * authorization, fault handling, limiting, or actuation: it consumes the + * interlock's Stage-1 jj_eligibility_t and reports back through + * jj_controller_report_t for Stage 2. + */ + +typedef struct { + dc_pid_config_t pid; + float sample_period_s; /* dt passed to dc_pid_step() each control step */ +} jj_controller_config_t; + +typedef struct { + jj_controller_config_t config; + dc_pid_state_t pid; +} jj_controller_t; + +typedef struct { + jj_controller_report_t report; /* the only part Stage 2 consumes */ + /* Advanced diagnostics; not primary HMI. Zeroed unless a step ran. */ + dc_pid_result_t pid; + bool stepped; + bool integrating; + float setpoint_c; /* NAN when no setpoint applies */ +} jj_controller_result_t; + +/* + * PROVISIONAL, UNCHARACTERIZED. Zero gains: a step produces a valid 0 % request. + * Output/integral bounds are the 0..100 % duty unit, not tuning. The sample + * period mirrors the existing 250 ms control-task cadence and is not an + * approved controller interval. Replace only from Phase 3 plant evidence. + */ +jj_controller_config_t jj_controller_provisional_config(void); + +void jj_controller_init(jj_controller_t *controller, + const jj_controller_config_t *config); + +jj_controller_result_t jj_controller_step(jj_controller_t *controller, + const jj_eligibility_t *eligibility, + const jj_inputs_t *input); diff --git a/components/jj_control/jj_control.c b/components/jj_control/jj_control.c new file mode 100644 index 0000000..d00ab7c --- /dev/null +++ b/components/jj_control/jj_control.c @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "jj_control.h" +#include +#include + +/* + * This unit runs inside jj_authority's critical section: bounded work only. + * It must not test fan proof, faults, printer state, authority, sensor status, + * or target validity. Those predicates live only in jj_interlock. + */ + +jj_controller_config_t jj_controller_provisional_config(void) +{ + return (jj_controller_config_t){ + .pid = { + .kp = 0.0f, .ki = 0.0f, .kd = 0.0f, /* PROVISIONAL: untuned */ + .derivative_alpha = 0.0f, + .output_min = 0.0f, .output_max = 100.0f, + .integral_min = 0.0f, .integral_max = 100.0f, + }, + .sample_period_s = 0.25f, /* PROVISIONAL: inherited task cadence */ + }; +} + +void jj_controller_init(jj_controller_t *controller, + const jj_controller_config_t *config) +{ + if (!controller) return; + memset(controller, 0, sizeof(*controller)); + if (config) controller->config = *config; + dc_pid_reset(&controller->pid); +} + +/* Setpoint resolution is product policy owned here, from mode alone. */ +static bool resolve_setpoint(const jj_inputs_t *input, float *setpoint_c) +{ + switch (input->mode) { + case JJ_MODE_MANUAL: + *setpoint_c = input->manual_target_c; + return true; + case JJ_MODE_OFF: + case JJ_MODE_AUTOMATIC: /* no Phase-4 chamber-target policy exists */ + return false; + } + return false; +} + +jj_controller_result_t jj_controller_step(jj_controller_t *controller, + const jj_eligibility_t *eligibility, + const jj_inputs_t *input) +{ + jj_controller_result_t result = { + .report = {.state = JJ_CONTROLLER_IDLE, .requested_duty_pct = 0.0f}, + .setpoint_c = NAN, + }; + if (!controller || !eligibility || !input) { + result.report.state = JJ_CONTROLLER_INVALID; + return result; + } + result.report.eligibility_sequence = eligibility->sequence; + + bool known = false, integrate = false; + switch (eligibility->disposition) { /* exhaustive: no default */ + case JJ_DISPOSITION_SUSPEND: + /* IDLE for as long as suspension lasts; never resume on stale state. */ + dc_pid_reset(&controller->pid); + return result; + case JJ_DISPOSITION_HOLD: + known = true; /* preserve state, freeze integral */ + break; + case JJ_DISPOSITION_RUN: + known = true; + integrate = true; + break; + } + if (!known) { + result.report.state = JJ_CONTROLLER_INVALID; + return result; + } + + float setpoint_c; + if (!resolve_setpoint(input, &setpoint_c)) { + dc_pid_reset(&controller->pid); + return result; /* IDLE: no applicable setpoint */ + } + result.setpoint_c = setpoint_c; + + /* + * PROVISIONAL: no special handling of target changes. dc_pid's + * derivative-on-measurement avoids setpoint kick; whether the integral + * should be preserved or reinitialized across a target change is open + * pending Phase 3. dc_pid output is used unmodified: no target-crossing + * override exists here. + */ + result.stepped = true; + if (dc_pid_step(&controller->pid, &controller->config.pid, setpoint_c, + input->chamber.temperature_c, + controller->config.sample_period_s, integrate, + &result.pid)) { + result.report.state = JJ_CONTROLLER_VALID; + result.report.requested_duty_pct = result.pid.output; + result.integrating = integrate; + } else { + result.report.state = JJ_CONTROLLER_INVALID; + result.report.requested_duty_pct = 0.0f; + } + return result; +} diff --git a/components/jj_interlock/include/jj_interlock.h b/components/jj_interlock/include/jj_interlock.h index dd76397..410fa83 100644 --- a/components/jj_interlock/include/jj_interlock.h +++ b/components/jj_interlock/include/jj_interlock.h @@ -52,7 +52,35 @@ typedef enum { JJ_BLOCK_MANUAL_TARGET_INVALID, JJ_BLOCK_FAN_PROOF_PENDING, JJ_BLOCK_INVALID_MODE, JJ_BLOCK_CONTROL_NO_AUTHORITY, JJ_BLOCK_STATE_REFRESH_REQUIRED, JJ_BLOCK_AUTO_AUTHORITY_UNAVAILABLE, + /* Stage-2 denials: produced only by jj_interlock_authorize(). */ + JJ_BLOCK_CONTROLLER_INVALID, JJ_BLOCK_CONTROL_SEQUENCE_INVALID, + JJ_BLOCK_REASON_COUNT, /* sentinel; not a reason */ } jj_block_reason_t; +_Static_assert(JJ_BLOCK_REASON_COUNT <= 32, "inhibit_mask is 32 bits wide"); + +/* + * Controller lifecycle. IDLE is an ordinary state (no step was attempted); + * INVALID means a step was expected but the controller could not produce a + * valid request. Zero values are the safe defaults. + */ +typedef enum { + JJ_CONTROLLER_IDLE = 0, + JJ_CONTROLLER_VALID, + JJ_CONTROLLER_INVALID, +} jj_controller_state_t; + +/* + * What the controller may do this cycle. Numeric order is precedence: + * SUSPEND (most restrictive) < HOLD < RUN, so aggregation takes the minimum. + * SUSPEND: do not step; controller IDLE; controller state reset. + * HOLD: step with integration frozen; state preserved; heat not allowed. + * RUN: step with integration enabled; heat may be authorized in Stage 2. + */ +typedef enum { + JJ_DISPOSITION_SUSPEND = 0, + JJ_DISPOSITION_HOLD, + JJ_DISPOSITION_RUN, +} jj_controller_disposition_t; typedef struct { jj_sensor_status_t status; float temperature_c; } jj_sensor_sample_t; typedef struct { @@ -81,8 +109,25 @@ typedef struct { jj_printer_sample_t printer; jj_fan_proof_t fan_proof; } jj_inputs_t; +/* + * Stage-1 result. Produced only by jj_interlock_evaluate(); the controller + * reads it but Stage 2 never trusts a caller's copy of it. + */ +typedef struct { + uint32_t sequence; /* interlock-issued evaluation token */ + jj_block_reason_t dominant_reason; /* diagnostic/HMI priority only */ + uint32_t inhibit_mask; /* 1u << reason for each inhibit found */ + jj_controller_disposition_t disposition; /* aggregate over all inhibits */ +} jj_eligibility_t; + +/* The only controller data Stage 2 consumes. Treated as untrusted input. */ +typedef struct { + uint32_t eligibility_sequence; /* copied from the jj_eligibility_t used */ + jj_controller_state_t state; + float requested_duty_pct; /* raw controller request; 0 unless VALID */ +} jj_controller_report_t; + typedef struct { - bool heater_requested; uint8_t fan_percent; float effective_target_c; bool thermal_management_required; @@ -91,12 +136,33 @@ typedef struct { jj_thermal_state_t thermal_state; jj_fault_t fault; jj_block_reason_t block_reason; + jj_controller_disposition_t controller_disposition; + jj_controller_state_t controller_state; + float requested_duty_pct; /* raw controller request, never safety-modified */ + bool heater_authorized; /* safety authorization, independent of magnitude */ + float allowed_duty_pct; /* 0 unless heater_authorized */ } jj_outputs_t; -typedef struct { jj_fault_t fault_latched; jj_outputs_t last_output; } jj_interlock_t; +typedef struct { + jj_fault_t fault_latched; + jj_outputs_t last_output; + /* Stage-1 evaluation awaiting its single Stage-2 consumption. */ + bool pending_valid; + uint32_t next_sequence; + jj_eligibility_t pending; + jj_outputs_t pending_output; +} jj_interlock_t; jj_inputs_t jj_inputs_safe_defaults(void); void jj_interlock_init(jj_interlock_t *state); -jj_outputs_t jj_interlock_step(jj_interlock_t *state, const jj_inputs_t *input); +/* + * Staged evaluation. Both stages run inside one control step. Stage 1 publishes + * a de-authorized snapshot immediately; Stage 2 consumes that exact evaluation + * once. Revocation, fault clear, or a newer Stage 1 invalidates it. + */ +jj_eligibility_t jj_interlock_evaluate(jj_interlock_t *state, const jj_inputs_t *input); +jj_outputs_t jj_interlock_authorize(jj_interlock_t *state, + const jj_controller_report_t *report); +jj_controller_disposition_t jj_block_reason_disposition(jj_block_reason_t reason); bool jj_interlock_clear_fault(jj_interlock_t *state, const jj_inputs_t *input); void jj_interlock_remove_remote_authorization( jj_interlock_t *state, @@ -110,3 +176,5 @@ const char *jj_block_reason_str(jj_block_reason_t reason); const char *jj_control_authority_str(jj_control_authority_t authority); const char *jj_control_inhibit_str(jj_control_inhibit_t inhibit); const char *jj_thermal_state_str(jj_thermal_state_t state); +const char *jj_controller_state_str(jj_controller_state_t state); +const char *jj_controller_disposition_str(jj_controller_disposition_t disposition); diff --git a/components/jj_interlock/jj_interlock.c b/components/jj_interlock/jj_interlock.c index df83071..24a2d19 100644 --- a/components/jj_interlock/jj_interlock.c +++ b/components/jj_interlock/jj_interlock.c @@ -2,6 +2,7 @@ #include "jj_interlock.h" #include #include +#include #include #ifdef ESP_PLATFORM @@ -55,6 +56,110 @@ void jj_interlock_init(jj_interlock_t *state) STATE_UNLOCK(); } +/* + * Controller disposition for every block reason. Exhaustive with no default: + * adding a jj_block_reason_t without deciding its disposition fails the + * -Werror build (-Wswitch). Only an explicitly approved transient inhibit may + * HOLD; everything else SUSPENDs (idle + reset). + */ +jj_controller_disposition_t jj_block_reason_disposition(jj_block_reason_t reason) +{ + switch (reason) { + case JJ_BLOCK_NONE: + return JJ_DISPOSITION_RUN; + case JJ_BLOCK_FAN_PROOF_PENDING: + /* Approved Category A: transient authorization loss. */ + return JJ_DISPOSITION_HOLD; + case JJ_BLOCK_OFF: + case JJ_BLOCK_NOT_COMMISSIONED: /* approved: suspend + reset */ + case JJ_BLOCK_FAULT_LATCHED: /* suspended for the whole latch */ + case JJ_BLOCK_INVALID_MODE: + case JJ_BLOCK_MANUAL_TARGET_INVALID: /* approved: no valid objective */ + case JJ_BLOCK_PRINTER_UNAVAILABLE: /* AUTOMATIC: no Phase-4 setpoint */ + case JJ_BLOCK_PRINTER_NOT_PRINTING: + case JJ_BLOCK_AUTO_POLICY_UNAVAILABLE: + case JJ_BLOCK_AUTO_AUTHORITY_UNAVAILABLE: + case JJ_BLOCK_CONTROL_NO_AUTHORITY: + case JJ_BLOCK_STATE_REFRESH_REQUIRED: + case JJ_BLOCK_CONTROLLER_INVALID: + case JJ_BLOCK_CONTROL_SEQUENCE_INVALID: + case JJ_BLOCK_REASON_COUNT: + return JJ_DISPOSITION_SUSPEND; + } + return JJ_DISPOSITION_SUSPEND; /* out-of-range value */ +} + +typedef struct { + jj_block_reason_t dominant; + uint32_t mask; + jj_controller_disposition_t disposition; +} inhibit_accumulator_t; + +/* + * Record one applicable inhibit. The first recorded reason is the dominant + * diagnostic reason (unchanged PR #7 priority). Disposition is the minimum over + * every recorded reason, so it does not depend on check order. Returns true + * once SUSPEND is reached: nothing later can change the disposition. + */ +static bool record(inhibit_accumulator_t *acc, jj_block_reason_t reason) +{ + if (acc->dominant == JJ_BLOCK_NONE) acc->dominant = reason; + acc->mask |= UINT32_C(1) << (uint32_t)reason; + const jj_controller_disposition_t disposition = + jj_block_reason_disposition(reason); + if (disposition < acc->disposition) acc->disposition = disposition; + return acc->disposition == JJ_DISPOSITION_SUSPEND; +} + +/* + * The single implementation of heat-eligibility predicates. Check order and + * fault-latching side effects are unchanged from PR #7; only the early + * returns now continue past non-SUSPEND inhibits. + */ +static void collect_inhibits(jj_interlock_t *state, const jj_inputs_t *input, + inhibit_accumulator_t *acc) +{ + if (!input->commissioned && record(acc, JJ_BLOCK_NOT_COMMISSIONED)) return; + if (!sensors_ok(input)) state->fault_latched = JJ_FAULT_SENSOR; + if (input->overtemperature_detected) + state->fault_latched = JJ_FAULT_OVERTEMPERATURE; + if (state->fault_latched != JJ_FAULT_NONE && + record(acc, JJ_BLOCK_FAULT_LATCHED)) return; + if (input->mode == JJ_MODE_OFF && record(acc, JJ_BLOCK_OFF)) return; + if (input->mode != JJ_MODE_MANUAL && input->mode != JJ_MODE_AUTOMATIC && + record(acc, JJ_BLOCK_INVALID_MODE)) return; + if (input->fan_proof == JJ_FAN_PROOF_FAILED || + (input->fan_proof != JJ_FAN_PROOF_UNAVAILABLE && + input->fan_proof != JJ_FAN_PROOF_PENDING && + input->fan_proof != JJ_FAN_PROOF_PROVEN)) { + state->fault_latched = JJ_FAULT_FAN; + if (record(acc, JJ_BLOCK_FAULT_LATCHED)) return; + } + if (input->fan_proof != JJ_FAN_PROOF_PROVEN && + record(acc, JJ_BLOCK_FAN_PROOF_PENDING)) return; + + if (input->mode == JJ_MODE_AUTOMATIC) { + if (input->active_authority != JJ_AUTHORITY_AUTOMATIC && + record(acc, JJ_BLOCK_AUTO_AUTHORITY_UNAVAILABLE)) return; + if (!input->printer.online && + record(acc, JJ_BLOCK_PRINTER_UNAVAILABLE)) return; + if (!input->printer.printing && + record(acc, JJ_BLOCK_PRINTER_NOT_PRINTING)) return; + /* dc_prusa owns its 15 s freshness decision. No second timer lives here. + * Exact bed-target mapping is intentionally undefined, so AUTO is cold. */ + (void)record(acc, JJ_BLOCK_AUTO_POLICY_UNAVAILABLE); + return; + } + if ((input->active_authority == JJ_AUTHORITY_REACQUIRING || + input->control_inhibit == JJ_CONTROL_INHIBIT_STATE_REFRESH_REQUIRED) && + record(acc, JJ_BLOCK_STATE_REFRESH_REQUIRED)) return; + if ((input->active_authority != JJ_AUTHORITY_REMOTE || + !input->manual_demand_authorized) && + record(acc, JJ_BLOCK_CONTROL_NO_AUTHORITY)) return; + if (!manual_target_valid(input->manual_target_c)) + (void)record(acc, JJ_BLOCK_MANUAL_TARGET_INVALID); +} + static jj_outputs_t blocked(jj_interlock_t *state, jj_block_reason_t reason, const jj_inputs_t *input) { @@ -73,8 +178,7 @@ static jj_outputs_t blocked(jj_interlock_t *state, jj_block_reason_t reason, const bool thermal_management = input->cooldown_required || input->fault_requires_thermal_management || state->fault_latched == JJ_FAULT_FAN || reason == JJ_BLOCK_FAN_PROOF_PENDING; - jj_outputs_t output = { - .heater_requested = false, + return (jj_outputs_t){ .fan_percent = thermal_management ? 100 : 0, .effective_target_c = 0.0f, .thermal_management_required = thermal_management, @@ -84,75 +188,135 @@ static jj_outputs_t blocked(jj_interlock_t *state, jj_block_reason_t reason, .fault = state->fault_latched, .block_reason = reason, }; - state->last_output = output; +} + +/* Strip any authorization from an output; optionally name the denial. */ +static jj_outputs_t deauthorized(jj_outputs_t output, jj_block_reason_t reason) +{ + output.heater_authorized = false; + output.allowed_duty_pct = 0.0f; + output.effective_target_c = 0.0f; + output.thermal_state = output.thermal_management_required + ? JJ_THERMAL_COOLDOWN : JJ_THERMAL_IDLE; + if (output.block_reason == JJ_BLOCK_NONE) output.block_reason = reason; return output; } -static jj_outputs_t step_unlocked(jj_interlock_t *state, const jj_inputs_t *input) +static jj_eligibility_t evaluate_unlocked(jj_interlock_t *state, + const jj_inputs_t *input) { - if (!input->commissioned) - return blocked(state, JJ_BLOCK_NOT_COMMISSIONED, input); - if (!sensors_ok(input)) state->fault_latched = JJ_FAULT_SENSOR; - if (input->overtemperature_detected) - state->fault_latched = JJ_FAULT_OVERTEMPERATURE; - if (state->fault_latched != JJ_FAULT_NONE) - return blocked(state, JJ_BLOCK_FAULT_LATCHED, input); - if (input->mode == JJ_MODE_OFF) - return blocked(state, JJ_BLOCK_OFF, input); - if (input->mode != JJ_MODE_MANUAL && input->mode != JJ_MODE_AUTOMATIC) - return blocked(state, JJ_BLOCK_INVALID_MODE, input); - if (input->fan_proof == JJ_FAN_PROOF_FAILED || - (input->fan_proof != JJ_FAN_PROOF_UNAVAILABLE && - input->fan_proof != JJ_FAN_PROOF_PENDING && - input->fan_proof != JJ_FAN_PROOF_PROVEN)) { - state->fault_latched = JJ_FAULT_FAN; - return blocked(state, JJ_BLOCK_FAULT_LATCHED, input); - } - if (input->fan_proof != JJ_FAN_PROOF_PROVEN) - return blocked(state, JJ_BLOCK_FAN_PROOF_PENDING, input); + inhibit_accumulator_t acc = { + .dominant = JJ_BLOCK_NONE, + .mask = 0, + .disposition = JJ_DISPOSITION_RUN, + }; + collect_inhibits(state, input, &acc); - const float target = input->manual_target_c; - if (input->mode == JJ_MODE_AUTOMATIC) { - if (input->active_authority != JJ_AUTHORITY_AUTOMATIC) - return blocked(state, JJ_BLOCK_AUTO_AUTHORITY_UNAVAILABLE, input); - if (!input->printer.online) - return blocked(state, JJ_BLOCK_PRINTER_UNAVAILABLE, input); - if (!input->printer.printing) - return blocked(state, JJ_BLOCK_PRINTER_NOT_PRINTING, input); - /* dc_prusa owns its 15 s freshness decision. No second timer lives here. - * Exact bed-target mapping is intentionally undefined, so AUTO is cold. */ - return blocked(state, JJ_BLOCK_AUTO_POLICY_UNAVAILABLE, input); + jj_outputs_t output; + if (acc.dominant == JJ_BLOCK_NONE) { + /* Eligible form; Stage 2 publishes it only if it authorizes. */ + output = (jj_outputs_t){ + .fan_percent = 100, + .effective_target_c = input->manual_target_c, + .thermal_management_required = true, + .active_authority = JJ_AUTHORITY_REMOTE, + .control_inhibit = JJ_CONTROL_INHIBIT_NONE, + .thermal_state = JJ_THERMAL_HEATING, + .fault = JJ_FAULT_NONE, + .block_reason = JJ_BLOCK_NONE, + }; + } else { + output = blocked(state, acc.dominant, input); } - if (input->active_authority == JJ_AUTHORITY_REACQUIRING || - input->control_inhibit == JJ_CONTROL_INHIBIT_STATE_REFRESH_REQUIRED) - return blocked(state, JJ_BLOCK_STATE_REFRESH_REQUIRED, input); - if (input->active_authority != JJ_AUTHORITY_REMOTE || - !input->manual_demand_authorized) - return blocked(state, JJ_BLOCK_CONTROL_NO_AUTHORITY, input); - if (!manual_target_valid(target)) - return blocked(state, JJ_BLOCK_MANUAL_TARGET_INVALID, input); - jj_outputs_t output = { - .heater_requested = true, - .fan_percent = 100, - .effective_target_c = target, - .thermal_management_required = true, - .active_authority = JJ_AUTHORITY_REMOTE, - .control_inhibit = JJ_CONTROL_INHIBIT_NONE, - .thermal_state = JJ_THERMAL_HEATING, - .fault = JJ_FAULT_NONE, - .block_reason = JJ_BLOCK_NONE, + output.controller_disposition = acc.disposition; + + const jj_eligibility_t eligibility = { + .sequence = ++state->next_sequence, + .dominant_reason = acc.dominant, + .inhibit_mask = acc.mask, + .disposition = acc.disposition, }; + state->pending = eligibility; + state->pending_output = output; + state->pending_valid = true; + /* Never leave an earlier authorization visible while Stage 2 is pending. */ + state->last_output = deauthorized(output, JJ_BLOCK_NONE); + return eligibility; +} + +jj_eligibility_t jj_interlock_evaluate(jj_interlock_t *state, const jj_inputs_t *input) +{ + if (!state || !input) + return (jj_eligibility_t){ + .dominant_reason = JJ_BLOCK_FAULT_LATCHED, + .disposition = JJ_DISPOSITION_SUSPEND, + }; + STATE_LOCK(); + const jj_eligibility_t eligibility = evaluate_unlocked(state, input); + STATE_UNLOCK(); + return eligibility; +} + +static bool report_state_known(jj_controller_state_t value) +{ + switch (value) { + case JJ_CONTROLLER_IDLE: + case JJ_CONTROLLER_VALID: + case JJ_CONTROLLER_INVALID: + return true; + } + return false; +} + +static jj_outputs_t authorize_unlocked(jj_interlock_t *state, + const jj_controller_report_t *report) +{ + if (!state->pending_valid || !report || + report->eligibility_sequence != state->pending.sequence) { + /* No matching Stage 1 for this report: fail cold, consume nothing. */ + state->pending_valid = false; + state->last_output = deauthorized(state->last_output, + JJ_BLOCK_CONTROL_SEQUENCE_INVALID); + return state->last_output; + } + /* Use the interlock-retained evaluation, exactly once. */ + const jj_eligibility_t eligibility = state->pending; + jj_outputs_t output = state->pending_output; + state->pending_valid = false; + + jj_controller_state_t controller = report_state_known(report->state) + ? report->state : JJ_CONTROLLER_INVALID; + const float request = report->requested_duty_pct; + if (controller == JJ_CONTROLLER_VALID && + !(isfinite(request) && request >= 0.0f && request <= 100.0f)) + controller = JJ_CONTROLLER_INVALID; + if (eligibility.disposition == JJ_DISPOSITION_SUSPEND && + controller != JJ_CONTROLLER_IDLE) + controller = JJ_CONTROLLER_INVALID; /* stepped while suspended */ + output.controller_state = controller; + /* Raw request is reported unmodified; safety only gates allowed duty. */ + output.requested_duty_pct = controller == JJ_CONTROLLER_VALID ? request : 0.0f; + + if (eligibility.disposition == JJ_DISPOSITION_RUN && + controller == JJ_CONTROLLER_VALID) { + output.heater_authorized = true; + /* No non-safety product limiter exists yet (ownership unresolved). */ + output.allowed_duty_pct = output.requested_duty_pct; + } else { + output = deauthorized(output, JJ_BLOCK_CONTROLLER_INVALID); + } state->last_output = output; return output; } -jj_outputs_t jj_interlock_step(jj_interlock_t *state, const jj_inputs_t *input) +jj_outputs_t jj_interlock_authorize(jj_interlock_t *state, + const jj_controller_report_t *report) { - if (!state || !input) + if (!state) return (jj_outputs_t){.fault = JJ_FAULT_SENSOR, .block_reason = JJ_BLOCK_FAULT_LATCHED}; STATE_LOCK(); - jj_outputs_t output = step_unlocked(state, input); + const jj_outputs_t output = authorize_unlocked(state, report); STATE_UNLOCK(); return output; } @@ -194,6 +358,7 @@ static bool clear_fault_unlocked(jj_interlock_t *state, const jj_inputs_t *input return false; } state->fault_latched = JJ_FAULT_NONE; + state->pending_valid = false; state->last_output = (jj_outputs_t){.block_reason = JJ_BLOCK_OFF}; return true; } @@ -215,7 +380,10 @@ void jj_interlock_remove_remote_authorization( { if (!state) return; STATE_LOCK(); - state->last_output.heater_requested = false; + /* Revocation also voids any Stage-1 result not yet authorized. */ + state->pending_valid = false; + state->last_output.heater_authorized = false; + state->last_output.allowed_duty_pct = 0.0f; state->last_output.effective_target_c = 0.0f; state->last_output.active_authority = authority; state->last_output.control_inhibit = inhibit; @@ -295,6 +463,8 @@ const char *jj_block_reason_str(jj_block_reason_t reason) case JJ_BLOCK_CONTROL_NO_AUTHORITY: return "control_no_authority"; case JJ_BLOCK_STATE_REFRESH_REQUIRED: return "state_refresh_required"; case JJ_BLOCK_AUTO_AUTHORITY_UNAVAILABLE: return "automatic_authority_unavailable"; + case JJ_BLOCK_CONTROLLER_INVALID: return "controller_invalid"; + case JJ_BLOCK_CONTROL_SEQUENCE_INVALID: return "control_sequence_invalid"; default: return "unknown"; } } @@ -330,3 +500,23 @@ const char *jj_thermal_state_str(jj_thermal_state_t state) default: return "unknown"; } } + +const char *jj_controller_state_str(jj_controller_state_t state) +{ + switch (state) { + case JJ_CONTROLLER_IDLE: return "idle"; + case JJ_CONTROLLER_VALID: return "valid"; + case JJ_CONTROLLER_INVALID: return "invalid"; + default: return "unknown"; + } +} + +const char *jj_controller_disposition_str(jj_controller_disposition_t disposition) +{ + switch (disposition) { + case JJ_DISPOSITION_SUSPEND: return "suspend"; + case JJ_DISPOSITION_HOLD: return "hold"; + case JJ_DISPOSITION_RUN: return "run"; + default: return "unknown"; + } +} diff --git a/components/jj_portal/jj_portal.c b/components/jj_portal/jj_portal.c index 6720077..ee189eb 100644 --- a/components/jj_portal/jj_portal.c +++ b/components/jj_portal/jj_portal.c @@ -239,7 +239,13 @@ static cJSON *control_state_json( const bool raw_request = authority->mode == JJ_MODE_MANUAL && authority->manual_demand_authorized; cJSON_AddBoolToObject(heater, "requested", raw_request); - cJSON_AddBoolToObject(heater, "allowed", output->heater_requested); + cJSON_AddStringToObject(heater, "control_loop", + jj_controller_state_str(output->controller_state)); + cJSON_AddNumberToObject(heater, "requested_duty_percent", + output->requested_duty_pct); + cJSON_AddBoolToObject(heater, "allowed", output->heater_authorized); + cJSON_AddNumberToObject(heater, "allowed_duty_percent", + output->allowed_duty_pct); cJSON_AddBoolToObject(heater, "delivered", false); cJSON_AddNumberToObject(heater, "delivered_percent", 0); cJSON_AddBoolToObject(heater, "available", false); @@ -586,7 +592,7 @@ static bool authorize(httpd_req_t *req, void *ctx) static esp_err_t thermal_state_guard(char *message, size_t message_size) { const jj_outputs_t output = jj_interlock_snapshot(s_interlock); - if (!output.heater_requested && !output.thermal_management_required) + if (!output.heater_authorized && !output.thermal_management_required) return ESP_OK; snprintf(message, message_size, "OTA is unavailable while heating or active thermal management is required."); diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index d1217bf..845749d 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,6 +1,6 @@ idf_component_register( SRCS "app_main.c" INCLUDE_DIRS "." - REQUIRES jj_authority jj_identity jj_interlock jj_portal dc_evlog dc_prusa dc_wifi nvs_flash app_update + REQUIRES jj_authority jj_control jj_identity jj_interlock jj_portal dc_evlog dc_prusa dc_wifi nvs_flash app_update ) target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/main/app_main.c b/main/app_main.c index 2937dfa..21e01fe 100644 --- a/main/app_main.c +++ b/main/app_main.c @@ -6,6 +6,7 @@ #include "dc_prusa.h" #include "dc_wifi.h" #include "jj_authority.h" +#include "jj_control.h" #include "jj_identity.h" #include "jj_interlock.h" #include "jj_portal.h" @@ -21,6 +22,7 @@ static const char *TAG = JJ_IDENTITY_PRODUCT_ID; static jj_interlock_t s_interlock; static jj_authority_t s_authority; +static jj_controller_t s_controller; static bool printer_is_printing(const char *state) { @@ -48,7 +50,7 @@ static void control_task(void *arg) */ const uint64_t now_ms = (uint64_t)esp_timer_get_time() / 1000U; const jj_outputs_t output = jj_authority_control_step( - &s_authority, &input, now_ms, NULL); + &s_authority, &s_controller, &input, now_ms, NULL); if (!startup_reported) { startup_reported = true; xTaskNotifyGive(startup_task); @@ -75,6 +77,9 @@ void app_main(void) ESP_ERROR_CHECK(err); jj_interlock_init(&s_interlock); jj_authority_init(&s_authority, &s_interlock, JJ_REMOTE_LEASE_TTL_MS); + const jj_controller_config_t controller_config = + jj_controller_provisional_config(); + jj_controller_init(&s_controller, &controller_config); BaseType_t created = xTaskCreate(control_task, "jj_control", 4096, xTaskGetCurrentTaskHandle(), 8, NULL); ESP_ERROR_CHECK(created == pdPASS ? ESP_OK : ESP_ERR_NO_MEM); @@ -91,7 +96,8 @@ void app_main(void) ESP_ERROR_CHECK(ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(2000)) > 0 ? ESP_OK : ESP_ERR_TIMEOUT); const jj_outputs_t startup_output = jj_interlock_snapshot(&s_interlock); - ESP_ERROR_CHECK(!startup_output.heater_requested && + ESP_ERROR_CHECK(!startup_output.heater_authorized && + startup_output.allowed_duty_pct == 0.0f && startup_output.effective_target_c == 0.0f ? ESP_OK : ESP_ERR_INVALID_STATE); const esp_partition_t *running = esp_ota_get_running_partition(); diff --git a/main/idf_component.yml b/main/idf_component.yml index 125352e..ab53c5e 100644 --- a/main/idf_component.yml +++ b/main/idf_component.yml @@ -3,6 +3,10 @@ dependencies: git: https://github.com/justinh-rahb/dragon-core.git path: components/dc_evlog version: 4e041d864763d468a50e9649807827dd83dd54bc + dc_pid: + git: https://github.com/justinh-rahb/dragon-core.git + path: components/dc_pid + version: 4e041d864763d468a50e9649807827dd83dd54bc dc_prusa: git: https://github.com/justinh-rahb/dragon-core.git path: components/dc_prusa diff --git a/tests/check_control_safety_contract.sh b/tests/check_control_safety_contract.sh index 55b12d4..8b57a5b 100755 --- a/tests/check_control_safety_contract.sh +++ b/tests/check_control_safety_contract.sh @@ -15,13 +15,13 @@ if grep -q 'jj_authority_snapshot' "$app"; then echo "control task must not snapshot authority before cached inputs" >&2 exit 1 fi -if grep -q 'jj_interlock_step' "$app" "$portal"; then +if grep -Eq 'jj_interlock_(evaluate|authorize)|jj_controller_step' "$app" "$portal"; then echo "ordinary production interlock evaluation must remain control-task owned" >&2 exit 1 fi if rg -n '(ESP_LOG[A-Z]*|\b(printf|snprintf|vfprintf|malloc|calloc|realloc|free|xSemaphoreTake|vTaskDelay)[[:space:]]*\(|esp_http)' \ - "$authority" >/dev/null; then + "$authority" "$root/components/jj_control/jj_control.c" >/dev/null; then echo "authority critical-section unit contains forbidden unbounded work" >&2 exit 1 fi diff --git a/tests/check_controller_contract.sh b/tests/check_controller_contract.sh new file mode 100755 index 0000000..44a5d1b --- /dev/null +++ b/tests/check_controller_contract.sh @@ -0,0 +1,52 @@ +#!/bin/sh +# Static guards for the PID-family controller/safety boundary. +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +control="$root/components/jj_control/jj_control.c" +control_h="$root/components/jj_control/include/jj_control.h" +interlock="$root/components/jj_interlock/jj_interlock.c" +manifest="$root/main/idf_component.yml" +command -v rg >/dev/null 2>&1 || { echo "controller contract check requires rg" >&2; exit 1; } + +fail() { echo "$1" >&2; exit 1; } + +# 1. jj_control holds no safety/eligibility predicates; jj_interlock owns them. +# Comments are stripped first so prose cannot mask or trip the check. +strip() { cc -fpreprocessed -dD -E -P "$1" 2>/dev/null; } +for file in "$control" "$control_h"; do + if strip "$file" | rg -n '\b(fan_proof|fault\w*|printer|commissioned|overtemperature\w*|active_authority|manual_demand_authorized|control_inhibit|cooldown\w*|status|JJ_SENSOR_\w+|JJ_FAULT_\w+|JJ_MANUAL_TARGET_(MIN|MAX)_C|JJ_BLOCK_\w+|JJ_AUTHORITY_\w+)\b'; then + fail "jj_control must not implement safety/eligibility predicates ($file)" + fi +done + +# 2. No product-level target-crossing override anywhere in firmware sources. +if rg -n --pcre2 '(temperature_c|measurement)[^;{}]*[<>]=?[^;{}]*(setpoint|target)|(setpoint|target)[^;{}]*[<>]=?[^;{}]*(temperature_c|measurement)' \ + "$root/components" "$root/main" -g '*.c' -g '*.h'; then + fail "temperature-vs-target comparisons belong only inside dc_pid" +fi + +# 3. The raw dc_pid output is the request, unmodified. +grep -q 'result.report.requested_duty_pct = result.pid.output;' "$control" || + fail "requested duty must be the unmodified dc_pid output" +[ "$(grep -c 'dc_pid_step(' "$control")" -eq 1 ] || + fail "jj_control must have exactly one dc_pid_step call site" + +# 4. The retired boolean must not return. +if rg -n 'heater_requested' "$root/components" "$root/main" "$root/tests" -g '!check_controller_contract.sh'; then + fail "heater_requested was replaced by heater_authorized/allowed_duty_pct" +fi + +# 5. Stage 2 authorizes only RUN + VALID, from interlock-retained state. +grep -q 'eligibility.disposition == JJ_DISPOSITION_RUN &&' "$interlock" || + fail "Stage 2 must require RUN disposition" +grep -q 'const jj_eligibility_t eligibility = state->pending;' "$interlock" || + fail "Stage 2 must use the interlock-retained Stage-1 result" + +# 6. dc_pid is pinned to the same dragon-core commit as every other dc_* dep. +pins=$(awk '/^ version:/ { print $2 }' "$manifest" | sort -u) +[ "$(printf '%s\n' "$pins" | wc -l)" -eq 1 ] || + fail "all dragon-core components must share one pinned commit" +grep -q '^ dc_pid:' "$manifest" || fail "dc_pid dependency missing" + +echo "controller/safety boundary contract check: PASS" diff --git a/tests/check_ota_contract.sh b/tests/check_ota_contract.sh index d350cd4..2cb7009 100644 --- a/tests/check_ota_contract.sh +++ b/tests/check_ota_contract.sh @@ -9,7 +9,7 @@ interlock="$root/components/jj_interlock/jj_interlock.c" grep -q 'guard_operation = guard_operation' "$portal" grep -q 'validate_image = validate_image' "$portal" grep -q 'esp_err_t err = thermal_state_guard(message, message_size);' "$portal" -grep -q '!output.heater_requested && !output.thermal_management_required' "$portal" +grep -q '!output.heater_authorized && !output.thermal_management_required' "$portal" grep -q 'strcmp(image->project_name, JJ_IDENTITY_PRODUCT_ID)' "$portal" grep -q '"commissioned", false' "$portal" grep -q '"heater_available", false' "$portal" diff --git a/tests/fetch_dc_pid.sh b/tests/fetch_dc_pid.sh new file mode 100755 index 0000000..128ea28 --- /dev/null +++ b/tests/fetch_dc_pid.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Print a directory holding dc_pid sources at the exact commit pinned in +# main/idf_component.yml, so host tests compile the dependency the firmware +# builds against. No second pin exists. +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +manifest="$root/main/idf_component.yml" +field() { + awk -v key="$1" ' + /^ dc_pid:[[:space:]]*$/ { inside=1; next } + inside && /^ [^ ]/ { exit } + inside && $1 == key":" { print $2; exit } + ' "$manifest" +} +url=$(field git); path=$(field path); commit=$(field version) +[ -n "$url" ] && [ -n "$path" ] && [ -n "$commit" ] || { + echo "dc_pid dependency missing from $manifest" >&2; exit 1; } +case "$commit" in + *[!0-9a-f]*|"") echo "dc_pid pin must be a full commit SHA" >&2; exit 1 ;; +esac +[ "${#commit}" -eq 40 ] || { echo "dc_pid pin must be a full commit SHA" >&2; exit 1; } +dest="$root/build/host-deps/dc_pid-$commit" +if [ ! -f "$dest/dc_pid.c" ] || [ ! -f "$dest/include/dc_pid.h" ]; then + tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT + git -C "$tmp" init -q + git -C "$tmp" fetch -q --depth 1 "$url" "$commit" + [ "$(git -C "$tmp" rev-parse FETCH_HEAD)" = "$commit" ] || { + echo "fetched dc_pid commit does not match pin" >&2; exit 1; } + git -C "$tmp" checkout -q FETCH_HEAD -- "$path" + mkdir -p "$dest" + cp -R "$tmp/$path/." "$dest/" +fi +printf '%s\n' "$dest" diff --git a/tests/jj_authority_host_test.c b/tests/jj_authority_host_test.c index 8a65fdc..6aa4cdf 100644 --- a/tests/jj_authority_host_test.c +++ b/tests/jj_authority_host_test.c @@ -76,13 +76,25 @@ static jj_control_snapshot_t refresh( return snapshot; } +static jj_controller_t s_controller; + +/* Staged interlock cycle with the provisional (zero-gain) controller. */ +static jj_outputs_t interlock_cycle(jj_interlock_t *interlock, + const jj_inputs_t *inputs) +{ + const jj_eligibility_t eligibility = jj_interlock_evaluate(interlock, inputs); + const jj_controller_result_t result = + jj_controller_step(&s_controller, &eligibility, inputs); + return jj_interlock_authorize(interlock, &result.report); +} + static jj_outputs_t evaluate( jj_interlock_t *interlock, const jj_control_snapshot_t *authority, jj_inputs_t inputs) { jj_authority_apply_to_inputs(authority, &inputs); - return jj_interlock_step(interlock, &inputs); + return interlock_cycle(interlock, &inputs); } static void test_manual_expiry_fails_cold_without_fault_and_keeps_cooldown(void) @@ -97,11 +109,11 @@ static void test_manual_expiry_fails_cold_without_fault_and_keeps_cooldown(void) jj_control_snapshot_t manual = mutate( &authority, &interlock, &lease, JJ_MUTATION_MANUAL, 45.0f, "manual-1", &inputs, 1); - CHECK(evaluate(&interlock, &manual, inputs).heater_requested); + CHECK(evaluate(&interlock, &manual, inputs).heater_authorized); CHECK(jj_authority_tick(&authority, 1000)); jj_outputs_t output = jj_interlock_snapshot(&interlock); - CHECK(!output.heater_requested); + CHECK(!output.heater_authorized); CHECK(output.thermal_state == JJ_THERMAL_COOLDOWN); jj_control_snapshot_t expired; jj_authority_snapshot(&authority, 1000, &expired); @@ -115,7 +127,7 @@ static void test_manual_expiry_fails_cold_without_fault_and_keeps_cooldown(void) "remote_lease_expired") == 0); inputs.cooldown_required = true; output = evaluate(&interlock, &expired, inputs); - CHECK(!output.heater_requested); + CHECK(!output.heater_authorized); CHECK(output.fault == JJ_FAULT_NONE); CHECK(output.control_inhibit == JJ_CONTROL_INHIBIT_NO_AUTHORITY); CHECK(output.thermal_state == JJ_THERMAL_COOLDOWN); @@ -141,7 +153,7 @@ static void test_reacquire_requires_refresh_and_new_explicit_request(void) CHECK(reacquired.control_inhibit == JJ_CONTROL_INHIBIT_STATE_REFRESH_REQUIRED); CHECK(!reacquired.manual_demand_authorized); - CHECK(!evaluate(&interlock, &reacquired, inputs).heater_requested); + CHECK(!evaluate(&interlock, &reacquired, inputs).heater_authorized); jj_control_mutation_t premature = { .generation = reacquired.generation, @@ -165,7 +177,7 @@ static void test_reacquire_requires_refresh_and_new_explicit_request(void) "manual-b", &inputs, 1005); CHECK(resumed.active_authority == JJ_AUTHORITY_REMOTE); CHECK(resumed.manual_demand_authorized); - CHECK(evaluate(&interlock, &resumed, inputs).heater_requested); + CHECK(evaluate(&interlock, &resumed, inputs).heater_authorized); } static void test_stale_generation_and_revision_are_atomic(void) @@ -209,7 +221,7 @@ static void test_explicit_takeover_revokes_old_remote_demand(void) first = refresh(&authority, &first, 0); first = mutate(&authority, &interlock, &first, JJ_MUTATION_MANUAL, 45.0f, "manual-a", &inputs, 1); - CHECK(evaluate(&interlock, &first, inputs).heater_requested); + CHECK(evaluate(&interlock, &first, inputs).heater_authorized); jj_control_acquire_t denied = {.takeover = false}; snprintf(denied.lease_id, sizeof denied.lease_id, "%s", LEASE_B); @@ -228,8 +240,8 @@ static void test_explicit_takeover_revokes_old_remote_demand(void) JJ_CONTROL_LOSS_EXPLICIT_TAKEOVER); CHECK(strcmp(jj_control_loss_reason_str(takeover.last_control_loss_reason), "explicit_takeover") == 0); - CHECK(!jj_interlock_snapshot(&interlock).heater_requested); - CHECK(!evaluate(&interlock, &takeover, inputs).heater_requested); + CHECK(!jj_interlock_snapshot(&interlock).heater_authorized); + CHECK(!evaluate(&interlock, &takeover, inputs).heater_authorized); jj_control_snapshot_t heartbeat; CHECK(jj_authority_heartbeat(&authority, LEASE_A, first.generation, 4, &heartbeat) == JJ_CONTROL_GENERATION_STALE); @@ -250,7 +262,7 @@ static void test_automatic_survives_browser_loss_but_eligibility_fails_cold(void &authority, &interlock, &lease, JJ_MUTATION_AUTOMATIC, 0.0f, "auto-a", &inputs, 1); jj_outputs_t output = evaluate(&interlock, &automatic, inputs); - CHECK(!output.heater_requested); + CHECK(!output.heater_authorized); CHECK(output.block_reason == JJ_BLOCK_AUTO_POLICY_UNAVAILABLE); CHECK(jj_authority_tick(&authority, 1000)); @@ -259,29 +271,29 @@ static void test_automatic_survives_browser_loss_but_eligibility_fails_cold(void CHECK(expired.mode == JJ_MODE_AUTOMATIC); CHECK(expired.active_authority == JJ_AUTHORITY_AUTOMATIC); output = evaluate(&interlock, &expired, inputs); - CHECK(!output.heater_requested); + CHECK(!output.heater_authorized); CHECK(output.block_reason == JJ_BLOCK_AUTO_POLICY_UNAVAILABLE); inputs.printer.online = false; output = evaluate(&interlock, &expired, inputs); - CHECK(!output.heater_requested); + CHECK(!output.heater_authorized); CHECK(output.fault == JJ_FAULT_NONE); CHECK(output.block_reason == JJ_BLOCK_PRINTER_UNAVAILABLE); CHECK(output.control_inhibit == JJ_CONTROL_INHIBIT_NOT_ELIGIBLE); inputs.printer.online = true; inputs.printer.printing = false; output = evaluate(&interlock, &expired, inputs); - CHECK(!output.heater_requested); + CHECK(!output.heater_authorized); CHECK(output.block_reason == JJ_BLOCK_PRINTER_NOT_PRINTING); inputs.printer.printing = true; inputs.automatic_target_available = false; output = evaluate(&interlock, &expired, inputs); - CHECK(!output.heater_requested); + CHECK(!output.heater_authorized); CHECK(output.block_reason == JJ_BLOCK_AUTO_POLICY_UNAVAILABLE); inputs.automatic_target_available = true; inputs.fan_proof = JJ_FAN_PROOF_PENDING; output = evaluate(&interlock, &expired, inputs); - CHECK(!output.heater_requested); + CHECK(!output.heater_authorized); CHECK(output.block_reason == JJ_BLOCK_FAN_PROOF_PENDING); } @@ -322,7 +334,7 @@ static void test_faults_are_not_cleared_by_authority_and_client_cannot_bypass(vo jj_inputs_t inputs = nominal_inputs(); jj_inputs_t faulted = inputs; faulted.chamber.status = JJ_SENSOR_OPEN; - CHECK(jj_interlock_step(&interlock, &faulted).fault == JJ_FAULT_SENSOR); + CHECK(interlock_cycle(&interlock, &faulted).fault == JJ_FAULT_SENSOR); jj_control_snapshot_t lease = acquire(&authority, LEASE_A, "phone", false, 0); lease = refresh(&authority, &lease, 0); @@ -421,7 +433,7 @@ static void test_retry_after_expiry_never_replays_historical_manual_demand(void) CHECK(!retry.lease_active); CHECK(!retry.manual_demand_authorized); CHECK(retry.active_authority == JJ_AUTHORITY_NONE); - CHECK(!evaluate(&interlock, &retry, inputs).heater_requested); + CHECK(!evaluate(&interlock, &retry, inputs).heater_authorized); } static void test_directional_authority_sampling(void) @@ -438,25 +450,25 @@ static void test_directional_authority_sampling(void) "directional-manual", &cached_prusa_inputs, 1); /* Grants do not rewrite the current interlock decision. */ - CHECK(!jj_interlock_snapshot(&interlock).heater_requested); + CHECK(!jj_interlock_snapshot(&interlock).heater_authorized); CHECK(jj_authority_control_step( - &authority, &cached_prusa_inputs, 2, &manual).heater_requested); + &authority, &s_controller, &cached_prusa_inputs, 2, &manual).heater_authorized); /* A takeover after the cached Prusa read revokes that decision now. */ jj_control_snapshot_t takeover = acquire( &authority, LEASE_B, "phone-b", true, 3); CHECK(!takeover.manual_demand_authorized); - CHECK(!jj_interlock_snapshot(&interlock).heater_requested); + CHECK(!jj_interlock_snapshot(&interlock).heater_authorized); CHECK(!jj_authority_control_step( - &authority, &cached_prusa_inputs, 3, NULL).heater_requested); + &authority, &s_controller, &cached_prusa_inputs, 3, NULL).heater_authorized); takeover = refresh(&authority, &takeover, 4); jj_control_snapshot_t second_manual = mutate( &authority, &interlock, &takeover, JJ_MUTATION_MANUAL, 45.0f, "directional-second-manual", &cached_prusa_inputs, 5); - CHECK(!jj_interlock_snapshot(&interlock).heater_requested); + CHECK(!jj_interlock_snapshot(&interlock).heater_authorized); CHECK(jj_authority_control_step( - &authority, &cached_prusa_inputs, 5, NULL).heater_requested); + &authority, &s_controller, &cached_prusa_inputs, 5, NULL).heater_authorized); jj_control_snapshot_t reacquiring = acquire( &authority, LEASE_A, "phone-b", false, 6); @@ -465,19 +477,19 @@ static void test_directional_authority_sampling(void) CHECK(strcmp(jj_control_loss_reason_str( reacquiring.last_control_loss_reason), "remote_reacquired") == 0); - CHECK(!jj_interlock_snapshot(&interlock).heater_requested); + CHECK(!jj_interlock_snapshot(&interlock).heater_authorized); reacquiring = refresh(&authority, &reacquiring, 7); second_manual = mutate( &authority, &interlock, &reacquiring, JJ_MUTATION_MANUAL, 45.0f, "directional-third-manual", &cached_prusa_inputs, 8); CHECK(jj_authority_control_step( - &authority, &cached_prusa_inputs, 8, NULL).heater_requested); + &authority, &s_controller, &cached_prusa_inputs, 8, NULL).heater_authorized); jj_control_snapshot_t off = mutate( &authority, &interlock, &second_manual, JJ_MUTATION_OFF, 0.0f, "directional-off", &cached_prusa_inputs, 9); CHECK(off.mode == JJ_MODE_OFF); - CHECK(!jj_interlock_snapshot(&interlock).heater_requested); + CHECK(!jj_interlock_snapshot(&interlock).heater_authorized); } static void test_evicted_retry_reports_revision_conflict_with_current_state(void) @@ -558,8 +570,32 @@ static void test_remote_clear_obeys_fault_class_policy(void) CHECK(interlock.fault_latched == JJ_FAULT_UNCONTROLLED_RISE); } + +static void test_control_step_without_controller_fails_cold(void) +{ + jj_authority_t authority; + jj_interlock_t interlock; + jj_authority_init(&authority, &interlock, 1000); + jj_interlock_init(&interlock); + jj_inputs_t inputs = nominal_inputs(); + jj_control_snapshot_t lease = acquire(&authority, LEASE_A, "phone-a", false, 0); + lease = refresh(&authority, &lease, 0); + (void)mutate(&authority, &interlock, &lease, JJ_MUTATION_MANUAL, 45.0f, + "null-controller", &inputs, 1); + CHECK(jj_authority_control_step(&authority, &s_controller, &inputs, 2, NULL) + .heater_authorized); + const jj_outputs_t output = + jj_authority_control_step(&authority, NULL, &inputs, 3, NULL); + CHECK(!output.heater_authorized); + CHECK(output.allowed_duty_pct == 0.0f); + CHECK(output.block_reason == JJ_BLOCK_CONTROL_SEQUENCE_INVALID); + CHECK(!jj_interlock_snapshot(&interlock).heater_authorized); +} + int main(void) { + const jj_controller_config_t config = jj_controller_provisional_config(); + jj_controller_init(&s_controller, &config); test_manual_expiry_fails_cold_without_fault_and_keeps_cooldown(); test_reacquire_requires_refresh_and_new_explicit_request(); test_stale_generation_and_revision_are_atomic(); @@ -572,6 +608,7 @@ int main(void) test_directional_authority_sampling(); test_evicted_retry_reports_revision_conflict_with_current_state(); test_remote_clear_obeys_fault_class_policy(); + test_control_step_without_controller_fails_cold(); puts("jj_authority_host_test: PASS"); return 0; } diff --git a/tests/jj_control_host_test.c b/tests/jj_control_host_test.c new file mode 100644 index 0000000..e8a8498 --- /dev/null +++ b/tests/jj_control_host_test.c @@ -0,0 +1,414 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "jj_control.h" +#include "jj_interlock.h" +#include +#include +#include +#include + +#define CHECK(condition) do { if (!(condition)) { \ + fprintf(stderr, "%s:%d: CHECK failed: %s\n", __FILE__, __LINE__, #condition); \ + exit(1); } } while (0) + +/* + * TEST FIXTURE ONLY. Non-zero gains exist so lifecycle behavior is observable. + * They are not tuning and say nothing about the Jump Jet thermal plant. + */ +static jj_controller_config_t fixture_config(void) +{ + jj_controller_config_t config = jj_controller_provisional_config(); + config.pid.kp = 4.0f; + config.pid.ki = 0.5f; + return config; +} + +static jj_inputs_t manual_inputs(float chamber_c) +{ + return (jj_inputs_t){ + .commissioned = true, + .mode = JJ_MODE_MANUAL, + .manual_target_c = JJ_MANUAL_TARGET_DEFAULT_C, + .active_authority = JJ_AUTHORITY_REMOTE, + .manual_demand_authorized = true, + .chamber = {.status = JJ_SENSOR_OK, .temperature_c = chamber_c}, + .outlet = {.status = JJ_SENSOR_OK, .temperature_c = 35.0f}, + .case_sensor = {.status = JJ_SENSOR_OK, .temperature_c = 36.0f}, + .printer = {.online = true, .printing = true, .bed_target_c = 100.0f}, + .fan_proof = JJ_FAN_PROOF_PROVEN, + }; +} + +typedef struct { + jj_controller_result_t controller; + jj_outputs_t output; +} cycle_t; + +/* The production ordering: Stage 1 -> controller -> Stage 2. */ +static cycle_t run_cycle(jj_interlock_t *interlock, jj_controller_t *controller, + const jj_inputs_t *input) +{ + cycle_t c; + const jj_eligibility_t eligibility = jj_interlock_evaluate(interlock, input); + c.controller = jj_controller_step(controller, &eligibility, input); + c.output = jj_interlock_authorize(interlock, &c.controller.report); + return c; +} + +static void check_cold(const cycle_t *c) +{ + CHECK(!c->output.heater_authorized); + CHECK(c->output.allowed_duty_pct == 0.0f); +} + +static void check_idle(const cycle_t *c) +{ + CHECK(!c->controller.stepped); + CHECK(c->controller.report.state == JJ_CONTROLLER_IDLE); + CHECK(c->output.controller_state == JJ_CONTROLLER_IDLE); + CHECK(c->output.requested_duty_pct == 0.0f); + check_cold(c); +} + +static void setup(jj_interlock_t *interlock, jj_controller_t *controller, + jj_controller_config_t config) +{ + jj_interlock_init(interlock); + jj_controller_init(controller, &config); +} + +static void test_boot_and_off_are_idle(void) +{ + jj_interlock_t interlock; + jj_controller_t controller; + setup(&interlock, &controller, fixture_config()); + + const jj_inputs_t boot = jj_inputs_safe_defaults(); + cycle_t c = run_cycle(&interlock, &controller, &boot); + check_idle(&c); + CHECK(c.output.block_reason == JJ_BLOCK_NOT_COMMISSIONED); + + jj_inputs_t off = manual_inputs(30.0f); + off.mode = JJ_MODE_OFF; + c = run_cycle(&interlock, &controller, &off); + check_idle(&c); + CHECK(c.output.block_reason == JJ_BLOCK_OFF); + /* IDLE is ordinary: not a fault, not the controller-invalid reason. */ + CHECK(c.output.fault == JJ_FAULT_NONE); + CHECK(c.output.block_reason != JJ_BLOCK_CONTROLLER_INVALID); +} + +static void test_off_to_manual_steps_and_authorizes(void) +{ + jj_interlock_t interlock; + jj_controller_t controller; + setup(&interlock, &controller, fixture_config()); + jj_inputs_t input = manual_inputs(30.0f); + input.mode = JJ_MODE_OFF; + (void)run_cycle(&interlock, &controller, &input); + + input.mode = JJ_MODE_MANUAL; + const cycle_t c = run_cycle(&interlock, &controller, &input); + CHECK(c.controller.stepped); + CHECK(c.controller.integrating); + CHECK(c.controller.setpoint_c == JJ_MANUAL_TARGET_DEFAULT_C); + CHECK(c.output.controller_state == JJ_CONTROLLER_VALID); + CHECK(c.output.heater_authorized); + CHECK(c.output.requested_duty_pct == c.controller.pid.output); + CHECK(c.output.requested_duty_pct > 0.0f); + CHECK(c.output.allowed_duty_pct == c.output.requested_duty_pct); +} + +static void test_provisional_config_is_valid_and_requests_nothing(void) +{ + jj_interlock_t interlock; + jj_controller_t controller; + setup(&interlock, &controller, jj_controller_provisional_config()); + const jj_inputs_t input = manual_inputs(30.0f); + const cycle_t c = run_cycle(&interlock, &controller, &input); + CHECK(c.output.controller_state == JJ_CONTROLLER_VALID); + CHECK(c.output.requested_duty_pct == 0.0f); + CHECK(c.output.allowed_duty_pct == 0.0f); +} + +static void test_rejected_step_is_invalid_and_cold(void) +{ + jj_controller_config_t bad[3]; + bad[0] = fixture_config(); + bad[0].pid.kd = 1.0f; /* kd without derivative_alpha */ + bad[1] = fixture_config(); + bad[1].sample_period_s = 0.0f; /* dt must be positive */ + bad[2] = fixture_config(); + bad[2].pid.output_max = NAN; + for (size_t i = 0; i < sizeof bad / sizeof bad[0]; ++i) { + jj_interlock_t interlock; + jj_controller_t controller; + setup(&interlock, &controller, bad[i]); + const jj_inputs_t input = manual_inputs(30.0f); + for (int n = 0; n < 5; ++n) { + const cycle_t c = run_cycle(&interlock, &controller, &input); + CHECK(c.controller.stepped); + CHECK(c.controller.report.state == JJ_CONTROLLER_INVALID); + CHECK(c.output.controller_state == JJ_CONTROLLER_INVALID); + CHECK(c.output.requested_duty_pct == 0.0f); + check_cold(&c); + CHECK(c.output.block_reason == JJ_BLOCK_CONTROLLER_INVALID); + } + } +} + +static void test_automatic_is_idle_before_phase_four(void) +{ + jj_interlock_t interlock; + jj_controller_t controller; + setup(&interlock, &controller, fixture_config()); + jj_inputs_t input = manual_inputs(30.0f); + input.mode = JJ_MODE_AUTOMATIC; + input.active_authority = JJ_AUTHORITY_AUTOMATIC; + input.automatic_target_available = true; + input.automatic_target_c = 42.0f; + for (int n = 0; n < 5; ++n) { + const cycle_t c = run_cycle(&interlock, &controller, &input); + check_idle(&c); + CHECK(c.output.block_reason == JJ_BLOCK_AUTO_POLICY_UNAVAILABLE); + } + /* Even if eligibility said RUN, AUTOMATIC has no setpoint to control. */ + const jj_eligibility_t run = {.disposition = JJ_DISPOSITION_RUN}; + const jj_controller_result_t r = jj_controller_step(&controller, &run, &input); + CHECK(!r.stepped && r.report.state == JJ_CONTROLLER_IDLE); +} + +static void test_fan_proof_pending_holds_and_freezes_integral(void) +{ + jj_interlock_t interlock; + jj_controller_t controller; + setup(&interlock, &controller, fixture_config()); + jj_inputs_t input = manual_inputs(40.0f); + for (int n = 0; n < 4; ++n) (void)run_cycle(&interlock, &controller, &input); + const float integral = controller.pid.integral; + CHECK(integral > 0.0f); + + input.fan_proof = JJ_FAN_PROOF_PENDING; + for (int n = 0; n < 3; ++n) { + const cycle_t c = run_cycle(&interlock, &controller, &input); + CHECK(c.output.controller_disposition == JJ_DISPOSITION_HOLD); + CHECK(c.controller.stepped); + CHECK(!c.controller.integrating); + CHECK(controller.pid.integral == integral); + CHECK(c.output.controller_state == JJ_CONTROLLER_VALID); + CHECK(c.output.requested_duty_pct > 0.0f); /* raw request, not zeroed */ + check_cold(&c); + CHECK(c.output.block_reason == JJ_BLOCK_FAN_PROOF_PENDING); + } + + /* Overlap with a SUSPEND-class inhibit suspends and resets. */ + jj_inputs_t no_authority = input; + no_authority.manual_demand_authorized = false; + cycle_t c = run_cycle(&interlock, &controller, &no_authority); + CHECK(c.output.block_reason == JJ_BLOCK_FAN_PROOF_PENDING); + CHECK(c.output.controller_disposition == JJ_DISPOSITION_SUSPEND); + check_idle(&c); + CHECK(controller.pid.integral == 0.0f); + + /* Resuming from a pure HOLD keeps the frozen integral. */ + setup(&interlock, &controller, fixture_config()); + input = manual_inputs(40.0f); + for (int n = 0; n < 4; ++n) (void)run_cycle(&interlock, &controller, &input); + const float kept = controller.pid.integral; + input.fan_proof = JJ_FAN_PROOF_PENDING; + (void)run_cycle(&interlock, &controller, &input); + input.fan_proof = JJ_FAN_PROOF_PROVEN; + c = run_cycle(&interlock, &controller, &input); + CHECK(c.controller.integrating); + CHECK(controller.pid.integral > kept); + CHECK(c.output.heater_authorized); +} + +static void test_fault_latch_keeps_controller_idle_until_safe_clear(void) +{ + jj_interlock_t interlock; + jj_controller_t controller; + setup(&interlock, &controller, fixture_config()); + jj_inputs_t input = manual_inputs(40.0f); + for (int n = 0; n < 4; ++n) (void)run_cycle(&interlock, &controller, &input); + CHECK(controller.pid.integral > 0.0f); + + jj_inputs_t faulted = input; + faulted.overtemperature_detected = true; + cycle_t c = run_cycle(&interlock, &controller, &faulted); + check_idle(&c); + CHECK(c.output.fault == JJ_FAULT_OVERTEMPERATURE); + CHECK(controller.pid.integral == 0.0f); + + /* Condition gone, MANUAL still has a target: the latch keeps it IDLE. */ + for (int n = 0; n < 20; ++n) { + c = run_cycle(&interlock, &controller, &input); + check_idle(&c); + CHECK(c.output.block_reason == JJ_BLOCK_FAULT_LATCHED); + CHECK(c.output.controller_disposition == JJ_DISPOSITION_SUSPEND); + CHECK(controller.pid.integral == 0.0f); + CHECK(!controller.pid.initialized); + } + + input.overtemperature_reset_proven = true; + CHECK(!jj_interlock_clear_fault(&interlock, &input)); /* not OFF */ + jj_inputs_t off = input; + off.mode = JJ_MODE_OFF; + CHECK(jj_interlock_clear_fault(&interlock, &off)); + c = run_cycle(&interlock, &controller, &off); + check_idle(&c); + c = run_cycle(&interlock, &controller, &input); + CHECK(c.output.controller_state == JJ_CONTROLLER_VALID); + CHECK(c.output.heater_authorized); +} + +static void test_category_b_inhibits_suspend_and_reset(void) +{ + jj_interlock_t interlock; + jj_controller_t controller; + setup(&interlock, &controller, fixture_config()); + jj_inputs_t input = manual_inputs(40.0f); + for (int n = 0; n < 4; ++n) (void)run_cycle(&interlock, &controller, &input); + CHECK(controller.pid.integral > 0.0f); + + jj_inputs_t bad_target = input; + bad_target.manual_target_c = 50.5f; + cycle_t c = run_cycle(&interlock, &controller, &bad_target); + check_idle(&c); + CHECK(c.output.block_reason == JJ_BLOCK_MANUAL_TARGET_INVALID); + CHECK(controller.pid.integral == 0.0f); + + for (int n = 0; n < 4; ++n) (void)run_cycle(&interlock, &controller, &input); + CHECK(controller.pid.integral > 0.0f); + jj_inputs_t uncommissioned = input; + uncommissioned.commissioned = false; + c = run_cycle(&interlock, &controller, &uncommissioned); + check_idle(&c); + CHECK(c.output.block_reason == JJ_BLOCK_NOT_COMMISSIONED); + CHECK(controller.pid.integral == 0.0f); +} + +static void test_no_target_crossing_override_outside_dc_pid(void) +{ + jj_interlock_t interlock; + jj_controller_t controller; + const jj_controller_config_t config = fixture_config(); + setup(&interlock, &controller, config); + dc_pid_state_t shadow; + dc_pid_reset(&shadow); + + /* Rise through the target and fall back through it. */ + const float trajectory[] = {38, 40, 42, 44, 44.8f, 45.2f, 45.6f, 45.9f, + 45.6f, 45.2f, 44.9f, 44.6f}; + bool nonzero_above_target = false; + for (size_t i = 0; i < sizeof trajectory / sizeof trajectory[0]; ++i) { + const jj_inputs_t input = manual_inputs(trajectory[i]); + const cycle_t c = run_cycle(&interlock, &controller, &input); + dc_pid_result_t expected; + CHECK(dc_pid_step(&shadow, &config.pid, JJ_MANUAL_TARGET_DEFAULT_C, + trajectory[i], config.sample_period_s, true, &expected)); + /* Requested and allowed duty are exactly dc_pid's output, every cycle. */ + CHECK(c.output.requested_duty_pct == expected.output); + CHECK(c.output.allowed_duty_pct == expected.output); + CHECK(c.output.heater_authorized); + if (trajectory[i] > JJ_MANUAL_TARGET_DEFAULT_C && expected.output > 0.0f) + nonzero_above_target = true; + } + CHECK(nonzero_above_target); /* the fixture actually exercises the case */ +} + +static void test_controller_defers_to_eligibility(void) +{ + /* + * jj_control has no fan/fault/printer/authority/sensor-status predicates: + * given RUN it steps even on inputs the interlock would refuse, and given + * SUSPEND it idles on inputs the interlock would allow. + */ + jj_controller_t controller; + const jj_controller_config_t config = fixture_config(); + jj_controller_init(&controller, &config); + jj_inputs_t hostile = manual_inputs(40.0f); + hostile.fan_proof = JJ_FAN_PROOF_FAILED; + hostile.overtemperature_detected = true; + hostile.commissioned = false; + hostile.manual_demand_authorized = false; + hostile.outlet.status = JJ_SENSOR_OPEN; + const jj_eligibility_t run = {.sequence = 7, .disposition = JJ_DISPOSITION_RUN}; + jj_controller_result_t r = jj_controller_step(&controller, &run, &hostile); + CHECK(r.stepped && r.report.state == JJ_CONTROLLER_VALID); + CHECK(r.report.eligibility_sequence == 7); + + const jj_eligibility_t suspend = {.disposition = JJ_DISPOSITION_SUSPEND}; + const jj_inputs_t fine = manual_inputs(40.0f); + r = jj_controller_step(&controller, &suspend, &fine); + CHECK(!r.stepped && r.report.state == JJ_CONTROLLER_IDLE); + + const jj_eligibility_t garbage = {.disposition = (jj_controller_disposition_t)9}; + r = jj_controller_step(&controller, &garbage, &fine); + CHECK(!r.stepped && r.report.state == JJ_CONTROLLER_INVALID); + CHECK(jj_controller_step(NULL, &run, &fine).report.state == + JJ_CONTROLLER_INVALID); +} + +static void test_invalid_never_yields_allowed_duty(void) +{ + /* Sweep modes, dispositions, and configs: allowed > 0 only when VALID+RUN. */ + const jj_mode_t modes[] = {JJ_MODE_OFF, JJ_MODE_MANUAL, JJ_MODE_AUTOMATIC}; + const jj_fan_proof_t fans[] = {JJ_FAN_PROOF_PROVEN, JJ_FAN_PROOF_PENDING}; + for (int bad = 0; bad < 2; ++bad) + for (size_t m = 0; m < 3; ++m) + for (size_t f = 0; f < 2; ++f) { + jj_interlock_t interlock; + jj_controller_t controller; + jj_controller_config_t config = fixture_config(); + if (bad) config.sample_period_s = -1.0f; + setup(&interlock, &controller, config); + jj_inputs_t input = manual_inputs(30.0f); + input.mode = modes[m]; + input.fan_proof = fans[f]; + const cycle_t c = run_cycle(&interlock, &controller, &input); + if (c.output.allowed_duty_pct > 0.0f || c.output.heater_authorized) { + CHECK(c.output.controller_state == JJ_CONTROLLER_VALID); + CHECK(c.output.controller_disposition == JJ_DISPOSITION_RUN); + } + if (c.output.controller_state != JJ_CONTROLLER_VALID) check_cold(&c); + } +} + +static void test_target_change_behavior_is_provisional(void) +{ + /* + * PROVISIONAL: documents current mechanics only. No target-change policy + * is implemented; dc_pid keeps its integral across a setpoint change. + * Preserve vs reinitialize is open pending Phase 3 evidence. + */ + jj_interlock_t interlock; + jj_controller_t controller; + setup(&interlock, &controller, fixture_config()); + jj_inputs_t input = manual_inputs(40.0f); + for (int n = 0; n < 4; ++n) (void)run_cycle(&interlock, &controller, &input); + const float before = controller.pid.integral; + input.manual_target_c = 35.0f; + const cycle_t c = run_cycle(&interlock, &controller, &input); + CHECK(c.output.controller_state == JJ_CONTROLLER_VALID); + CHECK(c.controller.pid.d == 0.0f); /* no derivative term, so no kick here */ + CHECK(controller.pid.initialized); + CHECK(before > 0.0f); +} + +int main(void) +{ + test_boot_and_off_are_idle(); + test_off_to_manual_steps_and_authorizes(); + test_provisional_config_is_valid_and_requests_nothing(); + test_rejected_step_is_invalid_and_cold(); + test_automatic_is_idle_before_phase_four(); + test_fan_proof_pending_holds_and_freezes_integral(); + test_fault_latch_keeps_controller_idle_until_safe_clear(); + test_category_b_inhibits_suspend_and_reset(); + test_no_target_crossing_override_outside_dc_pid(); + test_controller_defers_to_eligibility(); + test_invalid_never_yields_allowed_duty(); + test_target_change_behavior_is_provisional(); + puts("jj_control_host_test: PASS"); + return 0; +} diff --git a/tests/jj_interlock_host_test.c b/tests/jj_interlock_host_test.c index 63da916..cd89f37 100644 --- a/tests/jj_interlock_host_test.c +++ b/tests/jj_interlock_host_test.c @@ -1,5 +1,6 @@ #include "jj_interlock.h" #include +#include #include #include @@ -28,16 +29,40 @@ static jj_inputs_t nominal(jj_mode_t mode) return input; } +/* + * One staged cycle with a well-formed VALID controller report. Exercises the + * interlock alone: Stage 2 must treat this caller-built report as untrusted. + */ +static jj_outputs_t cycle_with(jj_interlock_t *state, const jj_inputs_t *input, + jj_controller_state_t controller, float duty) +{ + const jj_eligibility_t eligibility = jj_interlock_evaluate(state, input); + const jj_controller_report_t report = { + .eligibility_sequence = eligibility.sequence, + .state = eligibility.disposition == JJ_DISPOSITION_SUSPEND + ? JJ_CONTROLLER_IDLE : controller, + .requested_duty_pct = eligibility.disposition == JJ_DISPOSITION_SUSPEND + ? 0.0f : duty, + }; + return jj_interlock_authorize(state, &report); +} + +static jj_outputs_t cycle(jj_interlock_t *state, const jj_inputs_t *input) +{ + return cycle_with(state, input, JJ_CONTROLLER_VALID, 50.0f); +} + static jj_outputs_t step_once(jj_inputs_t input) { jj_interlock_t state; jj_interlock_init(&state); - return jj_interlock_step(&state, &input); + return cycle(&state, &input); } static void check_cold(jj_outputs_t output, jj_block_reason_t reason) { - CHECK(!output.heater_requested); + CHECK(!output.heater_authorized); + CHECK(output.allowed_duty_pct == 0.0f); CHECK(output.effective_target_c == 0.0f); CHECK(output.block_reason == reason); } @@ -64,7 +89,7 @@ static void test_manual_target_is_rejected_not_clamped(void) jj_inputs_t input = nominal(JJ_MODE_MANUAL); input.manual_target_c = accepted[i]; jj_outputs_t output = step_once(input); - CHECK(output.heater_requested); + CHECK(output.heater_authorized); CHECK(output.effective_target_c == accepted[i]); } const float rejected[] = {29.9f, 50.1f, NAN, INFINITY, -INFINITY}; @@ -120,12 +145,12 @@ static void test_faults_latch_and_need_explicit_safe_clear(void) jj_interlock_init(&state); jj_inputs_t input = nominal(JJ_MODE_MANUAL); input.chamber.status = JJ_SENSOR_OPEN; - jj_outputs_t output = jj_interlock_step(&state, &input); + jj_outputs_t output = cycle(&state, &input); CHECK(output.fault == JJ_FAULT_SENSOR); check_cold(output, JJ_BLOCK_FAULT_LATCHED); input = nominal(JJ_MODE_MANUAL); - CHECK(jj_interlock_step(&state, &input).fault == JJ_FAULT_SENSOR); + CHECK(cycle(&state, &input).fault == JJ_FAULT_SENSOR); CHECK(!jj_interlock_clear_fault(&state, &input)); input.mode = JJ_MODE_OFF; input.cooldown_required = true; @@ -136,7 +161,7 @@ static void test_faults_latch_and_need_explicit_safe_clear(void) jj_interlock_init(&state); input = nominal(JJ_MODE_MANUAL); input.overtemperature_detected = true; - output = jj_interlock_step(&state, &input); + output = cycle(&state, &input); CHECK(output.fault == JJ_FAULT_OVERTEMPERATURE); check_cold(output, JJ_BLOCK_FAULT_LATCHED); } @@ -169,8 +194,15 @@ static void test_fan_proof_and_null_inputs_fail_cold(void) jj_interlock_t state; jj_interlock_init(&state); - check_cold(jj_interlock_step(NULL, &input), JJ_BLOCK_FAULT_LATCHED); - check_cold(jj_interlock_step(&state, NULL), JJ_BLOCK_FAULT_LATCHED); + const jj_eligibility_t null_eligibility = jj_interlock_evaluate(NULL, &input); + CHECK(null_eligibility.disposition == JJ_DISPOSITION_SUSPEND); + CHECK(null_eligibility.dominant_reason == JJ_BLOCK_FAULT_LATCHED); + CHECK(jj_interlock_evaluate(&state, NULL).disposition == JJ_DISPOSITION_SUSPEND); + const jj_controller_report_t report = {.state = JJ_CONTROLLER_VALID, + .requested_duty_pct = 50.0f}; + check_cold(jj_interlock_authorize(NULL, &report), JJ_BLOCK_FAULT_LATCHED); + CHECK(!jj_interlock_authorize(&state, &report).heater_authorized); + CHECK(!jj_interlock_authorize(&state, NULL).heater_authorized); } static void test_remote_fault_acknowledgement_policy(void) @@ -247,6 +279,215 @@ static void test_remote_fault_acknowledgement_policy(void) CHECK(jj_interlock_clear_fault(&state, &input)); } + +static void test_disposition_classification_is_pinned(void) +{ + /* Changing any disposition must be a deliberate, reviewed test change. */ + for (int r = 0; r <= (int)JJ_BLOCK_REASON_COUNT; ++r) { + const jj_block_reason_t reason = (jj_block_reason_t)r; + jj_controller_disposition_t expected = JJ_DISPOSITION_SUSPEND; + if (reason == JJ_BLOCK_NONE) expected = JJ_DISPOSITION_RUN; + if (reason == JJ_BLOCK_FAN_PROOF_PENDING) expected = JJ_DISPOSITION_HOLD; + CHECK(jj_block_reason_disposition(reason) == expected); + } + CHECK(jj_block_reason_disposition((jj_block_reason_t)999) == + JJ_DISPOSITION_SUSPEND); + CHECK(JJ_DISPOSITION_SUSPEND < JJ_DISPOSITION_HOLD); + CHECK(JJ_DISPOSITION_HOLD < JJ_DISPOSITION_RUN); + CHECK(JJ_DISPOSITION_SUSPEND == 0 && JJ_CONTROLLER_IDLE == 0); +} + +static void test_overlapping_inhibits_aggregate_conservatively(void) +{ + jj_interlock_t state; + jj_interlock_init(&state); + jj_inputs_t input = nominal(JJ_MODE_MANUAL); + CHECK(jj_interlock_evaluate(&state, &input).disposition == JJ_DISPOSITION_RUN); + + input.fan_proof = JJ_FAN_PROOF_PENDING; + jj_eligibility_t e = jj_interlock_evaluate(&state, &input); + CHECK(e.disposition == JJ_DISPOSITION_HOLD); + CHECK(e.dominant_reason == JJ_BLOCK_FAN_PROOF_PENDING); + + /* Same dominant diagnostic reason, but disposition sees every inhibit. */ + jj_inputs_t no_authority = input; + no_authority.manual_demand_authorized = false; + e = jj_interlock_evaluate(&state, &no_authority); + CHECK(e.dominant_reason == JJ_BLOCK_FAN_PROOF_PENDING); + CHECK(e.disposition == JJ_DISPOSITION_SUSPEND); + CHECK(e.inhibit_mask & (UINT32_C(1) << JJ_BLOCK_FAN_PROOF_PENDING)); + CHECK(e.inhibit_mask & (UINT32_C(1) << JJ_BLOCK_CONTROL_NO_AUTHORITY)); + + jj_inputs_t refresh = input; + refresh.active_authority = JJ_AUTHORITY_REACQUIRING; + CHECK(jj_interlock_evaluate(&state, &refresh).disposition == + JJ_DISPOSITION_SUSPEND); + + jj_inputs_t bad_target = input; + bad_target.manual_target_c = 60.0f; + e = jj_interlock_evaluate(&state, &bad_target); + CHECK(e.dominant_reason == JJ_BLOCK_FAN_PROOF_PENDING); + CHECK(e.disposition == JJ_DISPOSITION_SUSPEND); + + jj_inputs_t automatic = nominal(JJ_MODE_AUTOMATIC); + automatic.fan_proof = JJ_FAN_PROOF_PENDING; + CHECK(jj_interlock_evaluate(&state, &automatic).disposition == + JJ_DISPOSITION_SUSPEND); + + /* Fault latch dominates and suspends regardless of other conditions. */ + jj_inputs_t faulted = input; + faulted.overtemperature_detected = true; + e = jj_interlock_evaluate(&state, &faulted); + CHECK(e.dominant_reason == JJ_BLOCK_FAULT_LATCHED); + CHECK(e.disposition == JJ_DISPOSITION_SUSPEND); +} + +static void test_authorization_requires_valid_controller(void) +{ + jj_inputs_t input = nominal(JJ_MODE_MANUAL); + jj_interlock_t state; + + jj_interlock_init(&state); + jj_outputs_t out = cycle_with(&state, &input, JJ_CONTROLLER_INVALID, 80.0f); + check_cold(out, JJ_BLOCK_CONTROLLER_INVALID); + CHECK(out.controller_state == JJ_CONTROLLER_INVALID); + CHECK(out.requested_duty_pct == 0.0f); + + jj_interlock_init(&state); + out = cycle_with(&state, &input, JJ_CONTROLLER_IDLE, 0.0f); + check_cold(out, JJ_BLOCK_CONTROLLER_INVALID); + + const float malformed[] = {NAN, INFINITY, -1.0f, 100.5f}; + for (size_t i = 0; i < sizeof malformed / sizeof malformed[0]; ++i) { + jj_interlock_init(&state); + out = cycle_with(&state, &input, JJ_CONTROLLER_VALID, malformed[i]); + check_cold(out, JJ_BLOCK_CONTROLLER_INVALID); + CHECK(out.controller_state == JJ_CONTROLLER_INVALID); + CHECK(out.requested_duty_pct == 0.0f); + } + jj_interlock_init(&state); + out = cycle_with(&state, &input, (jj_controller_state_t)77, 50.0f); + check_cold(out, JJ_BLOCK_CONTROLLER_INVALID); + + /* A legitimate VALID 0 % request is authorized and distinct from denial. */ + jj_interlock_init(&state); + out = cycle_with(&state, &input, JJ_CONTROLLER_VALID, 0.0f); + CHECK(out.heater_authorized); + CHECK(out.controller_state == JJ_CONTROLLER_VALID); + CHECK(out.allowed_duty_pct == 0.0f); + CHECK(out.block_reason == JJ_BLOCK_NONE); + + jj_interlock_init(&state); + out = cycle_with(&state, &input, JJ_CONTROLLER_VALID, 37.5f); + CHECK(out.heater_authorized); + CHECK(out.requested_duty_pct == 37.5f); + CHECK(out.allowed_duty_pct == 37.5f); + CHECK(out.effective_target_c == JJ_MANUAL_TARGET_DEFAULT_C); + CHECK(out.thermal_state == JJ_THERMAL_HEATING); +} + +static void test_safety_never_rewrites_valid_request(void) +{ + jj_interlock_t state; + jj_interlock_init(&state); + jj_inputs_t input = nominal(JJ_MODE_MANUAL); + input.fan_proof = JJ_FAN_PROOF_PENDING; + jj_outputs_t out = cycle_with(&state, &input, JJ_CONTROLLER_VALID, 42.0f); + check_cold(out, JJ_BLOCK_FAN_PROOF_PENDING); + CHECK(out.controller_disposition == JJ_DISPOSITION_HOLD); + CHECK(out.controller_state == JJ_CONTROLLER_VALID); + CHECK(out.requested_duty_pct == 42.0f); + + /* Stepping while suspended is a controller contract violation. */ + jj_inputs_t off = nominal(JJ_MODE_OFF); + const jj_eligibility_t e = jj_interlock_evaluate(&state, &off); + const jj_controller_report_t rogue = { + .eligibility_sequence = e.sequence, + .state = JJ_CONTROLLER_VALID, + .requested_duty_pct = 90.0f, + }; + out = jj_interlock_authorize(&state, &rogue); + check_cold(out, JJ_BLOCK_OFF); + CHECK(out.controller_state == JJ_CONTROLLER_INVALID); +} + +static void test_stage_two_consumes_only_its_own_stage_one(void) +{ + jj_interlock_t state; + jj_inputs_t input = nominal(JJ_MODE_MANUAL); + const jj_controller_report_t any = {.state = JJ_CONTROLLER_VALID, + .requested_duty_pct = 50.0f}; + + /* Authorize with no Stage 1 at all. */ + jj_interlock_init(&state); + CHECK(!jj_interlock_authorize(&state, &any).heater_authorized); + + /* Wrong token: denied, and the pending evaluation is consumed. */ + jj_interlock_init(&state); + jj_eligibility_t e = jj_interlock_evaluate(&state, &input); + jj_controller_report_t report = {.eligibility_sequence = e.sequence + 1, + .state = JJ_CONTROLLER_VALID, + .requested_duty_pct = 50.0f}; + jj_outputs_t out = jj_interlock_authorize(&state, &report); + check_cold(out, JJ_BLOCK_CONTROL_SEQUENCE_INVALID); + report.eligibility_sequence = e.sequence; + CHECK(!jj_interlock_authorize(&state, &report).heater_authorized); + + /* Replay: one Stage 1 authorizes at most one Stage 2. */ + jj_interlock_init(&state); + e = jj_interlock_evaluate(&state, &input); + report.eligibility_sequence = e.sequence; + CHECK(jj_interlock_authorize(&state, &report).heater_authorized); + out = jj_interlock_authorize(&state, &report); + check_cold(out, JJ_BLOCK_CONTROL_SEQUENCE_INVALID); + CHECK(!jj_interlock_snapshot(&state).heater_authorized); + + /* Stale: a newer Stage 1 supersedes an older one. */ + jj_interlock_init(&state); + const jj_eligibility_t older = jj_interlock_evaluate(&state, &input); + (void)jj_interlock_evaluate(&state, &input); + report.eligibility_sequence = older.sequence; + CHECK(!jj_interlock_authorize(&state, &report).heater_authorized); + + /* A caller cannot manufacture eligibility: Stage 2 uses its own copy. */ + jj_interlock_init(&state); + jj_inputs_t off = nominal(JJ_MODE_OFF); + e = jj_interlock_evaluate(&state, &off); + jj_eligibility_t forged = e; + forged.disposition = JJ_DISPOSITION_RUN; + forged.dominant_reason = JJ_BLOCK_NONE; + report.eligibility_sequence = forged.sequence; + CHECK(!jj_interlock_authorize(&state, &report).heater_authorized); + + /* Revocation between the stages voids the pending evaluation. */ + jj_interlock_init(&state); + e = jj_interlock_evaluate(&state, &input); + jj_interlock_remove_remote_authorization( + &state, JJ_AUTHORITY_NONE, JJ_CONTROL_INHIBIT_NO_AUTHORITY, + JJ_BLOCK_CONTROL_NO_AUTHORITY); + report.eligibility_sequence = e.sequence; + out = jj_interlock_authorize(&state, &report); + CHECK(!out.heater_authorized); + CHECK(out.block_reason == JJ_BLOCK_CONTROL_NO_AUTHORITY); + + /* Fault clear between the stages also voids it. */ + jj_interlock_init(&state); + jj_inputs_t clear = nominal(JJ_MODE_OFF); + e = jj_interlock_evaluate(&state, &input); + state.fault_latched = JJ_FAULT_SENSOR; + CHECK(jj_interlock_clear_fault(&state, &clear)); + report.eligibility_sequence = e.sequence; + CHECK(!jj_interlock_authorize(&state, &report).heater_authorized); + + /* Stage 1 immediately withdraws any earlier authorization. */ + jj_interlock_init(&state); + CHECK(cycle(&state, &input).heater_authorized); + CHECK(jj_interlock_snapshot(&state).heater_authorized); + (void)jj_interlock_evaluate(&state, &input); + CHECK(!jj_interlock_snapshot(&state).heater_authorized); + CHECK(jj_interlock_snapshot(&state).allowed_duty_pct == 0.0f); +} + int main(void) { test_boot_defaults_and_modes(); @@ -257,6 +498,11 @@ int main(void) test_off_keeps_thermal_management_request(); test_fan_proof_and_null_inputs_fail_cold(); test_remote_fault_acknowledgement_policy(); + test_disposition_classification_is_pinned(); + test_overlapping_inhibits_aggregate_conservatively(); + test_authorization_requires_valid_controller(); + test_safety_never_rewrites_valid_request(); + test_stage_two_consumes_only_its_own_stage_one(); puts("jj_interlock_host_test: PASS"); return 0; } diff --git a/tests/run_authority_host_test.sh b/tests/run_authority_host_test.sh index f4b8d56..fe60ded 100755 --- a/tests/run_authority_host_test.sh +++ b/tests/run_authority_host_test.sh @@ -1,13 +1,18 @@ #!/usr/bin/env bash set -euo pipefail root=$(cd "$(dirname "$0")/.." && pwd) +dc_pid=$("$root/tests/fetch_dc_pid.sh") out=$(mktemp) trap 'rm -f "$out"' EXIT cc -std=c11 -Wall -Wextra -Werror \ -fsanitize=undefined -fno-sanitize-recover=undefined \ -I"$root/components/jj_interlock/include" \ -I"$root/components/jj_authority/include" \ + -I"$root/components/jj_control/include" \ + -I"$dc_pid/include" \ "$root/components/jj_interlock/jj_interlock.c" \ "$root/components/jj_authority/jj_authority.c" \ + "$root/components/jj_control/jj_control.c" \ + "$dc_pid/dc_pid.c" \ "$root/tests/jj_authority_host_test.c" -lm -o "$out" "$out" diff --git a/tests/run_control_host_test.sh b/tests/run_control_host_test.sh new file mode 100755 index 0000000..ebcee73 --- /dev/null +++ b/tests/run_control_host_test.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +dc_pid=$("$root/tests/fetch_dc_pid.sh") +out=$(mktemp) +trap 'rm -f "$out"' EXIT +cc -std=c11 -Wall -Wextra -Werror \ + -fsanitize=undefined -fno-sanitize-recover=undefined \ + -I"$root/components/jj_interlock/include" \ + -I"$root/components/jj_control/include" \ + -I"$dc_pid/include" \ + "$root/components/jj_interlock/jj_interlock.c" \ + "$root/components/jj_control/jj_control.c" \ + "$dc_pid/dc_pid.c" \ + "$root/tests/jj_control_host_test.c" -lm -o "$out" +"$out" From 20da76a3044d2fa9d76e2a8a47f5364ec44af0d2 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Thu, 24 Sep 2026 00:18:46 +0000 Subject: [PATCH 2/3] docs(architecture): describe the staged PID-family control loop Document Stage 1 eligibility, controller disposition, and Stage 2 authorization, the IDLE/VALID/INVALID lifecycle, and which parameters remain provisional pending Phase 3 evidence. --- docs/ARCHITECTURE.md | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3e4d3e6..541a6e0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -81,8 +81,46 @@ interlock code never acquires authority. Authority critical sections contain only bounded state checks/copies, the eight-entry cache scan, and short interlock operations—no logging, formatted I/O, allocation, or blocking calls. +## PID-family control loop + +Normal heater control is PID-family closed-loop control; hysteretic bang-bang +exists only as a future Phase 3 validation comparator. `dc_pid` (pinned +dragon-core) provides controller mathematics only. `jj_control` owns setpoint +resolution, `dc_pid` state/config lifecycle, integration decisions, and the raw +request. `jj_interlock` remains the sole safety authority. + +Inside the one ordered control step, under the authority lock: + +1. `jj_interlock_evaluate()` (Stage 1) runs the single set of eligibility + predicates and returns a dominant block reason for diagnostics plus a + controller disposition aggregated over every applicable inhibit + (SUSPEND > HOLD > RUN). Diagnostic priority never decides disposition. Stage 1 + immediately publishes a de-authorized snapshot and retains the evaluation + under a sequence token. +2. `jj_controller_step()` consumes that result. SUSPEND: controller IDLE, state + reset. HOLD: step with integration frozen, state preserved. RUN: step with + integration. It never re-derives eligibility from `jj_inputs_t`. +3. `jj_interlock_authorize()` (Stage 2) consumes the retained evaluation exactly + once, matched by token. It authorizes only RUN with a VALID controller and a + finite 0–100 % request; `allowed_duty_pct` is then the raw request. Any other + combination, a stale/replayed/unknown token, or a revocation or fault clear + between the stages fails cold. + +Controller state is IDLE (no step attempted; ordinary), VALID, or INVALID (step +expected but rejected; reported as `controller_invalid`). `requested_duty_pct` +is the unmodified `dc_pid` output and 0 unless VALID. No product code compares +temperature to target; there is no target-crossing override. + +Only fan-proof-pending HOLDs. Every other inhibit, including NOT_COMMISSIONED +and MANUAL_TARGET_INVALID, SUSPENDs; the classification is an exhaustive switch, +so a new block reason does not compile until classified. Gains, sample interval, +integral/output limits, target-change integral handling, and any product duty +limiter are provisional pending Phase 3 plant evidence. The shipped +configuration has zero gains and the production image is uncommissioned, so it +never produces a heat request. + ## OTA invariant -The portal rejects OTA when heat is requested or active thermal management is +The portal rejects OTA when heat is authorized or active thermal management is required. The same authoritative guard is rerun immediately before boot selection, then project identity is checked. Browser/API controls cannot override interlocks. From 57491206a3e0813f7d95a2da08dd0ece4fde5d50 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Thu, 24 Sep 2026 00:41:46 +0000 Subject: [PATCH 3/3] fix(portal): show controller state and duty; test OTA across staged window Primary HMI: - Render controller state (Idle / Running / Invalid), requested duty, and allowed duty from the existing API fields via a pure heaterPresentation() helper. Idle and Running use ordinary styling; Invalid reuses the fault style and stays visible regardless of the dominant constraint, so HOLD + INVALID is not shown as a healthy hold. Missing or unknown controller data renders as "Unknown", never healthy. - Add operator messages for controller_invalid and control_sequence_invalid. No new fault or block reason. OTA regression coverage (guard unchanged; still requires both !heater_authorized and !thermal_management_required): - After an authorized cycle, Stage 1's de-authorized snapshot keeps thermal_management_required, including with cooldown_required or fault_requires_thermal_management set. - A sweep shows Stage-1 and Stage-2 snapshots always agree on thermal_management_required, so the staged window cannot create an OTA-safe condition the final state lacks. --- components/jj_portal/web/control-client.js | 31 ++++++ components/jj_portal/web/control.html | 8 ++ tests/control-client.test.cjs | 74 ++++++++++++++ tests/jj_interlock_host_test.c | 108 +++++++++++++++++++++ 4 files changed, 221 insertions(+) diff --git a/components/jj_portal/web/control-client.js b/components/jj_portal/web/control-client.js index 32a45eb..f800f8d 100644 --- a/components/jj_portal/web/control-client.js +++ b/components/jj_portal/web/control-client.js @@ -48,10 +48,40 @@ control_no_authority: "Control inhibited: remote controller disconnected", state_refresh_required: "Control inhibited: authoritative state refresh required", automatic_authority_unavailable: "Heating inhibited: Automatic controller unavailable", + controller_invalid: "Heating inhibited: controller could not produce a valid request", + control_sequence_invalid: "Heating inhibited: control evaluation was out of sequence", }; return messages[code] || "Heating inhibited: see advanced diagnostics"; } + const CONTROL_LOOP_LABELS = { + idle: {text: "Idle", className: ""}, + valid: {text: "Running", className: ""}, + invalid: {text: "Invalid: controller failure", className: "fault"}, + }; + + function dutyText(value) { + return typeof value === "number" && Number.isFinite(value) + ? value.toFixed(1) + " %" : "—"; + } + + /* + * Primary heater presentation. Controller state is shown on its own so an + * INVALID controller stays visible whatever the dominant constraint is. + */ + function heaterPresentation(heater) { + const h = heater || {}; + const loop = Object.prototype.hasOwnProperty.call(CONTROL_LOOP_LABELS, h.control_loop) + ? CONTROL_LOOP_LABELS[h.control_loop] + : {text: "Unknown", className: "inhibit"}; + return { + controllerText: loop.text, + controllerClass: loop.className, + requestedDuty: dutyText(h.requested_duty_percent), + allowedDuty: dutyText(h.allowed_duty_percent), + }; + } + function mutationErrorMessage(code) { if (code === "state_revision_conflict") return "Authoritative state changed. Refresh and verify the current result; the earlier request may have completed."; @@ -199,6 +229,7 @@ CONTROL_LOSS_ERRORS, controllerLabel, constraintMessage, + heaterPresentation, mutationErrorMessage, }; }); diff --git a/components/jj_portal/web/control.html b/components/jj_portal/web/control.html index a27f006..ce0c752 100644 --- a/components/jj_portal/web/control.html +++ b/components/jj_portal/web/control.html @@ -32,8 +32,11 @@

Off · None

Configured target
—
Effective target
—
+
Controller
—
Heater request
—
+
Requested duty
—
Heater allowed
—
+
Allowed duty
—
Heater delivered
—
Thermal handling
—
Health / fault
—
@@ -83,8 +86,13 @@

Advanced control details

state.dominant_constraint); $("configured").textContent = state.configured_target_c + " °C"; $("effective").textContent = state.effective_target_c == null ? "—" : state.effective_target_c + " °C"; + const heater = JumpJetControl.heaterPresentation(state.heater); + $("controller").textContent = heater.controllerText; + $("controller").className = heater.controllerClass; $("requested").textContent = state.heater.requested ? "On" : "Off"; + $("requested_duty").textContent = heater.requestedDuty; $("allowed").textContent = state.heater.allowed ? "On" : "Off"; + $("allowed_duty").textContent = heater.allowedDuty; $("delivered").textContent = state.heater.delivered ? "On" : "Off"; $("thermal").textContent = cap(state.thermal_state); $("health").textContent = state.safety.fault_latched ? "Fault: " + state.safety.fault : state.safety.health; diff --git a/tests/control-client.test.cjs b/tests/control-client.test.cjs index 6552739..04682ec 100644 --- a/tests/control-client.test.cjs +++ b/tests/control-client.test.cjs @@ -10,6 +10,7 @@ const { ControlError, controllerLabel, constraintMessage, + heaterPresentation, mutationErrorMessage, } = require("../components/jj_portal/web/control-client.js"); @@ -183,3 +184,76 @@ test("primary constraints are operator-readable while unknown codes stay generic assert.equal(constraintMessage("future_internal_code"), "Heating inhibited: see advanced diagnostics"); }); + +test("controller-state constraints have operator-readable messages", () => { + assert.equal(constraintMessage("controller_invalid"), + "Heating inhibited: controller could not produce a valid request"); + assert.equal(constraintMessage("control_sequence_invalid"), + "Heating inhibited: control evaluation was out of sequence"); +}); + +test("idle controller renders as an ordinary inactive state", () => { + const view = heaterPresentation({control_loop: "idle", + requested_duty_percent: 0, allowed_duty_percent: 0}); + assert.equal(view.controllerText, "Idle"); + assert.equal(view.controllerClass, ""); + assert.equal(view.requestedDuty, "0.0 %"); + assert.equal(view.allowedDuty, "0.0 %"); +}); + +test("valid controller renders normally with requested and allowed duty", () => { + const view = heaterPresentation({control_loop: "valid", allowed: true, + requested_duty_percent: 37.5, allowed_duty_percent: 37.5}); + assert.equal(view.controllerText, "Running"); + assert.equal(view.controllerClass, ""); + assert.equal(view.requestedDuty, "37.5 %"); + assert.equal(view.allowedDuty, "37.5 %"); +}); + +test("invalid controller renders as a distinct failure", () => { + const view = heaterPresentation({control_loop: "invalid", allowed: false, + requested_duty_percent: 0, allowed_duty_percent: 0}); + assert.equal(view.controllerText, "Invalid: controller failure"); + assert.equal(view.controllerClass, "fault"); + assert.notEqual(view.controllerClass, heaterPresentation({control_loop: "idle"}).controllerClass); +}); + +test("HOLD with an invalid controller still exposes INVALID", () => { + const holdInvalid = {dominant_constraint: "fan_proof_pending", + heater: {control_loop: "invalid", allowed: false, + requested_duty_percent: 0, allowed_duty_percent: 0}}; + assert.equal(constraintMessage(holdInvalid.dominant_constraint), + "Heating inhibited: airflow is not proven"); + const view = heaterPresentation(holdInvalid.heater); + assert.equal(view.controllerText, "Invalid: controller failure"); + assert.equal(view.controllerClass, "fault"); +}); + +test("HOLD request stays visible while allowed duty is zero", () => { + const view = heaterPresentation({control_loop: "valid", allowed: false, + requested_duty_percent: 42, allowed_duty_percent: 0}); + assert.equal(view.requestedDuty, "42.0 %"); + assert.equal(view.allowedDuty, "0.0 %"); +}); + +test("missing or unknown controller data never renders as healthy", () => { + for (const heater of [undefined, {}, {control_loop: "future"}, + {control_loop: "valid", requested_duty_percent: NaN}]) { + const view = heaterPresentation(heater); + if (!heater || heater.control_loop !== "valid") { + assert.equal(view.controllerText, "Unknown"); + assert.equal(view.controllerClass, "inhibit"); + } + if (!heater || !Number.isFinite(heater.requested_duty_percent)) + assert.equal(view.requestedDuty, "—"); + } +}); + +test("primary panel wires controller state and both duties", () => { + const page = fs.readFileSync(path.join( + __dirname, "../components/jj_portal/web/control.html"), "utf8"); + for (const id of ["controller", "requested_duty", "allowed_duty"]) + assert.match(page, new RegExp(`
`)); + assert.match(page, /JumpJetControl\.heaterPresentation\(state\.heater\)/); + assert.match(page, /\$\("controller"\)\.className = heater\.controllerClass/); +}); diff --git a/tests/jj_interlock_host_test.c b/tests/jj_interlock_host_test.c index cd89f37..165e26a 100644 --- a/tests/jj_interlock_host_test.c +++ b/tests/jj_interlock_host_test.c @@ -1,5 +1,6 @@ #include "jj_interlock.h" #include +#include #include #include #include @@ -488,6 +489,111 @@ static void test_stage_two_consumes_only_its_own_stage_one(void) CHECK(jj_interlock_snapshot(&state).allowed_duty_pct == 0.0f); } + +/* + * Mirrors jj_portal's thermal_state_guard(): "unauthorized" alone is not + * "thermally safe for OTA". check_ota_contract.sh pins the portal expression. + */ +static bool ota_guard_allows(jj_outputs_t output) +{ + return !output.heater_authorized && !output.thermal_management_required; +} + +/* Run one authorized cycle, then Stage 1 only; return the window snapshot. */ +static jj_outputs_t window_after_heating(jj_interlock_t *state, + const jj_inputs_t *next) +{ + jj_interlock_init(state); + const jj_inputs_t heating = nominal(JJ_MODE_MANUAL); + CHECK(cycle(state, &heating).heater_authorized); + CHECK(!ota_guard_allows(jj_interlock_snapshot(state))); + (void)jj_interlock_evaluate(state, next); + return jj_interlock_snapshot(state); +} + +static void test_stage_one_window_keeps_ota_blocked(void) +{ + jj_interlock_t state; + + /* A: still heating-eligible; Stage 1 withdraws authorization only. */ + jj_inputs_t input = nominal(JJ_MODE_MANUAL); + jj_outputs_t window = window_after_heating(&state, &input); + CHECK(!window.heater_authorized); + CHECK(window.allowed_duty_pct == 0.0f); + CHECK(window.thermal_management_required); + CHECK(!ota_guard_allows(window)); + + /* B: cooldown required going into Stage 1, in any mode. */ + const jj_mode_t modes[] = {JJ_MODE_MANUAL, JJ_MODE_OFF, JJ_MODE_AUTOMATIC}; + for (size_t m = 0; m < 3; ++m) { + input = nominal(modes[m]); + input.cooldown_required = true; + window = window_after_heating(&state, &input); + CHECK(!window.heater_authorized); + CHECK(window.thermal_management_required); + CHECK(!ota_guard_allows(window)); + } + + /* C: a fault requiring thermal management going into Stage 1. */ + for (size_t m = 0; m < 3; ++m) { + input = nominal(modes[m]); + input.fault_requires_thermal_management = true; + window = window_after_heating(&state, &input); + CHECK(!window.heater_authorized); + CHECK(window.thermal_management_required); + CHECK(!ota_guard_allows(window)); + } +} + +static void test_stage_one_and_two_agree_on_thermal_management(void) +{ + /* + * D: Stage 2 may change authorization but never the thermal-management + * condition, so the window cannot open an OTA path the final state lacks. + */ + const jj_mode_t modes[] = {JJ_MODE_OFF, JJ_MODE_MANUAL, JJ_MODE_AUTOMATIC}; + const jj_fan_proof_t fans[] = {JJ_FAN_PROOF_UNAVAILABLE, JJ_FAN_PROOF_PENDING, + JJ_FAN_PROOF_PROVEN, JJ_FAN_PROOF_FAILED}; + const jj_controller_state_t controllers[] = { + JJ_CONTROLLER_IDLE, JJ_CONTROLLER_VALID, JJ_CONTROLLER_INVALID}; + int cases = 0; + for (int prev = 0; prev < 2; ++prev) + for (size_t m = 0; m < 3; ++m) + for (size_t f = 0; f < 4; ++f) + for (int cooldown = 0; cooldown < 2; ++cooldown) + for (int fault_tm = 0; fault_tm < 2; ++fault_tm) + for (int authority = 0; authority < 2; ++authority) + for (int overtemp = 0; overtemp < 2; ++overtemp) + for (size_t c = 0; c < 3; ++c) { + jj_interlock_t state; + jj_interlock_init(&state); + if (prev) { + const jj_inputs_t heating = nominal(JJ_MODE_MANUAL); + CHECK(cycle(&state, &heating).heater_authorized); + } + jj_inputs_t input = nominal(modes[m]); + input.fan_proof = fans[f]; + input.cooldown_required = cooldown; + input.fault_requires_thermal_management = fault_tm; + input.manual_demand_authorized = authority; + input.overtemperature_detected = overtemp; + const jj_eligibility_t e = jj_interlock_evaluate(&state, &input); + const jj_outputs_t window = jj_interlock_snapshot(&state); + const jj_controller_report_t report = { + .eligibility_sequence = e.sequence, + .state = controllers[c], + .requested_duty_pct = 50.0f, + }; + const jj_outputs_t final = jj_interlock_authorize(&state, &report); + CHECK(!window.heater_authorized); + CHECK(window.thermal_management_required == final.thermal_management_required); + if (ota_guard_allows(window)) CHECK(ota_guard_allows(final)); + if (cooldown || fault_tm) CHECK(!ota_guard_allows(window)); + ++cases; + } + CHECK(cases == 2 * 3 * 4 * 2 * 2 * 2 * 2 * 3); +} + int main(void) { test_boot_defaults_and_modes(); @@ -503,6 +609,8 @@ int main(void) test_authorization_requires_valid_controller(); test_safety_never_rewrites_valid_request(); test_stage_two_consumes_only_its_own_stage_one(); + test_stage_one_window_keeps_ota_blocked(); + test_stage_one_and_two_agree_on_thermal_management(); puts("jj_interlock_host_test: PASS"); return 0; }