From 2a33d96cc43185fe41db68e65d9db17ecf8f28a0 Mon Sep 17 00:00:00 2001 From: Daniel Berlin Date: Sun, 26 Jul 2026 18:44:20 -0400 Subject: [PATCH 1/4] Add adaptive Smith model and identifier --- controller/smith_predictor.py | 1115 +++++++++++++++++++++++++++++++++ tests/__init__.py | 0 tests/test_smith_predictor.py | 669 ++++++++++++++++++++ 3 files changed, 1784 insertions(+) create mode 100644 controller/smith_predictor.py create mode 100644 tests/__init__.py create mode 100644 tests/test_smith_predictor.py diff --git a/controller/smith_predictor.py b/controller/smith_predictor.py new file mode 100644 index 000000000..6ef18dea3 --- /dev/null +++ b/controller/smith_predictor.py @@ -0,0 +1,1115 @@ +"""Exact first-order-plus-dead-time Smith predictor primitives.""" + +import math +from collections import deque +from dataclasses import dataclass +from typing import Callable, Deque, List, Optional, Sequence, Tuple + + +MIN_GAIN_F = 50.0 +MAX_GAIN_F = 2000.0 +MIN_TAU_SECONDS = 300.0 +MAX_TAU_SECONDS = 20000.0 +MIN_PREDICTED_F = -100.0 +MAX_PREDICTED_F = 1200.0 + +MAX_DELAY_SECONDS = 120.0 +DELAY_CANDIDATE_STEP_SECONDS = 5.0 +DELAY_CANDIDATE_GRID = tuple( + float(delay) + for delay in range( + 0, int(MAX_DELAY_SECONDS) + 1, int(DELAY_CANDIDATE_STEP_SECONDS) + ) +) +RLS_FORGETTING_FACTOR = 0.9995 +MIN_ACCEPTED_SECONDS = 3600.0 +MIN_ACCEPTED_OBSERVATIONS = 240 +MIN_DUTY_STDDEV = 0.05 +MIN_DUTY_TRANSITION = 0.05 +MIN_TRANSITION_SECONDS = 60.0 +MIN_TEMPERATURE_SPAN_F = 15.0 +MAX_CONFIRMATION_ESTIMATES = 20 +MIN_DELAY_RESIDUAL_MARGIN = 0.10 +MAX_GAIN_RELATIVE_STANDARD_ERROR = 0.20 +MAX_TAU_RELATIVE_STANDARD_ERROR = 0.25 + + +@dataclass(frozen=True) +class FOPDTModel: + gain_f_per_duty: float + tau_seconds: float + theta_seconds: float + confidence: float + residual: float + observations: int + revision: int = 0 + + def validate(self): + values = ( + self.gain_f_per_duty, + self.tau_seconds, + self.theta_seconds, + self.confidence, + self.residual, + ) + if not all(math.isfinite(value) for value in values): + raise ValueError("FOPDT model values must be finite") + if not MIN_GAIN_F <= self.gain_f_per_duty <= MAX_GAIN_F: + raise ValueError("gain is outside physical bounds") + if not MIN_TAU_SECONDS <= self.tau_seconds <= MAX_TAU_SECONDS: + raise ValueError("tau is outside physical bounds") + if not 0.0 <= self.theta_seconds <= 120.0: + raise ValueError("theta is outside candidate bounds") + if not 0.0 <= self.confidence <= 1.0 or self.residual < 0.0: + raise ValueError("confidence or residual is invalid") + if self.observations < 0 or self.revision < 0: + raise ValueError("counts must be non-negative") + + +@dataclass(frozen=True) +class _DutyCommand: + timestamp: float + duty: float + identification_allowed: bool + + +class DutyHistory: + """Piecewise-constant duty commands with their identification eligibility.""" + + def __init__(self, max_age_seconds=300.0): + if not self._is_finite(max_age_seconds) or max_age_seconds < 0.0: + raise ValueError("max_age_seconds must be a non-negative finite number") + self.max_age_seconds = float(max_age_seconds) + self._commands: List[_DutyCommand] = [] + + @property + def command_count(self): + return len(self._commands) + + def record(self, timestamp, duty, identification_allowed): + if not self._is_finite(timestamp) or not self._is_finite(duty): + raise ValueError("duty history timestamp and duty must be finite") + if not 0.0 <= duty <= 1.0: + raise ValueError("duty must be between zero and one") + + command = _DutyCommand( + float(timestamp), float(duty), bool(identification_allowed) + ) + index = len(self._commands) + while index and self._commands[index - 1].timestamp > command.timestamp: + index -= 1 + + if index and self._commands[index - 1].timestamp == command.timestamp: + self._commands[index - 1] = command + index -= 1 + else: + self._commands.insert(index, command) + + self._collapse_adjacent(index) + + def value_at(self, timestamp): + self._validate_time(timestamp) + if not self._commands: + return 0.0 + + value = self._commands[0].duty + for command in self._commands: + if command.timestamp > timestamp: + break + value = command.duty + return value + + def average(self, start, end, delay_seconds=0.0): + self._validate_interval(start, end) + if not self._is_finite(delay_seconds): + raise ValueError("delay_seconds must be finite") + + shifted_start = float(start) - float(delay_seconds) + shifted_end = float(end) - float(delay_seconds) + if shifted_end == shifted_start: + return self.value_at(shifted_start) + if not self._commands: + return 0.0 + + duty = self.value_at(shifted_start) + cursor = shifted_start + integral = 0.0 + for command in self._commands: + if command.timestamp <= cursor: + continue + if command.timestamp >= shifted_end: + break + integral += duty * (command.timestamp - cursor) + duty = command.duty + cursor = command.timestamp + integral += duty * (shifted_end - cursor) + return integral / (shifted_end - shifted_start) + + def interval_allowed(self, start, end): + self._validate_interval(start, end) + if not self._commands: + return False + + allowed = self._allowed_at(start) + if not allowed: + return False + if start == end: + return True + + for command in self._commands: + if command.timestamp <= start: + continue + if command.timestamp >= end: + break + if not command.identification_allowed: + return False + return True + + def prune(self, now): + self._validate_time(now) + cutoff = float(now) - self.max_age_seconds + first_at_or_after_cutoff = 0 + while ( + first_at_or_after_cutoff < len(self._commands) + and self._commands[first_at_or_after_cutoff].timestamp < cutoff + ): + first_at_or_after_cutoff += 1 + + if first_at_or_after_cutoff > 0: + del self._commands[: first_at_or_after_cutoff - 1] + + def _allowed_at(self, timestamp): + allowed = self._commands[0].identification_allowed + for command in self._commands: + if command.timestamp > timestamp: + break + allowed = command.identification_allowed + return allowed + + def _collapse_adjacent(self, index): + if index > 0 and self._same_command_state( + self._commands[index - 1], self._commands[index] + ): + del self._commands[index] + index -= 1 + if index + 1 < len(self._commands) and self._same_command_state( + self._commands[index], self._commands[index + 1] + ): + del self._commands[index + 1] + + @staticmethod + def _same_command_state(first, second): + return ( + first.duty == second.duty + and first.identification_allowed == second.identification_allowed + ) + + @staticmethod + def _is_finite(value): + try: + return math.isfinite(value) + except TypeError: + return False + + def _validate_time(self, timestamp): + if not self._is_finite(timestamp): + raise ValueError("timestamp must be finite") + + def _validate_interval(self, start, end): + self._validate_time(start) + self._validate_time(end) + if end < start: + raise ValueError("interval end must not precede start") + + +def _advance_state(state, duty, duration, model): + equilibrium = model.gain_f_per_duty * duty + return equilibrium + (state - equilibrium) * math.exp(-duration / model.tau_seconds) + + +class SmithPredictor: + """Applies a validated FOPDT correction to native-unit temperature samples.""" + + def __init__(self, units: str, clock: Callable[[], float]) -> None: + if units not in ("F", "C"): + raise ValueError("units must be F or C") + if not callable(clock): + raise TypeError("clock must be callable") + + self.units = units + self._clock = clock + self._history = DutyHistory() + self._model: Optional[FOPDTModel] = None + self._last_time: Optional[float] = None + self._last_measured_f: Optional[float] = None + self._undelayed_state: Optional[float] = None + self._delayed_state: Optional[float] = None + self._consecutive_implausible_residuals = 0 + self._prediction_active = False + + def record_output( + self, + duty: float, + identification_allowed: bool = True, + timestamp: Optional[float] = None, + ) -> None: + if timestamp is None: + timestamp = self._clock() + self._history._validate_time(timestamp) + command_time = float(timestamp) + if ( + self._prediction_active + and self._last_time is not None + and command_time < self._last_time + ): + raise ValueError("output timestamp precedes active predictor state") + self._history.record(command_time, duty, identification_allowed) + if self._model is None or not self._prediction_active: + self._history.prune(command_time) + + def set_model(self, model: FOPDTModel) -> None: + model.validate() + now = float(self._clock()) + self._history._validate_time(now) + self._history.prune(now) + state = model.gain_f_per_duty * self._history.value_at(now) + + self._model = model + self._last_time = now + self._last_measured_f = None + self._undelayed_state = state + self._delayed_state = state + self._consecutive_implausible_residuals = 0 + self._prediction_active = True + + def clear_dynamic_state(self) -> None: + self._last_time = None + self._last_measured_f = None + self._undelayed_state = None + self._delayed_state = None + self._consecutive_implausible_residuals = 0 + self._prediction_active = False + + def update( + self, measured_temperature: float, timestamp: Optional[float] = None + ) -> float: + if not DutyHistory._is_finite(measured_temperature): + self._deactivate(timestamp) + return measured_temperature + if timestamp is None: + timestamp = self._clock() + if not DutyHistory._is_finite(timestamp): + self._deactivate() + return measured_temperature + + now = float(timestamp) + measured_f = self._to_fahrenheit(float(measured_temperature)) + if not DutyHistory._is_finite(measured_f): + self._deactivate(now) + return measured_temperature + if self._model is None: + return measured_temperature + if not self._prediction_active and not self._reactivate(now): + return measured_temperature + if ( + self._last_time is None + or self._undelayed_state is None + or self._delayed_state is None + or now < self._last_time + ): + self._deactivate(now) + return measured_temperature + + previous_time = self._last_time + previous_delayed_state = self._delayed_state + try: + undelayed_state = self._advance_branch( + self._undelayed_state, previous_time, now, 0.0 + ) + delayed_state = self._advance_branch( + self._delayed_state, previous_time, now, self._model.theta_seconds + ) + except (OverflowError, ValueError): + self._deactivate(now) + return measured_temperature + + self._last_time = now + self._undelayed_state = undelayed_state + self._delayed_state = delayed_state + if not self._states_are_safe(undelayed_state, delayed_state): + self._deactivate(now) + return measured_temperature + + if self._last_measured_f is not None and now > previous_time: + if self._history.interval_allowed( + previous_time - self._model.theta_seconds, + now - self._model.theta_seconds, + ): + residual = ( + measured_f + - self._last_measured_f + - (delayed_state - previous_delayed_state) + ) + if not DutyHistory._is_finite(residual): + self._deactivate(now) + return measured_temperature + if abs(residual) > 100.0: + self._consecutive_implausible_residuals += 1 + else: + self._consecutive_implausible_residuals = 0 + if self._consecutive_implausible_residuals >= 4: + self._deactivate(now) + return measured_temperature + else: + self._consecutive_implausible_residuals = 0 + self._last_measured_f = measured_f + + correction_f = undelayed_state - delayed_state + predicted_f = measured_f + correction_f + if not self._is_safe_prediction(correction_f, predicted_f): + self._deactivate(now) + return measured_temperature + + predicted_temperature = self._from_fahrenheit(predicted_f) + if not DutyHistory._is_finite(predicted_temperature): + self._deactivate(now) + return measured_temperature + self._history.prune(now) + return predicted_temperature + + def status(self): + return { + "prediction_active": self._prediction_active, + "model_revision": None if self._model is None else self._model.revision, + "consecutive_implausible_residuals": self._consecutive_implausible_residuals, + } + + def _advance_branch(self, state, start, end, delay_seconds): + duty = self._history.value_at(start - delay_seconds) + cursor = start + for command in self._history._commands: + change_time = command.timestamp + delay_seconds + if change_time <= cursor: + continue + if change_time >= end: + break + state = _advance_state(state, duty, change_time - cursor, self._model) + duty = command.duty + cursor = change_time + return _advance_state(state, duty, end - cursor, self._model) + + def _states_are_safe(self, undelayed_state, delayed_state): + return all( + DutyHistory._is_finite(state) + and MIN_PREDICTED_F <= state <= MAX_PREDICTED_F + for state in (undelayed_state, delayed_state) + ) + + def _is_safe_prediction(self, correction_f, predicted_f): + return all( + DutyHistory._is_finite(value) + and MIN_PREDICTED_F <= value <= MAX_PREDICTED_F + for value in (correction_f, predicted_f) + ) + + def _deactivate(self, timestamp=None): + previous_time = self._last_time + self._prediction_active = False + self._last_time = None + self._last_measured_f = None + self._consecutive_implausible_residuals = 0 + state, initialized_at = self._safe_reinitialization( + timestamp, previous_time + ) + self._undelayed_state = state + self._delayed_state = state + self._last_time = initialized_at + + def _reactivate(self, now): + if ( + self._model is None + or self._last_time is None + or now < self._last_time + or not self._states_are_safe( + self._undelayed_state, self._delayed_state + ) + ): + self._deactivate(now) + return False + try: + self._model.validate() + except ValueError: + self._deactivate(now) + return False + self._prediction_active = True + return True + + def _safe_reinitialization(self, timestamp, previous_time): + now = self._reinitialization_time(timestamp, previous_time) + if self._model is None or now is None: + return 0.0, None + try: + self._model.validate() + state = self._model.gain_f_per_duty * self._history.value_at(now) + except (TypeError, ValueError, OverflowError): + return 0.0, None + if not DutyHistory._is_finite(state): + return 0.0, None + + state = min(max(float(state), MIN_PREDICTED_F), MAX_PREDICTED_F) + if not self._states_are_safe(state, state): + return 0.0, None + self._history.prune(now) + return state, now + + def _reinitialization_time(self, timestamp, previous_time): + candidates = [timestamp] + try: + candidates.append(self._clock()) + except (TypeError, ValueError, OverflowError): + pass + candidates.append(previous_time) + for candidate in candidates: + if DutyHistory._is_finite(candidate): + return float(candidate) + return None + + def _to_fahrenheit(self, temperature): + if self.units == "F": + return temperature + return temperature * 9.0 / 5.0 + 32.0 + + def _from_fahrenheit(self, temperature): + if self.units == "F": + return temperature + return (temperature - 32.0) * 5.0 / 9.0 + + +@dataclass +class _RLSCandidate: + delay_seconds: float + coefficients: list + covariance: list + residual_ewma: Optional[float] = None + valid_updates: int = 0 + + @classmethod + def create(cls, delay_seconds): + return cls( + float(delay_seconds), + [0.0, 0.0, 0.0], + [[1e6, 0.0, 0.0], [0.0, 1e6, 0.0], [0.0, 0.0, 1e6]], + ) + + +def _dot(left, right): + return sum(a * b for a, b in zip(left, right)) + + +def _matrix_vector(matrix, vector): + return [_dot(row, vector) for row in matrix] + + +class AdaptiveFOPDTIdentifier: + """Identifies a bounded FOPDT model from permitted applied-duty samples.""" + + def __init__( + self, + units: str, + clock: Callable[[], float], + delay_candidates: Optional[Sequence[float]] = None, + ) -> None: + if units not in ("F", "C"): + raise ValueError("units must be F or C") + if not callable(clock): + raise TypeError("clock must be callable") + + self.units = units + self._clock = clock + self._delay_candidates = self._validate_delay_candidates(delay_candidates) + self._history = DutyHistory( + max_age_seconds=2.0 * MAX_DELAY_SECONDS + MIN_TRANSITION_SECONDS + ) + self._candidates = self._fresh_candidates() + self._last_time: Optional[float] = None + self._last_temperature_f: Optional[float] = None + self._temperature_reference_f: Optional[float] = None + self._last_output_time: Optional[float] = None + self._transition_started_at: Optional[float] = None + self._sustained_transition = False + self._accepted_seconds = 0.0 + self._accepted_observations = 0 + self._duty_count = 0 + self._duty_mean = 0.0 + self._duty_m2 = 0.0 + self._temperature_min_f: Optional[float] = None + self._temperature_max_f: Optional[float] = None + self._confirmations: Deque[ + Tuple[float, float, float, float, float, float, float] + ] = deque(maxlen=MAX_CONFIRMATION_ESTIMATES) + self._trusted_model: Optional[FOPDTModel] = None + + @property + def trusted_model(self) -> Optional[FOPDTModel]: + return self._trusted_model + + def record_output( + self, + duty: float, + identification_allowed: bool = True, + timestamp: Optional[float] = None, + ) -> None: + if timestamp is None: + timestamp = self._clock() + self._history._validate_time(timestamp) + if not DutyHistory._is_finite(duty) or not 0.0 <= duty <= 1.0: + raise ValueError("duty must be between zero and one") + now = float(timestamp) + if self._last_output_time is not None and now < self._last_output_time: + raise ValueError("output timestamp precedes identifier output history") + + self._mark_sustained_transition(now) + self._history.record(now, float(duty), identification_allowed) + self._last_output_time = now + if not self._sustained_transition: + self._transition_started_at = self._pending_transition_start() + self._history.prune(now) + + def observe( + self, temperature: float, timestamp: Optional[float] = None + ) -> Optional[FOPDTModel]: + if not DutyHistory._is_finite(temperature): + return None + if timestamp is None: + timestamp = self._clock() + if not DutyHistory._is_finite(timestamp): + return None + + now = float(timestamp) + temperature_f = self._to_fahrenheit(float(temperature)) + if not DutyHistory._is_finite(temperature_f): + return None + if self._last_time is None: + self._set_baseline(now, temperature_f) + self._history.prune(now) + return None + if now <= self._last_time: + self._set_baseline(now, temperature_f) + self._confirmations.clear() + self._history.prune(now) + return None + + previous_time = self._last_time + previous_temperature_f = self._last_temperature_f + self._mark_sustained_transition(now) + published_model = None + if ( + previous_temperature_f is not None + and self._is_acceptable_interval(previous_time, now) + ): + published_model = self._accept_observation( + previous_time, now, previous_temperature_f, temperature_f + ) + else: + self._confirmations.clear() + self._last_time = now + self._last_temperature_f = temperature_f + self._history.prune(now) + return published_model + + def restore_trusted_model(self, model: FOPDTModel) -> None: + model.validate() + self._trusted_model = model + self._candidates = self._fresh_candidates() + self._last_time = None + self._last_temperature_f = None + self._temperature_reference_f = None + self._transition_started_at = None + self._sustained_transition = False + self._accepted_seconds = 0.0 + self._accepted_observations = 0 + self._duty_count = 0 + self._duty_mean = 0.0 + self._duty_m2 = 0.0 + self._temperature_min_f = None + self._temperature_max_f = None + self._confirmations.clear() + + def status(self) -> dict: + estimate, delay_margin, eligible_candidates = self._select_candidate() + return { + "trusted": self._trusted_model is not None, + "model_revision": ( + None if self._trusted_model is None else self._trusted_model.revision + ), + "accepted_seconds": self._accepted_seconds, + "accepted_observations": self._accepted_observations, + "duty_stddev": self._duty_stddev(), + "temperature_span_f": self._temperature_span_f(), + "sustained_transition": self._sustained_transition, + "candidate_count": len(self._candidates), + "eligible_candidates": eligible_candidates, + "delay_residual_margin": delay_margin, + "winning_delay_seconds": None if estimate is None else estimate[2], + "gain_relative_standard_error": ( + None if estimate is None else estimate[4] + ), + "tau_relative_standard_error": None if estimate is None else estimate[5], + "confirmation_count": len(self._confirmations), + } + + @staticmethod + def _validate_delay_candidates( + delay_candidates: Optional[Sequence[float]], + ) -> Tuple[float, ...]: + if delay_candidates is None: + return DELAY_CANDIDATE_GRID + try: + values = tuple(float(delay) for delay in delay_candidates) + except TypeError as error: + raise TypeError("delay_candidates must be a finite sequence") from error + if not values: + raise ValueError("delay_candidates must not be empty") + if len(values) > len(DELAY_CANDIDATE_GRID): + raise ValueError("delay candidates exceed the fixed candidate bank") + if len(set(values)) != len(values): + raise ValueError("delay candidates must be unique") + if not all(delay in DELAY_CANDIDATE_GRID for delay in values): + raise ValueError("delay candidates must be members of the fixed grid") + return tuple(sorted(values)) + + def _fresh_candidates(self): + return [_RLSCandidate.create(delay) for delay in self._delay_candidates] + + def _set_baseline(self, timestamp: float, temperature_f: float) -> None: + self._last_time = timestamp + self._last_temperature_f = temperature_f + if self._temperature_reference_f is None: + self._temperature_reference_f = temperature_f + + def _mark_sustained_transition(self, now: float) -> None: + if ( + not self._sustained_transition + and self._transition_started_at is not None + and now - self._transition_started_at >= MIN_TRANSITION_SECONDS + ): + self._sustained_transition = True + + def _pending_transition_start(self) -> Optional[float]: + commands = self._history._commands + if len(commands) < 2: + return None + previous = commands[-2] + current = commands[-1] + if ( + previous.identification_allowed + and current.identification_allowed + and abs(current.duty - previous.duty) >= MIN_DUTY_TRANSITION + ): + return current.timestamp + return None + + def _is_acceptable_interval(self, start: float, end: float) -> bool: + duration = end - start + if ( + not DutyHistory._is_finite(duration) + or duration <= 0.0 + or duration + > self._history.max_age_seconds - max(self._delay_candidates) + ): + return False + return self._history.interval_allowed(start, end) + + def _accept_observation( + self, + previous_time: float, + now: float, + previous_temperature_f: float, + temperature_f: float, + ) -> Optional[FOPDTModel]: + duration = now - previous_time + applied_duty = self._history.average(previous_time, now) + if not DutyHistory._is_finite(applied_duty): + return None + + self._accepted_seconds += duration + self._accepted_observations += 1 + self._update_duty_statistics(applied_duty) + interval_min_f = min(previous_temperature_f, temperature_f) + interval_max_f = max(previous_temperature_f, temperature_f) + self._temperature_min_f = ( + interval_min_f + if self._temperature_min_f is None + else min(self._temperature_min_f, interval_min_f) + ) + self._temperature_max_f = ( + interval_max_f + if self._temperature_max_f is None + else max(self._temperature_max_f, interval_max_f) + ) + for index in range(len(self._candidates)): + self._update_candidate( + index, + previous_time, + now, + previous_temperature_f, + temperature_f, + ) + return self._consider_estimate() + + def _update_duty_statistics(self, duty: float) -> None: + self._duty_count += 1 + delta = duty - self._duty_mean + self._duty_mean += delta / self._duty_count + self._duty_m2 += delta * (duty - self._duty_mean) + + def _update_candidate( + self, + index: int, + previous_time: float, + now: float, + previous_temperature_f: float, + temperature_f: float, + ) -> None: + candidate = self._candidates[index] + try: + if not self._history.interval_allowed( + previous_time - candidate.delay_seconds, + now - candidate.delay_seconds, + ): + return + delayed_average_duty = self._history.average( + previous_time, now, candidate.delay_seconds + ) + duration = now - previous_time + if ( + self._temperature_reference_f is None + or not self._candidate_matrix_is_finite(candidate.covariance) + or not all( + DutyHistory._is_finite(value) + for value in ( + duration, + previous_temperature_f, + temperature_f, + delayed_average_duty, + ) + ) + or duration <= 0.0 + ): + raise ValueError("candidate update is non-finite") + + z = ( + previous_temperature_f - self._temperature_reference_f + ) / 500.0 + phi = [1.0, z, delayed_average_duty] + y = (temperature_f - previous_temperature_f) / duration + if not all(DutyHistory._is_finite(value) for value in phi + [y]): + raise ValueError("candidate regression values are non-finite") + + p_phi = _matrix_vector(candidate.covariance, phi) + denominator = RLS_FORGETTING_FACTOR + _dot(phi, p_phi) + if ( + not all(DutyHistory._is_finite(value) for value in p_phi) + or not DutyHistory._is_finite(denominator) + or denominator <= 1e-12 + ): + raise ValueError("candidate covariance is numerically invalid") + gain_vector = [value / denominator for value in p_phi] + error = y - _dot(phi, candidate.coefficients) + if not all( + DutyHistory._is_finite(value) for value in gain_vector + [error] + ): + raise ValueError("candidate gain is non-finite") + + coefficients = [ + value + gain_component * error + for value, gain_component in zip( + candidate.coefficients, gain_vector + ) + ] + covariance = [ + [ + ( + candidate.covariance[row][column] + - gain_vector[row] + * sum( + phi[covariance_index] + * candidate.covariance[covariance_index][column] + for covariance_index in range(3) + ) + ) + / RLS_FORGETTING_FACTOR + for column in range(3) + ] + for row in range(3) + ] + squared_error = error * error + residual_ewma = ( + squared_error + if candidate.residual_ewma is None + else 0.98 * candidate.residual_ewma + 0.02 * squared_error + ) + if not ( + all(DutyHistory._is_finite(value) for value in coefficients) + and self._candidate_matrix_is_finite(covariance) + and DutyHistory._is_finite(squared_error) + and DutyHistory._is_finite(residual_ewma) + ): + raise ValueError("candidate update overflowed") + candidate.coefficients = coefficients + candidate.covariance = covariance + candidate.residual_ewma = residual_ewma + candidate.valid_updates += 1 + except (ArithmeticError, OverflowError, TypeError, ValueError): + self._candidates[index] = _RLSCandidate.create(candidate.delay_seconds) + + @staticmethod + def _candidate_matrix_is_finite(matrix) -> bool: + try: + return len(matrix) == 3 and all( + len(row) == 3 + and all(DutyHistory._is_finite(value) for value in row) + for row in matrix + ) + except TypeError: + return False + + def _candidate_estimate( + self, candidate: _RLSCandidate + ) -> Optional[Tuple[float, float, float, float, float, float]]: + if ( + self._temperature_reference_f is None + or candidate.valid_updates < MIN_ACCEPTED_OBSERVATIONS + or candidate.residual_ewma is None + or not all( + DutyHistory._is_finite(value) + for value in candidate.coefficients + [candidate.residual_ewma] + ) + or not self._candidate_matrix_is_finite(candidate.covariance) + or candidate.residual_ewma < 0.0 + ): + return None + + beta_t = candidate.coefficients[1] / 500.0 + beta_u = candidate.coefficients[2] + beta_0 = candidate.coefficients[0] - beta_t * self._temperature_reference_f + if not ( + all(DutyHistory._is_finite(value) for value in (beta_t, beta_u, beta_0)) + and beta_t < 0.0 + ): + return None + try: + tau = -1.0 / beta_t + gain_f = -beta_u / beta_t + offset_f = -beta_0 / beta_t + except (OverflowError, ZeroDivisionError): + return None + if not ( + all(DutyHistory._is_finite(value) for value in (tau, gain_f, offset_f)) + and MIN_GAIN_F <= gain_f <= MAX_GAIN_F + and MIN_TAU_SECONDS <= tau <= MAX_TAU_SECONDS + and 0.0 <= candidate.delay_seconds <= MAX_DELAY_SECONDS + ): + return None + + residual = candidate.residual_ewma + covariance = candidate.covariance + try: + var_beta_t = residual * covariance[1][1] / (500.0 * 500.0) + var_beta_u = residual * covariance[2][2] + cov_beta = residual * covariance[1][2] / 500.0 + var_tau = var_beta_t / (beta_t ** 4) + d_gain_d_beta_t = beta_u / (beta_t ** 2) + d_gain_d_beta_u = -1.0 / beta_t + var_gain = ( + d_gain_d_beta_t ** 2 * var_beta_t + + d_gain_d_beta_u ** 2 * var_beta_u + + 2.0 * d_gain_d_beta_t * d_gain_d_beta_u * cov_beta + ) + except (OverflowError, ZeroDivisionError): + return None + if not ( + all( + DutyHistory._is_finite(value) + for value in ( + var_beta_t, + var_beta_u, + cov_beta, + var_tau, + d_gain_d_beta_t, + d_gain_d_beta_u, + var_gain, + ) + ) + and var_tau >= 0.0 + and var_gain >= 0.0 + ): + return None + gain_relative_standard_error = math.sqrt(var_gain) / gain_f + tau_relative_standard_error = math.sqrt(var_tau) / tau + if not ( + all( + DutyHistory._is_finite(value) + for value in ( + gain_relative_standard_error, + tau_relative_standard_error, + ) + ) + and gain_relative_standard_error <= MAX_GAIN_RELATIVE_STANDARD_ERROR + and tau_relative_standard_error <= MAX_TAU_RELATIVE_STANDARD_ERROR + ): + return None + return ( + gain_f, + tau, + candidate.delay_seconds, + residual, + gain_relative_standard_error, + tau_relative_standard_error, + ) + + def _select_candidate( + self, + ) -> Tuple[ + Optional[Tuple[float, float, float, float, float, float]], float, int + ]: + winner = None + runner_up_residual = None + eligible_candidates = 0 + for candidate in self._candidates: + estimate = self._candidate_estimate(candidate) + if estimate is None: + continue + eligible_candidates += 1 + if winner is None or estimate[3] < winner[3]: + if winner is not None: + runner_up_residual = winner[3] + winner = estimate + elif runner_up_residual is None or estimate[3] < runner_up_residual: + runner_up_residual = estimate[3] + if winner is None or runner_up_residual is None or runner_up_residual <= 0.0: + return winner, 0.0, eligible_candidates + delay_margin = max( + 0.0, (runner_up_residual - winner[3]) / runner_up_residual + ) + return winner, delay_margin, eligible_candidates + + def _consider_estimate(self) -> Optional[FOPDTModel]: + estimate, delay_margin, _ = self._select_candidate() + if ( + estimate is None + or delay_margin < MIN_DELAY_RESIDUAL_MARGIN + or not self._excitation_is_sufficient() + ): + self._confirmations.clear() + return None + + if self._trusted_model is not None and not self._is_material(estimate): + self._confirmations.clear() + return None + if self._confirmations and self._confirmations[0][2] != estimate[2]: + self._confirmations.clear() + self._confirmations.append( + ( + estimate[0], + estimate[1], + estimate[2], + estimate[3], + estimate[4], + estimate[5], + delay_margin, + ) + ) + if not self._confirmation_is_stable(): + return None + + confirmation_count = len(self._confirmations) + confidence = max( + 0.0, + min( + 1.0, + delay_margin / 0.10, + 0.20 / max(estimate[4], 1e-12), + 0.25 / max(estimate[5], 1e-12), + confirmation_count / 20.0, + ), + ) + if self._trusted_model is None: + model = FOPDTModel( + estimate[0], + estimate[1], + estimate[2], + confidence, + estimate[3], + self._accepted_observations, + 1, + ) + else: + model = FOPDTModel( + 0.9 * self._trusted_model.gain_f_per_duty + 0.1 * estimate[0], + 0.9 * self._trusted_model.tau_seconds + 0.1 * estimate[1], + estimate[2], + confidence, + estimate[3], + self._accepted_observations, + self._trusted_model.revision + 1, + ) + try: + model.validate() + except ValueError: + self._confirmations.clear() + return None + self._trusted_model = model + self._confirmations.clear() + return model + + def _excitation_is_sufficient(self) -> bool: + return ( + self._accepted_seconds >= MIN_ACCEPTED_SECONDS + and self._accepted_observations >= MIN_ACCEPTED_OBSERVATIONS + and self._duty_stddev() >= MIN_DUTY_STDDEV + and self._sustained_transition + and self._temperature_span_f() >= MIN_TEMPERATURE_SPAN_F + ) + + def _is_material( + self, estimate: Tuple[float, float, float, float, float, float] + ) -> bool: + model = self._trusted_model + if model is None: + return False + return ( + abs(estimate[0] - model.gain_f_per_duty) / model.gain_f_per_duty + >= 0.05 + or abs(estimate[1] - model.tau_seconds) / model.tau_seconds >= 0.05 + or abs(estimate[2] - model.theta_seconds) >= 5.0 + ) + + def _confirmation_is_stable(self) -> bool: + if len(self._confirmations) < MAX_CONFIRMATION_ESTIMATES: + return False + gains = [estimate[0] for estimate in self._confirmations] + taus = [estimate[1] for estimate in self._confirmations] + delays = [estimate[2] for estimate in self._confirmations] + gain_mean = sum(gains) / len(gains) + tau_mean = sum(taus) / len(taus) + return ( + all(delay == delays[0] for delay in delays) + and (max(gains) - min(gains)) / gain_mean <= 0.05 + and (max(taus) - min(taus)) / tau_mean <= 0.075 + ) + + def _duty_stddev(self) -> float: + if self._duty_count == 0: + return 0.0 + return math.sqrt(max(0.0, self._duty_m2 / self._duty_count)) + + def _temperature_span_f(self) -> float: + if self._temperature_min_f is None or self._temperature_max_f is None: + return 0.0 + return self._temperature_max_f - self._temperature_min_f + + def _to_fahrenheit(self, temperature: float) -> float: + if self.units == "F": + return temperature + return temperature * 9.0 / 5.0 + 32.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_smith_predictor.py b/tests/test_smith_predictor.py new file mode 100644 index 000000000..d4aaeed82 --- /dev/null +++ b/tests/test_smith_predictor.py @@ -0,0 +1,669 @@ +import math +import unittest +from dataclasses import FrozenInstanceError + +from controller.smith_predictor import ( + AdaptiveFOPDTIdentifier, + DutyHistory, + FOPDTModel, + SmithPredictor, +) + + +class FakeClock: + def __init__(self): + self.now = 0.0 + + def __call__(self): + return self.now + + +def model(gain=600.0, tau=300.0, delay=20.0, revision=1): + return FOPDTModel(gain, tau, delay, 0.95, 0.1, 300, revision) + + +def _to_native_temperature(temperature_f, units): + if units == "F": + return temperature_f + return (temperature_f - 32.0) * 5.0 / 9.0 + + +def _command_schedule(duration_seconds): + duties = (0.15, 0.75, 0.30, 0.85, 0.20, 0.65, 0.40, 0.90, 0.10, 0.70) + intervals = (300, 450, 600, 750, 900) + commands = [(0.0, duties[0])] + timestamp = 0 + duty_index = 1 + interval_index = 0 + while timestamp < duration_seconds: + timestamp += intervals[interval_index % len(intervals)] + if timestamp > duration_seconds: + break + commands.append((float(timestamp), duties[duty_index % len(duties)])) + duty_index += 1 + interval_index += 1 + return commands + + +def _feed_synthetic_fopdt( + identifier, + clock, + gain, + tau, + delay, + duration_seconds=26 * 3600, + observation_intervals=(15, 16), +): + ambient_f = 125.0 + commands = _command_schedule(duration_seconds) + current_temperature_f = ambient_f + gain * commands[0][1] + command_index = 0 + delayed_command_index = 0 + interval_index = 0 + next_observation = observation_intervals[0] + + identifier.record_output(commands[0][1]) + identifier.observe(_to_native_temperature(current_temperature_f, identifier.units)) + one_second_decay = math.exp(-1.0 / tau) + for second in range(1, duration_seconds + 1): + delayed_time = float(second - 1) - delay + while ( + delayed_command_index + 1 < len(commands) + and commands[delayed_command_index + 1][0] <= delayed_time + ): + delayed_command_index += 1 + equilibrium_f = ambient_f + gain * commands[delayed_command_index][1] + current_temperature_f = equilibrium_f + ( + current_temperature_f - equilibrium_f + ) * one_second_decay + + clock.now = float(second) + while ( + command_index + 1 < len(commands) + and commands[command_index + 1][0] == clock.now + ): + command_index += 1 + identifier.record_output(commands[command_index][1]) + if second == next_observation: + identifier.observe( + _to_native_temperature(current_temperature_f, identifier.units) + ) + interval_index = (interval_index + 1) % len(observation_intervals) + next_observation += observation_intervals[interval_index] + return identifier + + +def identify_synthetic_fopdt(gain, tau, delay, units="F"): + clock = FakeClock() + identifier = AdaptiveFOPDTIdentifier(units, clock) + _feed_synthetic_fopdt(identifier, clock, gain, tau, delay) + return identifier.trusted_model + + +def identify_constant_duty(duration_seconds): + clock = FakeClock() + identifier = AdaptiveFOPDTIdentifier("F", clock) + identifier.record_output(0.4) + identifier.observe(365.0) + for second in range(15, duration_seconds + 1, 15): + clock.now = float(second) + identifier.observe(365.0) + return identifier.trusted_model, identifier.status() + + + + +class FOPDTModelTests(unittest.TestCase): + def test_model_is_immutable_and_validates_physical_bounds(self): + valid_model = model() + valid_model.validate() + + with self.assertRaises(FrozenInstanceError): + setattr(valid_model, "gain_f_per_duty", 700.0) + with self.assertRaises(ValueError): + model(gain=49.0).validate() + with self.assertRaises(ValueError): + model(tau=math.nan).validate() + + +class DutyHistoryTests(unittest.TestCase): + def test_duty_history_time_weights_fractional_boundaries(self): + history = DutyHistory(max_age_seconds=300.0) + history.record(0.0, 0.2, True) + history.record(10.0, 0.8, True) + + self.assertAlmostEqual(history.average(-5.0, 5.0), 0.2) + self.assertAlmostEqual(history.average(5.0, 15.0), 0.5) + self.assertAlmostEqual(history.average(25.0, 35.0, delay_seconds=20.0), 0.5) + + def test_duty_history_retains_one_predecessor_when_pruned(self): + history = DutyHistory(max_age_seconds=30.0) + for second in range(0, 101, 10): + history.record(float(second), second / 100.0, True) + + history.prune(100.0) + + self.assertEqual(history.value_at(70.0), 0.7) + self.assertLessEqual(history.command_count, 5) + + def test_duty_history_replaces_timestamps_and_preserves_permission_changes(self): + history = DutyHistory() + history.record(0.0, 0.2, True) + history.record(10.0, 0.2, False) + history.record(10.0, 0.7, True) + history.record(20.0, 0.7, True) + + self.assertEqual(history.command_count, 2) + self.assertEqual(history.value_at(10.0), 0.7) + self.assertTrue(history.interval_allowed(0.0, 10.0)) + self.assertTrue(history.interval_allowed(10.0, 20.0)) + + def test_duty_history_rejects_invalid_commands_and_tracks_invalid_segments(self): + history = DutyHistory() + for timestamp, duty in ((math.nan, 0.2), (0.0, math.inf), (0.0, -0.1), (0.0, 1.1)): + with self.subTest(timestamp=timestamp, duty=duty): + with self.assertRaises(ValueError): + history.record(timestamp, duty, True) + + history.record(0.0, 0.2, True) + history.record(10.0, 0.2, False) + history.record(20.0, 0.2, True) + + self.assertTrue(history.interval_allowed(0.0, 10.0)) + self.assertFalse(history.interval_allowed(5.0, 15.0)) + self.assertTrue(history.interval_allowed(20.0, 30.0)) + + +class SmithPredictorTests(unittest.TestCase): + def test_smith_predictor_starts_with_zero_correction(self): + clock = FakeClock() + predictor = SmithPredictor("F", clock) + predictor.record_output(0.2) + predictor.set_model(model()) + + self.assertEqual(predictor.update(250.0), 250.0) + + def test_inactive_predictor_bounds_alternating_history(self): + clock = FakeClock() + predictor = SmithPredictor("F", clock) + for second in range(0, 1001, 10): + clock.now = float(second) + predictor.record_output(0.2 if second % 20 == 0 else 0.8) + + self.assertLessEqual(predictor._history.command_count, 32) + predictor.set_model(model()) + self.assertEqual(predictor._undelayed_state, 120.0) + + def test_set_model_prunes_inactive_alternating_history(self): + clock = FakeClock() + predictor = SmithPredictor("F", clock) + for second in range(0, 1001, 10): + predictor._history.record( + float(second), 0.2 if second % 20 == 0 else 0.8, True + ) + + clock.now = 1000.0 + predictor.set_model(model()) + + self.assertLessEqual(predictor._history.command_count, 32) + self.assertEqual(predictor._undelayed_state, 120.0) + + def test_active_predictor_rejects_stale_output_timestamps(self): + clock = FakeClock() + predictor = SmithPredictor("F", clock) + predictor.record_output(0.2) + predictor.set_model(model()) + clock.now = 10.0 + predictor.update(250.0) + + with self.assertRaises(ValueError): + predictor.record_output(0.8, timestamp=5.0) + + def test_smith_predictor_integrates_delayed_boundary_exactly(self): + clock = FakeClock() + predictor = SmithPredictor("F", clock) + predictor.record_output(0.2) + predictor.set_model(model(gain=600.0, tau=300.0, delay=20.0)) + predictor.update(250.0) + clock.now = 10.0 + predictor.record_output(0.8) + clock.now = 40.0 + + predicted = predictor.update(250.0) + undelayed = 120.0 * math.exp(-30.0 / 300.0) + 480.0 * ( + 1.0 - math.exp(-30.0 / 300.0) + ) + delayed = 120.0 * math.exp(-10.0 / 300.0) + 480.0 * ( + 1.0 - math.exp(-10.0 / 300.0) + ) + self.assertAlmostEqual(predicted, 250.0 + undelayed - delayed, places=9) + + def test_smith_predictor_retains_unadvanced_command_transitions(self): + clock = FakeClock() + predictor = SmithPredictor("F", clock) + predictor.record_output(0.2) + predictor.set_model(model(gain=600.0, tau=300.0, delay=20.0)) + predictor.update(250.0) + clock.now = 10.0 + predictor.record_output(0.8) + clock.now = 20.0 + predictor.record_output(0.2) + clock.now = 400.0 + predictor.record_output(0.8) + + predicted = predictor.update(250.0) + undelayed = 120.0 * math.exp(-10.0 / 300.0) + 480.0 * ( + 1.0 - math.exp(-10.0 / 300.0) + ) + undelayed = 120.0 + (undelayed - 120.0) * math.exp(-380.0 / 300.0) + delayed = 120.0 * math.exp(-10.0 / 300.0) + 480.0 * ( + 1.0 - math.exp(-10.0 / 300.0) + ) + delayed = 120.0 + (delayed - 120.0) * math.exp(-360.0 / 300.0) + self.assertAlmostEqual(predicted, 250.0 + undelayed - delayed, places=9) + + def test_invalid_model_and_nonfinite_state_fall_back_to_measured(self): + predictor = SmithPredictor("F", FakeClock()) + with self.assertRaises(ValueError): + predictor.set_model(model(tau=0.0)) + predictor.record_output(0.2) + predictor.set_model(model()) + predictor._undelayed_state = math.nan + + self.assertEqual(predictor.update(275.0), 275.0) + self.assertFalse(predictor.status()["prediction_active"]) + + def test_safety_fallback_resets_every_failure_mode_and_recovers(self): + for failure_mode in ("nonfinite", "out_of_range", "residual"): + with self.subTest(failure_mode=failure_mode): + clock = FakeClock() + predictor = SmithPredictor("F", clock) + predictor.record_output(0.2) + trusted_model = model() + predictor.set_model(trusted_model) + predictor.update(250.0) + clock.now = 1.0 + predictor.record_output(0.8) + clock.now = 15.0 + + if failure_mode == "nonfinite": + predictor._undelayed_state = math.nan + predictor._delayed_state = 120.0 + measured_temperature = 275.0 + elif failure_mode == "out_of_range": + predictor._undelayed_state = 1201.0 + predictor._delayed_state = 120.0 + measured_temperature = 275.0 + else: + predictor._consecutive_implausible_residuals = 3 + measured_temperature = 500.0 + + self.assertEqual( + predictor.update(measured_temperature), measured_temperature + ) + self.assertFalse(predictor.status()["prediction_active"]) + self.assertIs(predictor._model, trusted_model) + undelayed_state = predictor._undelayed_state + delayed_state = predictor._delayed_state + self.assertIsNotNone(undelayed_state) + self.assertIsNotNone(delayed_state) + assert undelayed_state is not None + assert delayed_state is not None + self.assertTrue(math.isfinite(undelayed_state)) + self.assertTrue(math.isfinite(delayed_state)) + self.assertEqual(undelayed_state, delayed_state) + self.assertEqual(undelayed_state - delayed_state, 0.0) + self.assertIsNone(predictor._last_measured_f) + self.assertEqual(predictor._consecutive_implausible_residuals, 0) + + self.assertEqual( + predictor.update(measured_temperature), measured_temperature + ) + self.assertTrue(predictor.status()["prediction_active"]) + + def test_celsius_uses_the_same_fahrenheit_model_correction(self): + fahrenheit_clock = FakeClock() + celsius_clock = FakeClock() + fahrenheit = SmithPredictor("F", fahrenheit_clock) + celsius = SmithPredictor("C", celsius_clock) + for predictor in (fahrenheit, celsius): + predictor.record_output(0.2) + predictor.set_model(model()) + fahrenheit.update(250.0) + celsius.update((250.0 - 32.0) * 5.0 / 9.0) + fahrenheit_clock.now = celsius_clock.now = 10.0 + fahrenheit.record_output(0.8) + celsius.record_output(0.8) + fahrenheit_clock.now = celsius_clock.now = 40.0 + + f_correction = fahrenheit.update(250.0) - 250.0 + measured_c = (250.0 - 32.0) * 5.0 / 9.0 + c_correction = celsius.update(measured_c) - measured_c + self.assertAlmostEqual(c_correction, f_correction * 5.0 / 9.0) + + def test_four_implausible_one_step_residuals_disable_prediction(self): + clock = FakeClock() + predictor = SmithPredictor("F", clock) + predictor.record_output(0.2) + predictor.set_model(model()) + predictor.update(250.0) + for index, temperature in enumerate((400.0, 550.0, 700.0, 850.0), 1): + clock.now = index * 15.0 + predictor.update(temperature) + + self.assertFalse(predictor.status()["prediction_active"]) + + def test_residuals_ignore_delayed_invalid_source_intervals(self): + clock = FakeClock() + predictor = SmithPredictor("F", clock) + predictor.record_output(0.2) + predictor.set_model(model(delay=120.0)) + predictor.update(250.0) + for timestamp, allowed in ( + (10.0, False), + (20.0, True), + (25.0, False), + (35.0, True), + (40.0, False), + (50.0, True), + ): + clock.now = timestamp + predictor.record_output(0.2, identification_allowed=allowed) + + clock.now = 100.0 + predictor.update(250.0) + for timestamp, temperature in ( + (130.0, 400.0), + (145.0, 550.0), + (160.0, 700.0), + (175.0, 850.0), + ): + clock.now = timestamp + predictor.update(temperature) + + self.assertTrue(predictor.status()["prediction_active"]) + + + +class AdaptiveFOPDTIdentifierTests(unittest.TestCase): + def test_identifier_recovers_small_medium_and_large_models(self): + profiles = ( + (640.0, 3333.3333333333335, 20.0), + (647.0588235294117, 4705.882352941177, 35.0), + (700.0, 6500.0, 50.0), + ) + for gain, tau, delay in profiles: + with self.subTest(gain=gain, tau=tau, delay=delay): + estimate = identify_synthetic_fopdt(gain, tau, delay) + self.assertIsNotNone(estimate) + assert estimate is not None + self.assertAlmostEqual( + estimate.gain_f_per_duty, gain, delta=gain * 0.10 + ) + self.assertAlmostEqual(estimate.tau_seconds, tau, delta=tau * 0.15) + self.assertAlmostEqual(estimate.theta_seconds, delay, delta=5.0) + + def test_identifier_does_not_trust_constant_duty(self): + estimate, status = identify_constant_duty(duration_seconds=8 * 3600) + + self.assertIsNone(estimate) + self.assertFalse(status["trusted"]) + self.assertLess(status["duty_stddev"], 0.05) + + def test_identifier_rejects_ambiguous_delay(self): + identifier = AdaptiveFOPDTIdentifier( + "F", FakeClock(), delay_candidates=(20, 25) + ) + identifier._temperature_reference_f = 200.0 + identifier._accepted_seconds = 3600.0 + identifier._accepted_observations = 240 + identifier._duty_count = 2 + identifier._duty_m2 = 0.02 + identifier._sustained_transition = True + identifier._temperature_min_f = 200.0 + identifier._temperature_max_f = 220.0 + beta_t = -1.0 / 3333.3333333333335 + beta_u = 640.0 / 3333.3333333333335 + beta_0 = -beta_t * 125.0 + for candidate, residual in zip(identifier._candidates, (1e-8, 1.05e-8)): + candidate.coefficients = [ + beta_0 + beta_t * identifier._temperature_reference_f, + beta_t * 500.0, + beta_u, + ] + candidate.covariance = [ + [0.001, 0.0, 0.0], + [0.0, 0.001, 0.0], + [0.0, 0.0, 0.001], + ] + candidate.residual_ewma = residual + candidate.valid_updates = 240 + + for _ in range(20): + self.assertIsNone(identifier._consider_estimate()) + + status = identifier.status() + self.assertEqual(status["eligible_candidates"], 2) + self.assertLess(status["delay_residual_margin"], 0.10) + self.assertIsNone(identifier.trusted_model) + + def test_disturbed_interval_does_not_create_observation(self): + clock = FakeClock() + identifier = AdaptiveFOPDTIdentifier("F", clock) + identifier.record_output(0.2, True) + identifier.observe(200.0) + clock.now = 15.0 + identifier.record_output(1.0, False) + clock.now = 45.0 + identifier.observe(240.0) + self.assertEqual(identifier.status()["accepted_observations"], 0) + clock.now = 60.0 + identifier.record_output(0.3, True) + identifier.observe(241.0) + clock.now = 75.0 + identifier.observe(242.0) + self.assertEqual(identifier.status()["accepted_observations"], 1) + + def test_temperature_span_includes_the_baseline_endpoint(self): + clock = FakeClock() + identifier = AdaptiveFOPDTIdentifier("F", clock) + identifier.record_output(0.2) + identifier.observe(200.0) + clock.now = 15.0 + identifier.observe(215.0) + + self.assertEqual(identifier.status()["temperature_span_f"], 15.0) + + def test_celsius_samples_recover_the_canonical_fahrenheit_model(self): + estimate = identify_synthetic_fopdt( + 647.0588235294117, 4705.882352941177, 35.0, units="C" + ) + + self.assertIsNotNone(estimate) + assert estimate is not None + self.assertAlmostEqual(estimate.gain_f_per_duty, 647.0588235294117, delta=65.0) + self.assertAlmostEqual(estimate.tau_seconds, 4705.882352941177, delta=706.0) + self.assertAlmostEqual(estimate.theta_seconds, 35.0, delta=5.0) + + def test_repeated_duty_records_preserve_a_sustained_transition(self): + clock = FakeClock() + identifier = AdaptiveFOPDTIdentifier("F", clock) + identifier.record_output(0.2) + clock.now = 10.0 + identifier.record_output(0.8) + clock.now = 25.0 + identifier.record_output(0.8) + clock.now = 70.0 + identifier.record_output(0.8) + + self.assertTrue(identifier.status()["sustained_transition"]) + + def test_same_timestamp_replacement_does_not_create_a_transition(self): + clock = FakeClock() + identifier = AdaptiveFOPDTIdentifier("F", clock) + identifier.record_output(0.2) + clock.now = 300.0 + identifier.record_output(0.8) + identifier.record_output(0.2) + clock.now = 400.0 + identifier.record_output(0.2) + + self.assertFalse(identifier.status()["sustained_transition"]) + + def test_same_timestamp_correction_restores_the_original_hold(self): + clock = FakeClock() + identifier = AdaptiveFOPDTIdentifier("F", clock) + identifier.record_output(0.2) + clock.now = 10.0 + identifier.record_output(0.8) + clock.now = 20.0 + identifier.record_output(0.7) + identifier.record_output(0.8) + clock.now = 70.0 + identifier.record_output(0.8) + + self.assertTrue(identifier.status()["sustained_transition"]) + + def test_restore_trusted_model_is_immediate_and_keeps_rls_fresh(self): + identifier = AdaptiveFOPDTIdentifier("F", FakeClock()) + identifier._candidates[0].coefficients = [1.0, 2.0, 3.0] + identifier._candidates[0].valid_updates = 4 + restored = model(gain=640.0, tau=3333.0, delay=20.0, revision=7) + + identifier.restore_trusted_model(restored) + + self.assertIs(identifier.trusted_model, restored) + self.assertTrue(identifier.status()["trusted"]) + self.assertEqual(identifier.status()["model_revision"], 7) + self.assertEqual(identifier._candidates[0].coefficients, [0.0, 0.0, 0.0]) + self.assertEqual(identifier._candidates[0].valid_updates, 0) + + def test_delay_candidates_require_a_unique_grid_subset(self): + valid_grid = tuple(range(0, 121, 5)) + invalid_candidates = ( + (2.5,), + (5, 5), + valid_grid + (0,), + ) + for delay_candidates in invalid_candidates: + with self.subTest(delay_candidates=delay_candidates): + with self.assertRaises(ValueError): + AdaptiveFOPDTIdentifier( + "F", FakeClock(), delay_candidates=delay_candidates + ) + + def test_material_check_without_a_trusted_model_is_false(self): + identifier = AdaptiveFOPDTIdentifier("F", FakeClock()) + + self.assertFalse(identifier._is_material((640.0, 3333.0, 20.0, 0.0, 0.0, 0.0))) + + def test_invalid_candidates_never_become_eligible(self): + cases = ( + ("negative gain", [-0.1, -0.5, -0.6]), + ("unstable beta", [0.3, 0.5, 0.6]), + ("non-finite coefficient", [math.nan, -0.5, 0.6]), + ("out-of-bounds gain", [-0.1, -0.5, 3.0]), + ) + for name, coefficients in cases: + with self.subTest(name=name): + identifier = AdaptiveFOPDTIdentifier("F", FakeClock()) + identifier._temperature_reference_f = 200.0 + candidate = identifier._candidates[0] + candidate.coefficients = coefficients + candidate.covariance = [ + [0.001, 0.0, 0.0], + [0.0, 0.001, 0.0], + [0.0, 0.0, 0.001], + ] + candidate.residual_ewma = 1e-9 + candidate.valid_updates = 240 + + self.assertEqual(identifier.status()["eligible_candidates"], 0) + + def test_failed_candidate_resets_without_poisoning_others(self): + clock = FakeClock() + identifier = AdaptiveFOPDTIdentifier("F", clock, delay_candidates=(0, 5)) + identifier.record_output(0.2) + identifier.observe(200.0) + clock.now = 15.0 + identifier.record_output(0.8) + identifier.observe(202.0) + identifier._candidates[0].covariance[0][0] = math.nan + clock.now = 30.0 + identifier.observe(204.0) + + self.assertEqual(identifier._candidates[0].valid_updates, 0) + self.assertGreater(identifier._candidates[1].valid_updates, 1) + + def test_candidate_uses_the_exact_delayed_interval_average(self): + clock = FakeClock() + identifier = AdaptiveFOPDTIdentifier("F", clock, delay_candidates=(0, 5)) + identifier.record_output(0.2) + identifier.observe(200.0) + clock.now = 10.0 + identifier.record_output(1.0) + identifier.observe(200.0) + delayed_candidate = identifier._candidates[1] + delayed_candidate.coefficients = [0.0, 0.0, 0.0] + delayed_candidate.covariance = [ + [1e6, 0.0, 0.0], + [0.0, 1e6, 0.0], + [0.0, 0.0, 1e6], + ] + delayed_candidate.residual_ewma = None + delayed_candidate.valid_updates = 0 + clock.now = 20.0 + + identifier.observe(210.0) + + delayed_average = 0.6 + expected = ( + delayed_average * 1e6 + / (0.9995 + 1e6 + delayed_average ** 2 * 1e6) + ) + self.assertAlmostEqual( + identifier._candidates[1].coefficients[2], expected, places=12 + ) + + def test_material_estimate_blends_gain_tau_and_bumps_revision(self): + identifier = AdaptiveFOPDTIdentifier( + "F", FakeClock(), delay_candidates=(20, 25) + ) + identifier.restore_trusted_model(model(gain=600.0, tau=3000.0, delay=10.0, revision=4)) + identifier._temperature_reference_f = 200.0 + identifier._accepted_seconds = 3600.0 + identifier._accepted_observations = 240 + identifier._duty_count = 2 + identifier._duty_m2 = 0.02 + identifier._sustained_transition = True + identifier._temperature_min_f = 200.0 + identifier._temperature_max_f = 220.0 + for candidate, residual in zip(identifier._candidates, (1e-9, 1e-5)): + beta_t = -1.0 / 4000.0 + beta_u = 800.0 / 4000.0 + beta_0 = -beta_t * 125.0 + candidate.coefficients = [ + beta_0 + beta_t * identifier._temperature_reference_f, + beta_t * 500.0, + beta_u, + ] + candidate.covariance = [ + [0.001, 0.0, 0.0], + [0.0, 0.001, 0.0], + [0.0, 0.0, 0.001], + ] + candidate.residual_ewma = residual + candidate.valid_updates = 240 + + published = None + for _ in range(20): + published = identifier._consider_estimate() + + self.assertIsNotNone(published) + assert published is not None + self.assertAlmostEqual(published.gain_f_per_duty, 620.0) + self.assertAlmostEqual(published.tau_seconds, 3100.0) + self.assertEqual(published.theta_seconds, 20.0) + self.assertEqual(published.revision, 5) +if __name__ == "__main__": + unittest.main() From 50a690ec1e951f63947cdb693232249a4e6c02ab Mon Sep 17 00:00:00 2001 From: Daniel Berlin Date: Sun, 26 Jul 2026 18:44:20 -0400 Subject: [PATCH 2/4] Integrate adaptive Smith control into PID-SP --- .gitignore | 3 + common/adaptive_controller_state.py | 187 +++++++++ control.py | 99 ++++- controller/controllers.json | 24 +- controller/pid_sp.py | 225 +++++++++-- controller/runtime.py | 131 ++++++ tests/test_adaptive_controller_state.py | 427 +++++++++++++++++++ tests/test_pid_sp.py | 517 ++++++++++++++++++++++++ 8 files changed, 1550 insertions(+), 63 deletions(-) create mode 100644 common/adaptive_controller_state.py mode change 100644 => 100755 controller/pid_sp.py create mode 100644 controller/runtime.py create mode 100644 tests/test_adaptive_controller_state.py create mode 100644 tests/test_pid_sp.py diff --git a/.gitignore b/.gitignore index 00e2d79fd..b063c7528 100755 --- a/.gitignore +++ b/.gitignore @@ -109,3 +109,6 @@ test_*.py debug_*.py debug_*.html *_test.py +!tests/ +!tests/**/*.py +/adaptive_controller_state.json diff --git a/common/adaptive_controller_state.py b/common/adaptive_controller_state.py new file mode 100644 index 000000000..32676df54 --- /dev/null +++ b/common/adaptive_controller_state.py @@ -0,0 +1,187 @@ +"""Durable storage for trusted adaptive controller model snapshots.""" + +import logging +import json +import math +import os +from pathlib import Path +import tempfile +import time + + +LOGGER = logging.getLogger(__name__) + + +_ROOT_VERSION = 1 +_MODEL_VERSION = 1 +_ROOT_KEYS = {"version", "models"} +_MODEL_KEYS = { + "version", + "gain_f_per_duty", + "tau_seconds", + "theta_seconds", + "confidence", + "residual", + "observations", + "revision", +} +_NUMERIC_MODEL_KEYS = ( + "gain_f_per_duty", + "tau_seconds", + "theta_seconds", + "confidence", + "residual", +) + + +class AdaptiveControllerStateStore: + """Atomically persist only validated, physical controller model snapshots.""" + + def __init__( + self, + path=Path("adaptive_controller_state.json"), + clock=time.time, + min_write_interval=1800.0, + ): + interval = float(min_write_interval) + if not math.isfinite(interval) or interval < 0.0: + raise ValueError("min_write_interval must be a non-negative finite number") + + self.path = Path(path) + self._clock = clock + self._min_write_interval = interval + loaded_models = self._read_models() + self._models = {} if loaded_models is None else loaded_models + self._pending = {} + self._last_successful_write = ( + self._now() if loaded_models is not None else None + ) + + def load(self, name): + """Return a copy of a persisted trusted snapshot, if it is valid.""" + snapshot = self._models.get(name) + return None if snapshot is None else dict(snapshot) + + def stage(self, name, snapshot): + """Stage a newer trusted snapshot without writing dynamic controller state.""" + if not isinstance(name, str) or not name: + return False + + validated = self._validate_model(snapshot) + if validated is None: + return False + + current = self._pending.get(name, self._models.get(name)) + if current is not None and validated["revision"] <= current["revision"]: + return False + + self._pending[name] = validated + return True + + def flush(self, force=False): + """Atomically write pending models when the routine-write interval permits.""" + if not self._pending: + return False + if ( + not force + and self._last_successful_write is not None + and self._now() - self._last_successful_write + < self._min_write_interval + ): + return False + + models = dict(self._models) + models.update(self._pending) + root = {"version": _ROOT_VERSION, "models": models} + if not self._write(root): + return False + + self._models = models + self._pending.clear() + self._last_successful_write = self._now() + return True + + def _now(self): + return float(self._clock()) + + def _read_models(self): + try: + with self.path.open("r", encoding="utf-8") as state_file: + root = json.load(state_file) + except (OSError, TypeError, ValueError): + return None + + if type(root) is not dict or set(root) != _ROOT_KEYS: + return None + if type(root["version"]) is not int or root["version"] != _ROOT_VERSION: + return None + if type(root["models"]) is not dict: + return None + + models = {} + for name, snapshot in root["models"].items(): + if not isinstance(name, str) or not name: + return None + validated = self._validate_model(snapshot) + if validated is None: + return None + models[name] = validated + return models + + def _validate_model(self, snapshot): + if type(snapshot) is not dict or set(snapshot) != _MODEL_KEYS: + return None + if type(snapshot["version"]) is not int or snapshot["version"] != _MODEL_VERSION: + return None + if any( + isinstance(snapshot[key], bool) + or not isinstance(snapshot[key], (int, float)) + or not math.isfinite(snapshot[key]) + for key in _NUMERIC_MODEL_KEYS + ): + return None + if any(type(snapshot[key]) is not int for key in ("observations", "revision")): + return None + if not 50.0 <= snapshot["gain_f_per_duty"] <= 2000.0: + return None + if not 300.0 <= snapshot["tau_seconds"] <= 20000.0: + return None + if not 0.0 <= snapshot["theta_seconds"] <= 120.0: + return None + if not 0.0 <= snapshot["confidence"] <= 1.0: + return None + if snapshot["residual"] < 0.0: + return None + if snapshot["observations"] < 0 or snapshot["revision"] < 0: + return None + return dict(snapshot) + + def _write(self, root): + temporary_path = None + try: + self.path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", + dir=self.path.parent, + prefix=self.path.name + ".tmp-", + delete=False, + encoding="utf-8", + ) as state_file: + temporary_path = Path(state_file.name) + json.dump(root, state_file, sort_keys=True, indent=2) + state_file.flush() + os.fsync(state_file.fileno()) + os.replace(temporary_path, self.path) + temporary_path = None + return True + except Exception: + LOGGER.exception( + "Unable to atomically write adaptive controller model state" + ) + return False + finally: + if temporary_path is not None: + try: + temporary_path.unlink() + except OSError: + pass diff --git a/control.py b/control.py index 67c8e8840..1bdbf5eea 100755 --- a/control.py +++ b/control.py @@ -30,6 +30,21 @@ from file_mgmt.cookfile import create_cookfile from file_mgmt.common import read_json_file_data from os.path import exists +from common.adaptive_controller_state import AdaptiveControllerStateStore +from controller.runtime import ( + apply_live_hold_target, + apply_live_hold_target_and_restart_cycle, + controller_reinit_output_seed, + diagnostics, + identification_allowed, + record_lid_open_transition, + hold_pid_update_due, + manual_override_duty, + normal_pid_output_recording_allowed, + record_output, + restore_model, + stage_model, +) ''' ============================================================================== @@ -324,6 +339,9 @@ def _work_cycle(mode, grill_platform, probe_complex, display_device, dist_device settings = read_settings() control = read_control() pelletdb = read_pellet_db() + adaptive_store = AdaptiveControllerStateStore() + controllerCore = None + adaptive_model_pending = False control['hopper_check'] = True write_control(control, direct_write=True, origin='control') @@ -439,6 +457,11 @@ def _work_cycle(mode, grill_platform, probe_complex, display_device, dist_device controllerCore, controller_status = _init_controller(settings, control) if controller_status == 'Inactive': status = 'Inactive' + else: + restore_model( + controllerCore, adaptive_store, settings['controller']['selected'] + ) + record_output(controllerCore, CycleRatio, identification_allowed=True) eventLogger.debug('On Time = ' + str(OnTime) + ', OffTime = ' + str(OffTime) + ', CycleTime = ' + str( CycleTime) + ', CycleRatio = ' + str(CycleRatio)) @@ -579,8 +602,14 @@ def _work_cycle(mode, grill_platform, probe_complex, display_device, dist_device _process_system_commands(grill_platform) + cycle_restart = apply_live_hold_target_and_restart_cycle( + controllerCore, mode, control, now + ) + if cycle_restart is not None: + controllerCycleStart = cycle_restart + write_control(control, direct_write=True, origin='control') # Check if new mode has been requested - if control['updated']: + elif control['updated']: break # Check if user changed settings and reload @@ -610,6 +639,27 @@ def _work_cycle(mode, grill_platform, probe_complex, display_device, dist_device settings = read_settings() controllerCore, controller_status = _init_controller(settings, control) if controller_status == 'Active': + restore_model( + controllerCore, adaptive_store, settings['controller']['selected'] + ) + manual_override_active = manual_override['auger'] >= now + manual_auger_output = ( + grill_platform.get_output_status()['auger'] + if manual_override_active + else False + ) + seed_duty, seed_identification_allowed = ( + controller_reinit_output_seed( + CycleRatio, + LidOpenDetect, + manual_override_active, + ControlFanPid, + manual_auger_output, + ) + ) + record_output( + controllerCore, seed_duty, seed_identification_allowed + ) eventLogger.info('Controller reinitialized with updated settings') # Check if user changed hopper levels and update if required @@ -671,6 +721,11 @@ def _work_cycle(mode, grill_platform, probe_complex, display_device, dist_device grill_platform.auger_off() eventLogger.debug('Auger OFF') manual_override['auger'] = override_time # Set override time + record_output( + controllerCore, + manual_override_duty(control['manual']['output']), + False, + ) if control['manual']['change'] == 'igniter': if control['manual']['output'] and not current_output_status['igniter']: @@ -706,8 +761,14 @@ def _work_cycle(mode, grill_platform, probe_complex, display_device, dist_device if mode in ('Startup', 'Reignite', 'Smoke', 'Hold', 'Prime'): if mode == 'Hold': # Check to see if it's time to update pid and update if needed. - if (now - controllerCycleStart) > CycleTime: + if hold_pid_update_due(now, controllerCycleStart, CycleTime): pid_output = controllerCore.update(ptemp) + if stage_model( + controllerCore, adaptive_store, settings['controller']['selected'] + ): + adaptive_model_pending = True + if adaptive_store.flush(): + adaptive_model_pending = False controllerCycleStart = now CycleRatio = RawCycleRatio = settings['cycle_data']['u_min'] if LidOpenDetect else pid_output # If ratio is less than min set auger ratio to min and control further via fan. @@ -724,7 +785,21 @@ def _work_cycle(mode, grill_platform, probe_complex, display_device, dist_device ControlFanPid = False # Don't set ratio over maximum. CycleRatio = min(CycleRatio, settings['cycle_data']['u_max']) + if normal_pid_output_recording_allowed( + manual_override['auger'], now + ): + record_output( + controllerCore, + CycleRatio, + identification_allowed=identification_allowed( + LidOpenDetect, + manual_override['auger'] >= now, + ControlFanPid, + ), + ) if manual_override['auger'] < now: + if manual_override['auger']: + record_output(controllerCore, CycleRatio, False) manual_override['auger'] = 0 # If Auger is OFF and time since toggle is greater than Off Time if not current_output_status['auger'] and (now - auger_toggle_time) > (CycleTime * (1 - CycleRatio)): @@ -741,7 +816,7 @@ def _work_cycle(mode, grill_platform, probe_complex, display_device, dist_device #publish pid info to mqtt if enabled if settings['notify_services'].get('mqtt') != None and settings['notify_services']['mqtt']['enabled']: - pid_data = controllerCore.__dict__ + pid_data = diagnostics(controllerCore) pid_data['cycle_ratio'] = round(CycleRatio, 2) check_notify(settings, control, pid_data=pid_data) @@ -873,6 +948,7 @@ def _work_cycle(mode, grill_platform, probe_complex, display_device, dist_device # If we are in a state where the auger ratio is min and we are using the fan for control, turning the fan on here would overshoot the temps. # This is a major issue when using piFire for a wood or charcoal pit or a hybrid wood/pellet pit. grill_platform.auger_off() + record_lid_open_transition(controllerCore) grill_platform.fan_off() auger_toggle_time = now LidOpenEventExpires = now + settings['cycle_data']['LidOpenPauseTime'] @@ -891,6 +967,7 @@ def _work_cycle(mode, grill_platform, probe_complex, display_device, dist_device else: LidOpenDetect = True grill_platform.auger_off() + record_lid_open_transition(controllerCore) grill_platform.fan_off() auger_toggle_time = now LidOpenEventExpires = now + settings['cycle_data']['LidOpenPauseTime'] @@ -1077,6 +1154,7 @@ def _work_cycle(mode, grill_platform, probe_complex, display_device, dist_device # END Mode Loop # ********* + # Clean-up and Exit grill_platform.auger_off() grill_platform.igniter_off() @@ -1087,6 +1165,21 @@ def _work_cycle(mode, grill_platform, probe_complex, display_device, dist_device grill_platform.fan_off() grill_platform.power_off() eventLogger.debug('Fan OFF, Power OFF') + + try: + if stage_model( + controllerCore, adaptive_store, settings['controller']['selected'] + ): + adaptive_model_pending = True + except Exception: + controlLogger.exception('Unable to stage adaptive controller model state') + try: + if adaptive_store.flush(force=True): + adaptive_model_pending = False + elif adaptive_model_pending: + controlLogger.error('Unable to persist adaptive controller model state') + except Exception: + controlLogger.exception('Unable to persist adaptive controller model state') if mode in ('Startup', 'Reignite'): control['safety']['afterstarttemp'] = ptemp diff --git a/controller/controllers.json b/controller/controllers.json index c86d2dfc5..112683f46 100644 --- a/controller/controllers.json +++ b/controller/controllers.json @@ -246,7 +246,7 @@ "friendly_name" : "PID Smith Predictor", "module_name" : "pid_sp", "image" : "pid_sp.png", - "description" : "The Auto Center PID controller for PiFire, modified to include a Smith Predictor. This software was originally developed by GitHub user DBorello as part of his excellent PiSmoker project. Slightly modified by @weberbox and @dogtreatfairy and adapted for PiFire. ", + "description" : "The Auto Center PID controller for PiFire uses an adaptive Smith predictor: gain, time constant, and dead time are learned from applied duty and temperature. This software was originally developed by GitHub user DBorello as part of his excellent PiSmoker project. Slightly modified by @weberbox and @dogtreatfairy and adapted for PiFire.", "author" : "Dan Borello", "link" : "https://github.com/DBorello/PiSmoker", "contributors" : ["Ben Parmeter", "James Weber", "Ryan Steel"], @@ -313,28 +313,6 @@ "option_max" : null, "option_step" : 0.0001, "hidden" : false - }, - { - "option_name" : "tau", - "option_friendly_name" : "Tau (s)", - "option_description" : "Time constant for the Smith Predictor. This is the time it takes the system to reach 2/3 of it's final value after a Set Point change. Higher Tau decreases system sensitivity to model mismatch. [Default=115]", - "option_type" : "float", - "option_default" : 115, - "option_min" : null, - "option_max" : null, - "option_step" : 1, - "hidden" : false - }, - { - "option_name": "theta", - "option_friendly_name" : "Theta (s)", - "option_description" : "Time delay in the system. Theta is measured after commanding a Set Point change as the time it takes from the initial commmand to seeing initial temperature rise. Higher Theta makes the system more sensitive to delays in the system. [Default=65]", - "option_type" : "float", - "option_default" : 65, - "option_min" : null, - "option_max" : null, - "option_step" : 1, - "hidden" : false } ] }, diff --git a/controller/pid_sp.py b/controller/pid_sp.py old mode 100644 new mode 100755 index 253674a42..aef1635d4 --- a/controller/pid_sp.py +++ b/controller/pid_sp.py @@ -35,7 +35,9 @@ ''' import time import math +from typing import Optional from controller.base import ControllerBase +from controller.smith_predictor import AdaptiveFOPDTIdentifier, FOPDTModel, SmithPredictor ''' Class Definition @@ -45,6 +47,10 @@ def __init__(self, config, units, cycle_data): super().__init__(config, units, cycle_data) self.function_list.append('set_gains') self.function_list.append('get_k') + self.function_list.append('set_output') + self.function_list.append('get_model_snapshot') + self.function_list.append('restore_model') + self.function_list.append('get_status') pb = config.get('PB', 60.0) ti = config.get('Ti', 180.0) @@ -59,6 +65,8 @@ def __init__(self, config, units, cycle_data): self.pb = pb self.units = units + self._identifier = AdaptiveFOPDTIdentifier(units, clock=lambda: time.time()) + self._predictor = SmithPredictor(units, clock=lambda: time.time()) self.last_update = time.time() self.last_set_time = time.time() @@ -68,92 +76,150 @@ def __init__(self, config, units, cycle_data): self.center = 0.5 self.center_factor = config.get('center_factor', 0.0010) - self.tau = config.get('tau', 115) - self.theta = config.get('theta', 65) - self.stable_window = config.get('stable_window', 12) self.cycle_time = cycle_data['HoldCycleTime'] self.derv = 0.0 self.inter = 0.0 - self.last = 150 + self.last: Optional[float] = None + self.previous_controller_input: Optional[float] = None + self._predicted_temperature: Optional[float] = None self.start_change_temp = 0.0 self.new_target = False + self.output_limited_approach = False + self.slow_approach_samples = 0 self.set_target(0.0) def _calculate_gains(self, pb, ti, td): + self.ti = float(ti) if pb == 0: self.kp = 0 else: self.kp = -1 / pb - if ti == 0: + if self.ti <= 0.0: self.ki = 0 else: - self.ki = self.kp / ti + self.ki = self.kp / self.ti self.kd = self.kp * td + def _update_integral_limit(self): + if self.ki == 0: + self.inter_max = 0.0 + else: + self.inter_max = abs(self.center / self.ki) + + def _target_capture_band(self): + if self.units == 'F': + return 3.0 + return 3.0 * 5.0 / 9.0 + + def _reset_approach_state(self): + self.output_limited_approach = False + self.slow_approach_samples = 0 + def update(self, current): # Elapsed time since last update current_time = time.time() + first_selected_sample = self.previous_controller_input is None + + trusted_update = self._identifier.observe(current, current_time) + if trusted_update is not None: + self._predictor.set_model(trusted_update) + current_time = time.time() + controller_input = self._predictor.update(current, current_time) dt = current_time - self.last_update + self._predicted_temperature = controller_input + error = controller_input - self.set_point - # Fix self.last being set to 0.0 on set point change - if self.last == 0.0 and self.new_target: - self.last = current - - # Error Calculation - error = current - self.set_point + previous_controller_input = self.previous_controller_input + if previous_controller_input is None: + selected_rate = 0.0 + else: + selected_rate = (controller_input - previous_controller_input) / dt - # Rate of Change Calculation - self.roc = (current - self.last) / dt # Rate of change in Degrees per second + # Seed setpoint-transition bookkeeping from the first selected sample. + if first_selected_sample: + self.derv = 0.0 + if self.new_target: + self.start_change_temp = controller_input - # Predict future temperature and error - predicted_temp = current + (self.roc * self.theta) * (1 - math.exp(-dt / self.tau)) - predicted_error = predicted_temp - self.set_point + capture_band = self._target_capture_band() + upward_transition = self.set_point > self.start_change_temp + captured_target = abs(error) <= capture_band or (upward_transition and error >= 0.0) + if self.new_target and captured_target: + self.new_target = False + self._reset_approach_state() # Determine output - if predicted_error < -self.pb: + if error < -self.pb: self.u = 1.0 # If overshooting, minimize output - elif predicted_error > self.stable_window: + elif error > self.stable_window: self.u = 0.0 else: - # Reset integral term when current temperature first reaches or exceeds set point after a set point change - if self.new_target and abs(error) <= 3: - self.new_target = False - # Reset integral if the system is not within stable window or has not reached halfway to the set point within 3 cycles. Prevents overshoots on small set point changes. - if (abs(error) > self.stable_window) or (self.new_target and current_time - self.last_set_time >= self.cycle_time * 3 and abs(error) <= abs(self.start_change_temp - self.set_point) / 2): + # Keep normal small-step damping until the target is reached. A + # halfway transition still resets the integral accumulator, but + # completes only after its full PID candidate is output-limited. + reached_halfway = self.new_target and current_time - self.last_set_time >= self.cycle_time * 3 and abs(error) <= abs(self.start_change_temp - self.set_point) / 2 + if abs(error) > self.stable_window or reached_halfway: self.inter = 0.0 # Minimize derivative to maximize descent rate when setting new lower Set Point - if (self.new_target and self.set_point < current) or (abs(error) > self.pb / 2): + if (self.new_target and self.set_point < controller_input) or (abs(error) > self.pb / 2): self.derv = 0.0 # P - self.p = self.kp * predicted_error + self.center + self.p = self.kp * error + self.center # I - self.inter += predicted_error * dt + self.inter += error * dt + self.inter = max(min(self.inter, self.inter_max), -self.inter_max) self.i = self.ki * self.inter self.i = max(min(self.i, self.center), -self.center) # D - self.derv = (predicted_temp - self.last) / dt + if first_selected_sample: + self.derv = 0.0 + else: + self.derv = selected_rate self.d = self.kd * self.derv - - # If error is within PB, reduce output to prevent overshoots - if error < self.pb and current_time - self.last_set_time < self.cycle_time * 3: - self.u = self.u * 0.65 # PID self.u = self.p + self.i + self.d + # If error is within PB, reduce output to prevent overshoots + if abs(error) < self.pb and current_time - self.last_set_time < self.cycle_time * 3: + self.u *= 0.65 + + u_max = self.cycle_data.get('u_max', 1.0) + if reached_halfway and upward_transition and self.u >= u_max: + if self.ti <= 0.0: + self.new_target = False + self._reset_approach_state() + else: + self.output_limited_approach = True + + if self.output_limited_approach and self.new_target: + if self.ti <= 0.0: + self.new_target = False + self._reset_approach_state() + else: + rate_threshold = capture_band / (2.0 * self.ti) + if selected_rate <= rate_threshold: + self.slow_approach_samples += 1 + else: + self.slow_approach_samples = 0 + if self.slow_approach_samples >= 3: + self.new_target = False + self._reset_approach_state() + # Update for next cycle self.error = error self.last = current + self.previous_controller_input = controller_input self.last_update = current_time return self.u @@ -163,9 +229,13 @@ def set_target(self, set_point): self.error = 0.0 self.inter = 0.0 self.derv = 0.0 + self._reset_approach_state() self.last_update = time.time() self.last_set_time = self.last_update - self.start_change_temp = self.last + if self.previous_controller_input is not None: + self.start_change_temp = self.previous_controller_input + else: + self.start_change_temp = self.last if self.last is not None else 0.0 self.new_target = True # Dynamically set self.center depending on set_point. Higher centers are needed to achieve higher temps, lower centers for lower temps. if self.units == "F": @@ -178,14 +248,95 @@ def set_target(self, set_point): self.center = (set_point * 9/5 + 32) * self.center_factor else: self.center = (set_point * 9/5 + 32) * self.center_factor * 1.2 + self._update_integral_limit() def set_gains(self, pb, ti, td): self._calculate_gains(pb,ti,td) - if self.ki == 0: - self.inter_max = 0 - else: - self.inter_max = abs(self.center / self.ki) + self._update_integral_limit() + if self.ti <= 0.0: + self.inter = 0.0 + self.i = 0.0 def get_k(self): return self.kp, self.ki, self.kd + def set_output(self, applied_ratio, identification_allowed=True): + current_time = time.time() + self._identifier.record_output(applied_ratio, identification_allowed, timestamp=current_time) + self._predictor.record_output(applied_ratio, identification_allowed, timestamp=current_time) + + def get_model_snapshot(self) -> Optional[dict]: + model = self._identifier.trusted_model + if model is None: + return None + return { + 'version': 1, + 'gain_f_per_duty': model.gain_f_per_duty, + 'tau_seconds': model.tau_seconds, + 'theta_seconds': model.theta_seconds, + 'confidence': model.confidence, + 'residual': model.residual, + 'observations': model.observations, + 'revision': model.revision, + } + + def restore_model(self, snapshot) -> bool: + expected_keys = { + 'version', + 'gain_f_per_duty', + 'tau_seconds', + 'theta_seconds', + 'confidence', + 'residual', + 'observations', + 'revision', + } + numeric_keys = ( + 'gain_f_per_duty', + 'tau_seconds', + 'theta_seconds', + 'confidence', + 'residual', + ) + try: + if not isinstance(snapshot, dict) or set(snapshot) != expected_keys: + return False + if type(snapshot['version']) is not int or snapshot['version'] != 1: + return False + if any(isinstance(snapshot[key], bool) or not isinstance(snapshot[key], (int, float)) for key in numeric_keys): + return False + if any(type(snapshot[key]) is not int for key in ('observations', 'revision')): + return False + model = FOPDTModel(float(snapshot['gain_f_per_duty']), float(snapshot['tau_seconds']), float(snapshot['theta_seconds']), float(snapshot['confidence']), float(snapshot['residual']), snapshot['observations'], snapshot['revision']) + model.validate() + self._identifier.restore_trusted_model(model) + self._predictor.set_model(model) + except (KeyError, TypeError, ValueError, OverflowError): + return False + return True + + def get_status(self) -> dict: + model = self._identifier.trusted_model + predictor_status = self._predictor.status() + identifier_status = self._identifier.status() + return { + 'prediction_active': bool(predictor_status['prediction_active']), + 'controller_input_temperature': self._json_scalar(self.previous_controller_input), + 'predicted_temperature': self._json_scalar(self._predicted_temperature), + 'undelayed_model': self._json_scalar(self._predictor._undelayed_state), + 'delayed_model': self._json_scalar(self._predictor._delayed_state), + 'estimated_gain_f_per_duty': self._json_scalar(None if model is None else model.gain_f_per_duty), + 'estimated_tau_seconds': self._json_scalar(None if model is None else model.tau_seconds), + 'estimated_theta_seconds': self._json_scalar(None if model is None else model.theta_seconds), + 'model_confidence': self._json_scalar(None if model is None else model.confidence), + 'model_residual': self._json_scalar(None if model is None else model.residual), + 'accepted_observations': self._json_scalar(identifier_status['accepted_observations']), + 'model_revision': self._json_scalar(None if model is None else model.revision), + } + @staticmethod + def _json_scalar(value): + if value is None or isinstance(value, (bool, str, int)): + return value + if isinstance(value, float) and math.isfinite(value): + return value + return None diff --git a/controller/runtime.py b/controller/runtime.py new file mode 100644 index 000000000..abbed92ab --- /dev/null +++ b/controller/runtime.py @@ -0,0 +1,131 @@ +"""Optional adaptive-controller runtime integration helpers.""" + + +def supports(controller, function_name): + """Return whether a controller declares an optional function available.""" + supported_functions = getattr(controller, "supported_functions", None) + if not callable(supported_functions): + return False + try: + return function_name in supported_functions() + except (AttributeError, TypeError): + return False + + +def record_output(controller, duty, identification_allowed=True): + """Record an applied output when the controller supports adaptive history.""" + method = _supported_method(controller, "set_output") + if method is not None: + method(duty, identification_allowed) + + + +def record_lid_open_transition(controller): + """Record the exact auger-off output enforced for a lid-open event.""" + record_output(controller, 0.0, identification_allowed=False) + + +def identification_allowed(lid_open, manual_override_active, fan_pid_active): + """Return whether applied output can contribute to model identification.""" + return not (lid_open or manual_override_active or fan_pid_active) + + +def manual_override_duty(output): + """Convert a manual auger command into an exact applied duty.""" + return 1.0 if output else 0.0 + + +def controller_reinit_output_seed( + cycle_ratio, + lid_open, + manual_override_active, + fan_pid_active, + auger_output, +): + """Return the applied output and identification gate for a replacement PID.""" + if manual_override_active: + return manual_override_duty(auger_output), False + return cycle_ratio, identification_allowed( + lid_open, manual_override_active, fan_pid_active + ) + +def restore_model(controller, store, name): + """Restore a persisted trusted model only for an adaptive controller.""" + method = _supported_method(controller, "restore_model") + if method is None: + return False + + snapshot = store.load(name) + if snapshot is None: + return False + return bool(method(snapshot)) + + +def stage_model(controller, store, name): + """Stage a controller's current trusted model when it is available.""" + method = _supported_method(controller, "get_model_snapshot") + if method is None: + return False + + snapshot = method() + if snapshot is None: + return False + return bool(store.stage(name, snapshot)) + + +def diagnostics(controller): + """Return adaptive diagnostics or preserve the legacy plain-controller payload.""" + method = _supported_method(controller, "get_status") + if method is not None: + return method() + return dict(controller.__dict__) + + +def apply_live_hold_target(controller, active_mode, control): + """Apply only a target-only Hold update without consuming other changes.""" + if active_mode != "Hold" or control.get("mode") != "Hold": + return False + if ( + not control.get("updated") + or control.get("units_change") is not False + or control.get("controller_update", False) + ): + return False + if "primary_setpoint" not in control or not hasattr(controller, "set_point"): + return False + + target = control["primary_setpoint"] + if target == controller.set_point: + return False + + method = _supported_method(controller, "set_target") + if method is None: + return False + + method(target) + control["updated"] = False + return True + + +def apply_live_hold_target_and_restart_cycle(controller, active_mode, control, now): + """Apply a live Hold target and return its new PID cycle start time.""" + if not apply_live_hold_target(controller, active_mode, control): + return None + return now + + +def hold_pid_update_due(now, controller_cycle_start, cycle_time): + """Return whether a Hold PID update is due.""" + return now - controller_cycle_start > cycle_time + + +def normal_pid_output_recording_allowed(manual_override_until, now): + """Return whether a PID output may replace manual auger history.""" + return manual_override_until < now + + +def _supported_method(controller, function_name): + if not supports(controller, function_name): + return None + method = getattr(controller, function_name, None) + return method if callable(method) else None diff --git a/tests/test_adaptive_controller_state.py b/tests/test_adaptive_controller_state.py new file mode 100644 index 000000000..e7f73f716 --- /dev/null +++ b/tests/test_adaptive_controller_state.py @@ -0,0 +1,427 @@ +import json +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import patch + +# The production package initializer imports optional application dependencies. +# These focused stdlib-only tests load the new submodule without those imports. +common_package = types.ModuleType("common") +common_package.__path__ = [str(Path(__file__).parents[1] / "common")] +sys.modules.setdefault("common", common_package) + +from common.adaptive_controller_state import AdaptiveControllerStateStore +import controller.runtime as controller_runtime +from controller.smith_predictor import AdaptiveFOPDTIdentifier +from controller.runtime import ( + apply_live_hold_target, + apply_live_hold_target_and_restart_cycle, + controller_reinit_output_seed, + diagnostics, + identification_allowed, + hold_pid_update_due, + manual_override_duty, + normal_pid_output_recording_allowed, + record_output, + restore_model, + stage_model, + supports, +) + + +def trusted_snapshot(revision=1): + return { + "version": 1, + "gain_f_per_duty": 647.0588235294117, + "tau_seconds": 4705.882352941177, + "theta_seconds": 35.0, + "confidence": 0.95, + "residual": 0.01, + "observations": 500, + "revision": revision, + } + + +class FakeClock: + def __init__(self): + self.now = 0.0 + + def __call__(self): + return self.now + + +class FakeAdaptiveController: + def __init__(self, set_point=250.0): + self.set_point = set_point + self.outputs = [] + self.restored = [] + self.targets = [] + self.snapshot = trusted_snapshot() + + def supported_functions(self): + return ( + "set_output", + "get_model_snapshot", + "restore_model", + "get_status", + "set_target", + ) + + def set_output(self, duty, identification_allowed=True): + self.outputs.append((duty, identification_allowed)) + + def get_model_snapshot(self): + return dict(self.snapshot) + + def restore_model(self, snapshot): + self.restored.append(dict(snapshot)) + return True + + def get_status(self): + return {"adaptive": True, "revision": self.snapshot["revision"]} + + def set_target(self, target): + self.targets.append(target) + self.set_point = target + + +class PlainController: + def __init__(self): + self.name = "plain" + self.set_point = 225.0 + + def supported_functions(self): + return ("update",) + + def set_output(self, *_args, **_kwargs): + raise AssertionError("unsupported output hook was called") + + def get_model_snapshot(self): + raise AssertionError("unsupported snapshot hook was called") + + def restore_model(self, _snapshot): + raise AssertionError("unsupported restore hook was called") + + def get_status(self): + raise AssertionError("unsupported status hook was called") + + +class StoreSpy: + def __init__(self, snapshot=None): + self.snapshot = snapshot + self.loaded_names = [] + self.staged = [] + + def load(self, name): + self.loaded_names.append(name) + return self.snapshot + + def stage(self, name, snapshot): + self.staged.append((name, dict(snapshot))) + return True + + +class AdaptiveControllerStateStoreTests(unittest.TestCase): + def test_state_store_round_trips_without_age_expiry(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "adaptive.json" + clock = FakeClock() + store = AdaptiveControllerStateStore(path, clock) + self.assertTrue(store.stage("pid_sp", trusted_snapshot())) + self.assertTrue(store.flush(force=True)) + + clock.now = 10 * 365 * 24 * 3600 + restored = AdaptiveControllerStateStore(path, clock).load("pid_sp") + + self.assertEqual(restored, trusted_snapshot()) + + def test_state_store_throttles_then_force_flushes_atomically(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "adaptive.json" + clock = FakeClock() + store = AdaptiveControllerStateStore(path, clock) + self.assertTrue(store.stage("pid_sp", trusted_snapshot())) + self.assertTrue(store.flush()) + + clock.now = 10.0 + self.assertTrue(store.stage("pid_sp", trusted_snapshot(revision=2))) + self.assertFalse(store.flush()) + self.assertTrue(store.flush(force=True)) + + self.assertEqual(store.load("pid_sp"), trusted_snapshot(revision=2)) + self.assertFalse(list(path.parent.glob(path.name + ".tmp-*"))) + + def test_stage_ignores_non_newer_revisions(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "adaptive.json" + store = AdaptiveControllerStateStore(path, FakeClock()) + self.assertTrue(store.stage("pid_sp", trusted_snapshot(revision=2))) + self.assertFalse(store.stage("pid_sp", trusted_snapshot(revision=1))) + self.assertFalse(store.stage("pid_sp", trusted_snapshot(revision=2))) + self.assertTrue(store.flush(force=True)) + + self.assertEqual( + AdaptiveControllerStateStore(path).load("pid_sp"), + trusted_snapshot(revision=2), + ) + + def test_corrupt_or_invalid_root_state_is_ignored(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "adaptive.json" + for state in ( + "not json", + json.dumps({"version": 2, "models": {"pid_sp": trusted_snapshot()}}), + json.dumps( + { + "version": 1, + "models": { + "pid_sp": dict( + trusted_snapshot(), transient_temperature=225.0 + ) + }, + } + ), + ): + with self.subTest(state=state): + path.write_text(state) + self.assertIsNone( + AdaptiveControllerStateStore(path).load("pid_sp") + ) + + def test_corrupt_file_allows_immediate_recovery_write(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "adaptive.json" + path.write_text("not json") + clock = FakeClock() + store = AdaptiveControllerStateStore(path, clock) + + self.assertTrue(store.stage("pid_sp", trusted_snapshot())) + self.assertTrue(store.flush()) + self.assertEqual(store.load("pid_sp"), trusted_snapshot()) + + def test_failed_replace_removes_temporary_file_and_keeps_pending_model(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "adaptive.json" + store = AdaptiveControllerStateStore(path, FakeClock()) + self.assertTrue(store.stage("pid_sp", trusted_snapshot())) + + with self.assertLogs( + "common.adaptive_controller_state", level="ERROR" + ) as logged: + with patch( + "common.adaptive_controller_state.os.replace", + side_effect=OSError("replace failed"), + ): + self.assertFalse(store.flush(force=True)) + + self.assertIn("replace failed", "\n".join(logged.output)) + self.assertEqual(store._pending, {"pid_sp": trusted_snapshot()}) + self.assertFalse(list(path.parent.glob(path.name + ".tmp-*"))) + self.assertTrue(store.flush(force=True)) + self.assertEqual(store.load("pid_sp"), trusted_snapshot()) + + +class RuntimeAdapterTests(unittest.TestCase): + def test_runtime_uses_optional_hooks_and_json_status(self): + controller = FakeAdaptiveController() + + self.assertTrue(supports(controller, "set_output")) + record_output(controller, 0.4, False) + + self.assertEqual(controller.outputs[-1], (0.4, False)) + self.assertEqual(diagnostics(controller), controller.get_status()) + + def test_runtime_restores_and_stages_only_adaptive_snapshots(self): + controller = FakeAdaptiveController() + store = StoreSpy(trusted_snapshot()) + + self.assertTrue(restore_model(controller, store, "pid_sp")) + self.assertEqual(store.loaded_names, ["pid_sp"]) + self.assertEqual(controller.restored, [trusted_snapshot()]) + self.assertTrue(stage_model(controller, store, "pid_sp")) + self.assertEqual(store.staged, [("pid_sp", trusted_snapshot())]) + + def test_plain_controllers_are_untouched_and_keep_existing_diagnostics(self): + controller = PlainController() + store = StoreSpy(trusted_snapshot()) + + self.assertFalse(supports(controller, "set_output")) + self.assertIsNone(record_output(controller, 0.4, False)) + self.assertFalse(restore_model(controller, store, "pid_sp")) + self.assertFalse(stage_model(controller, store, "pid_sp")) + self.assertEqual(diagnostics(controller), dict(controller.__dict__)) + self.assertEqual(store.loaded_names, []) + self.assertEqual(store.staged, []) + + def test_identification_gate_requires_every_override_to_be_inactive(self): + cases = ( + (False, False, False, True), + (False, False, True, False), + (False, True, False, False), + (False, True, True, False), + (True, False, False, False), + (True, False, True, False), + (True, True, False, False), + (True, True, True, False), + ) + + for lid_open, manual_override_active, fan_pid_active, expected in cases: + with self.subTest( + lid_open=lid_open, + manual_override_active=manual_override_active, + fan_pid_active=fan_pid_active, + ): + self.assertIs( + identification_allowed( + lid_open, manual_override_active, fan_pid_active + ), + expected, + ) + + def test_manual_override_duty_is_exactly_binary(self): + self.assertEqual(manual_override_duty(False), 0.0) + self.assertEqual(manual_override_duty(True), 1.0) + + def test_lid_open_transition_disables_next_identifier_interval_until_fresh_command( + self, + ): + clock = FakeClock() + identifier = AdaptiveFOPDTIdentifier( + "F", clock, delay_candidates=(0.0,) + ) + + class IdentifierOutputController: + def __init__(self): + self.outputs = [] + + def supported_functions(self): + return ("set_output",) + + def set_output(self, duty, identification_allowed=True): + self.outputs.append((duty, identification_allowed)) + identifier.record_output(duty, identification_allowed) + + controller = IdentifierOutputController() + identifier.record_output(0.4) + identifier.observe(250.0) + + clock.now = 5.0 + controller_runtime.record_lid_open_transition(controller) + + self.assertEqual(controller.outputs, [(0.0, False)]) + clock.now = 10.0 + identifier.observe(240.0) + self.assertEqual(identifier.status()["accepted_observations"], 0) + + identifier.record_output(0.4) + clock.now = 20.0 + identifier.observe(242.0) + self.assertEqual(identifier.status()["accepted_observations"], 1) + + def test_live_hold_target_update_only_handles_target_only_hold_change(self): + controller = FakeAdaptiveController(set_point=250.0) + control = { + "updated": True, + "mode": "Hold", + "primary_setpoint": 275.0, + "units_change": False, + } + + self.assertTrue(apply_live_hold_target(controller, "Hold", control)) + self.assertEqual(controller.targets, [275.0]) + self.assertFalse(control["updated"]) + + control.update(updated=True, mode="Stop") + self.assertFalse(apply_live_hold_target(controller, "Hold", control)) + self.assertTrue(control["updated"]) + + def test_live_hold_target_update_leaves_other_updates_for_work_cycle(self): + controller = FakeAdaptiveController(set_point=250.0) + for control in ( + { + "updated": True, + "mode": "Hold", + "primary_setpoint": 250.0, + "units_change": False, + }, + { + "updated": True, + "mode": "Hold", + "primary_setpoint": 275.0, + "units_change": True, + }, + { + "updated": True, + "mode": "Hold", + "primary_setpoint": 275.0, + "units_change": False, + "controller_update": True, + }, + ): + with self.subTest(control=control): + self.assertFalse(apply_live_hold_target(controller, "Hold", control)) + self.assertTrue(control["updated"]) + self.assertEqual(controller.targets, []) + + + def test_live_target_update_restarts_hold_pid_cycle(self): + controller = FakeAdaptiveController(set_point=250.0) + control = { + "updated": True, + "mode": "Hold", + "primary_setpoint": 275.0, + "units_change": False, + } + now = 500.0 + + restart_at = apply_live_hold_target_and_restart_cycle( + controller, "Hold", control, now + ) + + self.assertEqual(restart_at, now) + self.assertEqual(controller.targets, [275.0]) + self.assertFalse(control["updated"]) + self.assertFalse(hold_pid_update_due(now + 30.0, restart_at, 30.0)) + self.assertTrue(hold_pid_update_due(now + 30.001, restart_at, 30.0)) + + def test_pid_tick_keeps_manual_output_until_auger_override_expires(self): + now = 100.0 + + self.assertTrue(hold_pid_update_due(now, 70.0, 20.0)) + self.assertFalse(normal_pid_output_recording_allowed(110.0, now)) + self.assertFalse(normal_pid_output_recording_allowed(now, now)) + self.assertTrue(normal_pid_output_recording_allowed(99.999, now)) + + def test_reinit_seed_uses_active_manual_auger_duty(self): + for auger_output, expected_duty in ((False, 0.0), (True, 1.0)): + with self.subTest(auger_output=auger_output): + self.assertEqual( + controller_reinit_output_seed( + 0.42, False, True, False, auger_output + ), + (expected_duty, False), + ) + + def test_reinit_seed_uses_lid_and_fan_identification_gate_when_manual_is_idle( + self, + ): + for lid_open, fan_pid_active, expected_allowed in ( + (False, False, True), + (False, True, False), + (True, False, False), + (True, True, False), + ): + with self.subTest( + lid_open=lid_open, fan_pid_active=fan_pid_active + ): + self.assertEqual( + controller_reinit_output_seed( + 0.42, lid_open, False, fan_pid_active, True + ), + (0.42, expected_allowed), + ) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pid_sp.py b/tests/test_pid_sp.py new file mode 100644 index 000000000..123acb125 --- /dev/null +++ b/tests/test_pid_sp.py @@ -0,0 +1,517 @@ +import math +import json +import unittest +from pathlib import Path +from unittest.mock import patch + +from controller.pid_sp import Controller +from controller.smith_predictor import FOPDTModel + + +CONFIG = { + "PB": 60.0, + "Ti": 180.0, + "Td": 45.0, + "center_factor": 0.001, + "stable_window": 12.0, +} +CYCLE_DATA = { + "HoldCycleTime": 15, + "u_min": 0.05, + "u_max": 0.90, +} + + +def update_at(controller, second, temperature): + with patch("controller.pid_sp.time.time", return_value=float(second)): + return controller.update(float(temperature)) + + +def trusted_snapshot( + gain=647.0588235294117, + tau=4705.882352941177, + theta=35.0, +): + return { + "version": 1, + "gain_f_per_duty": gain, + "tau_seconds": tau, + "theta_seconds": theta, + "confidence": 0.95, + "residual": 0.01, + "observations": 500, + "revision": 1, + } + + +class PidSmithPredictorTests(unittest.TestCase): + def make_controller(self, config=None): + return Controller(CONFIG if config is None else config, "F", CYCLE_DATA) + + def test_first_sample_has_zero_derivative(self): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.set_target(225.0) + with patch("controller.pid_sp.time.time", return_value=15.0): + controller.update(225.0) + + self.assertEqual(controller.d, 0.0) + self.assertEqual(controller.last, 225.0) + + def test_initial_target_uses_first_real_sample_for_later_reset_logic(self): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.set_target(350.0) + for second, temperature in ((15.0, 300.0), (30.0, 330.0), (45.0, 340.0)): + with patch("controller.pid_sp.time.time", return_value=second): + controller.update(temperature) + + self.assertEqual(controller.start_change_temp, 300.0) + self.assertIsInstance(controller.u, float) + + def test_output_limited_approach_keeps_integral_damped_while_rising(self): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.set_target(600.0) + + for second, temperature in ((15, 550), (30, 575), (45, 575)): + update_at(controller, second, temperature) + + self.assertTrue(controller.output_limited_approach) + self.assertTrue(controller.new_target) + self.assertLessEqual( + abs(controller.inter), + abs(controller.error * controller.cycle_time), + ) + + update_at(controller, 60, 577.0) + self.assertEqual(controller.slow_approach_samples, 0) + self.assertTrue(controller.new_target) + + def test_output_limited_approach_releases_after_three_slow_samples(self): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.set_target(600.0) + + for second, temperature in ( + (15, 550), + (30, 575), + (45, 575), + (60, 575), + (75, 575), + ): + update_at(controller, second, temperature) + + self.assertFalse(controller.output_limited_approach) + self.assertFalse(controller.new_target) + self.assertEqual(controller.slow_approach_samples, 0) + + def test_fast_sample_breaks_slow_approach_confirmation(self): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.set_target(600.0) + + for second, temperature in ( + (15, 550), + (30, 575), + (45, 575), + (60, 575), + ): + update_at(controller, second, temperature) + self.assertGreater(controller.slow_approach_samples, 0) + + update_at(controller, 75, 575.18) + self.assertEqual(controller.slow_approach_samples, 0) + self.assertTrue(controller.new_target) + def test_slow_approach_scaled_rate_boundary_confirms_exactly(self): + for exact_boundary in (True, False): + with self.subTest(exact_boundary=exact_boundary): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.set_target(600.0) + + for second, temperature in ( + (15, 550), + (30, 575), + (45, 575), + (60, 575), + ): + update_at(controller, second, temperature) + self.assertTrue(controller.output_limited_approach) + self.assertEqual(controller.slow_approach_samples, 2) + + boundary_temperature = 575.0 + ( + controller._target_capture_band() + / (2.0 * controller.ti) + * controller.cycle_time + ) + temperature = ( + boundary_temperature + if exact_boundary + else math.nextafter(boundary_temperature, math.inf) + ) + update_at(controller, 75, temperature) + + self.assertEqual(controller.slow_approach_samples, 0) + if exact_boundary: + self.assertFalse(controller.output_limited_approach) + self.assertFalse(controller.new_target) + else: + self.assertTrue(controller.output_limited_approach) + self.assertTrue(controller.new_target) + def test_halfway_transition_remains_active_when_derivative_reduces_output( + self, + ): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.set_target(600.0) + + for second, temperature in ((15.0, 550.0), (30.0, 565.0), (45.0, 575.0)): + with patch("controller.pid_sp.time.time", return_value=second): + controller.update(temperature) + + u_max = controller.cycle_data.get("u_max", 1.0) + self.assertGreaterEqual(controller.p, u_max) + self.assertLess(controller.d, 0.0) + self.assertLess(controller.u, u_max) + self.assertTrue(controller.new_target) + self.assertEqual(controller.inter, -25.0 * controller.cycle_time) + self.assertFalse(controller.output_limited_approach) + self.assertEqual(controller.slow_approach_samples, 0) + + def test_capture_band_and_target_crossing_clear_approach_state(self): + for temperature in (598.0, 604.0): + with self.subTest(temperature=temperature): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.set_target(600.0) + controller.output_limited_approach = True + controller.slow_approach_samples = 2 + controller.previous_controller_input = 590.0 + controller.last_update = 0.0 + + update_at(controller, 15, temperature) + + self.assertFalse(controller.new_target) + self.assertFalse(controller.output_limited_approach) + self.assertEqual(controller.slow_approach_samples, 0) + + def test_set_target_clears_approach_state(self): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.output_limited_approach = True + controller.slow_approach_samples = 2 + + with patch("controller.pid_sp.time.time", return_value=15.0): + controller.set_target(350.0) + + self.assertFalse(controller.output_limited_approach) + self.assertEqual(controller.slow_approach_samples, 0) + + def test_capture_and_slow_rate_thresholds_scale_between_units(self): + fahrenheit = self.make_controller() + celsius = Controller(CONFIG, "C", CYCLE_DATA) + + self.assertAlmostEqual(fahrenheit._target_capture_band(), 3.0) + self.assertAlmostEqual(celsius._target_capture_band(), 3.0 * 5.0 / 9.0) + self.assertAlmostEqual( + celsius._target_capture_band() / celsius.ti, + (fahrenheit._target_capture_band() / fahrenheit.ti) * 5.0 / 9.0, + ) + + def test_zero_ti_completes_output_limited_halfway_without_division(self): + config = dict(CONFIG) + config["Ti"] = 0.0 + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller(config) + controller.set_target(600.0) + + for second, temperature in ((15, 550), (30, 575), (45, 575)): + output = update_at(controller, second, temperature) + + self.assertTrue(math.isfinite(output)) + self.assertFalse(controller.output_limited_approach) + self.assertFalse(controller.new_target) + self.assertEqual(controller.i, 0.0) + def test_set_gains_zero_ti_completes_active_approach_without_division( + self, + ): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.set_target(600.0) + + for second, temperature in ((15, 550), (30, 575), (45, 575)): + update_at(controller, second, temperature) + self.assertTrue(controller.output_limited_approach) + + controller.set_gains(CONFIG["PB"], 0.0, CONFIG["Td"]) + output = update_at(controller, 60, 550) + + self.assertTrue(math.isfinite(output)) + self.assertFalse(controller.output_limited_approach) + self.assertFalse(controller.new_target) + self.assertEqual(controller.i, 0.0) + + def test_set_gains_zero_ti_clears_active_approach_below_proportional_band( + self, + ): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.set_target(600.0) + + for second, temperature in ((15, 550), (30, 575), (45, 575)): + update_at(controller, second, temperature) + self.assertTrue(controller.output_limited_approach) + + controller.set_gains(CONFIG["PB"], 0.0, CONFIG["Td"]) + output = update_at(controller, 60, 500) + + self.assertTrue(math.isfinite(output)) + self.assertFalse(controller.output_limited_approach) + self.assertFalse(controller.new_target) + + def test_output_limited_approach_counts_slow_rate_below_proportional_band( + self, + ): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.set_target(600.0) + + for second, temperature in ((15, 550), (30, 575), (45, 575)): + update_at(controller, second, temperature) + self.assertTrue(controller.output_limited_approach) + + update_at(controller, 60, 577) + self.assertEqual(controller.slow_approach_samples, 0) + self.assertTrue(controller.new_target) + + update_at(controller, 75, 500) + + self.assertEqual(controller.slow_approach_samples, 1) + self.assertTrue(controller.output_limited_approach) + self.assertTrue(controller.new_target) + + def test_negative_ti_constructor_disables_integral_and_completes_transition( + self, + ): + config = dict(CONFIG) + config["Ti"] = -1.0 + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller(config) + controller.set_target(600.0) + + for second, temperature in ((15, 550), (30, 575), (45, 575)): + output = update_at(controller, second, temperature) + + self.assertTrue(math.isfinite(output)) + self.assertEqual(controller.ki, 0.0) + self.assertEqual(controller.i, 0.0) + self.assertFalse(controller.output_limited_approach) + self.assertFalse(controller.new_target) + + def test_set_gains_negative_ti_disables_integral_and_completes_active_approach( + self, + ): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.set_target(600.0) + + for second, temperature in ((15, 550), (30, 575), (45, 575)): + update_at(controller, second, temperature) + self.assertTrue(controller.output_limited_approach) + + controller.set_gains(CONFIG["PB"], -1.0, CONFIG["Td"]) + self.assertEqual(controller.inter, 0.0) + output = update_at(controller, 60, 500) + + self.assertTrue(math.isfinite(output)) + self.assertEqual(controller.ki, 0.0) + self.assertEqual(controller.i, 0.0) + self.assertFalse(controller.output_limited_approach) + self.assertFalse(controller.new_target) + + def test_startup_reduction_scales_newly_calculated_output(self): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.set_target(100.0) + controller.last = 100.0 + controller.last_update = 0.0 + with patch("controller.pid_sp.time.time", return_value=15.0): + output = controller.update(100.0) + + self.assertAlmostEqual( + output, + (controller.p + controller.i + controller.d) * 0.65, + ) + + def test_integral_accumulator_is_bounded(self): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.set_target(100.0) + controller.set_gains(CONFIG["PB"], CONFIG["Ti"], CONFIG["Td"]) + controller.last = 99.0 + for second in range(15, 3_015, 15): + with patch("controller.pid_sp.time.time", return_value=float(second)): + controller.update(99.0) + + self.assertLessEqual(abs(controller.inter), controller.inter_max) + + def test_adaptive_hooks_are_supported(self): + controller = self.make_controller() + + self.assertTrue( + { + "set_output", + "get_model_snapshot", + "restore_model", + "get_status", + }.issubset(controller.supported_functions()) + ) + + def test_measured_temperature_is_used_before_model_trust(self): + controller = self.make_controller() + controller.set_target(250.0) + controller.update(240.0) + + self.assertFalse(controller.get_status()["prediction_active"]) + self.assertEqual(controller.get_status()["controller_input_temperature"], 240.0) + + def test_promotion_keeps_prediction_active_when_clock_advances(self): + with patch( + "controller.pid_sp.time.time", + side_effect=(0.0, 0.0, 0.0), + ): + controller = self.make_controller() + model = FOPDTModel( + 647.0588235294117, + 4705.882352941177, + 35.0, + 0.95, + 0.01, + 500, + 1, + ) + controller._identifier.observe = lambda current, timestamp: model + + with patch( + "controller.pid_sp.time.time", + side_effect=(15.0, 16.0, 17.0), + ): + controller.update(240.0) + + self.assertTrue(controller.get_status()["prediction_active"]) + + + def test_live_target_uses_selected_input_for_outside_band_crossing(self): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + self.assertTrue(controller.restore_model(trusted_snapshot())) + controller.previous_controller_input = 590.0 + controller.last = 610.0 + controller.set_target(600.0) + + self.assertTrue(controller.get_status()["prediction_active"]) + with patch.object(controller._predictor, "update", return_value=604.0): + update_at(controller, 15, 596.0) + + self.assertFalse(controller.new_target) + self.assertFalse(controller.output_limited_approach) + + def test_first_selected_input_sets_halfway_transition_boundary(self): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + self.assertTrue(controller.restore_model(trusted_snapshot())) + controller.set_target(600.0) + + self.assertTrue(controller.get_status()["prediction_active"]) + with patch.object( + controller._predictor, + "update", + side_effect=(550.0, 570.0, 570.0), + ): + for second in (15, 30, 45): + update_at(controller, second, 530.0) + + self.assertEqual(controller.start_change_temp, 550.0) + self.assertFalse(controller.output_limited_approach) + self.assertTrue(controller.new_target) + + def test_restored_model_drives_one_temperature_for_p_i_and_d(self): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + self.assertTrue(controller.restore_model(trusted_snapshot())) + controller.set_target(250.0) + controller.set_output(0.2) + with patch("controller.pid_sp.time.time", return_value=15.0): + controller.update(240.0) + controller.set_output(0.8) + previous_selected = controller.get_status()["controller_input_temperature"] + with patch("controller.pid_sp.time.time", return_value=60.0): + controller.update(242.0) + status = controller.get_status() + selected = status["controller_input_temperature"] + + self.assertAlmostEqual( + controller.p, + controller.kp * (selected - 250.0) + controller.center, + ) + self.assertAlmostEqual( + controller.derv, + (selected - previous_selected) / 45.0, + ) + expected_selected_inter = ( + (previous_selected - 250.0) * 15.0 + + (selected - 250.0) * 45.0 + ) + expected_measured_inter = (240.0 - 250.0) * 15.0 + ( + 242.0 - 250.0 + ) * 45.0 + self.assertAlmostEqual(controller.inter, expected_selected_inter) + self.assertAlmostEqual( + controller.i, controller.ki * expected_selected_inter + ) + self.assertNotAlmostEqual(controller.inter, expected_measured_inter) + self.assertNotAlmostEqual( + controller.i, controller.ki * expected_measured_inter + ) + + def test_set_target_preserves_model_and_identifier_state(self): + controller = self.make_controller() + self.assertTrue(controller.restore_model(trusted_snapshot())) + controller.set_output(0.3) + before = controller.get_model_snapshot() + accepted = controller.get_status()["accepted_observations"] + + controller.set_target(350.0) + + self.assertEqual(controller.get_model_snapshot(), before) + self.assertEqual(controller.get_status()["accepted_observations"], accepted) + + def test_snapshot_validation_and_status_are_json_safe(self): + controller = self.make_controller() + for snapshot in ( + None, + {}, + dict(trusted_snapshot(), tau_seconds=0.0), + dict(trusted_snapshot(), version=2), + ): + with self.subTest(snapshot=snapshot): + self.assertFalse(controller.restore_model(snapshot)) + + self.assertTrue(controller.restore_model(trusted_snapshot())) + self.assertEqual(controller.get_model_snapshot(), trusted_snapshot()) + json.dumps(controller.get_status(), allow_nan=False) + json.dumps(controller.get_model_snapshot(), allow_nan=False) + + def test_metadata_no_longer_exposes_fixed_model_parameters(self): + metadata = json.loads(Path("controller/controllers.json").read_text())[ + "metadata" + ]["pid_sp"] + option_names = {option["option_name"] for option in metadata["config"]} + + self.assertNotIn("tau", option_names) + self.assertNotIn("theta", option_names) + + +if __name__ == "__main__": + unittest.main() From aeff951fc3a40b401de6d7e01088fa580677d420 Mon Sep 17 00:00:00 2001 From: Daniel Berlin Date: Sun, 26 Jul 2026 18:44:20 -0400 Subject: [PATCH 3/4] Add deterministic PID controller simulator --- pid_simulator.py | 862 ++++++++++++++++++++++++++++++++++++ tests/test_pid_simulator.py | 649 +++++++++++++++++++++++++++ 2 files changed, 1511 insertions(+) create mode 100755 pid_simulator.py create mode 100644 tests/test_pid_simulator.py diff --git a/pid_simulator.py b/pid_simulator.py new file mode 100755 index 000000000..7a3383432 --- /dev/null +++ b/pid_simulator.py @@ -0,0 +1,862 @@ +#!/usr/bin/env python3 + +import argparse +import csv +import importlib +import json +import logging +import math +import sys +import types +from collections import deque +from contextlib import contextmanager +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Generator, Optional, Sequence + +from controller import runtime as controller_runtime + + +CONTROLLER_NAMES = ( + "pid", + "pid_clamping", + "pid_clamping_percent_pb", + "pid_parallel", + "pid_ac", + "pid_sp", +) +SETPOINT_MODES = ("production-reset", "continuous") +U_MIN = 0.05 +U_MAX = 0.9 +SETTLING_WINDOW_SECONDS = 10 * 60 + + +@dataclass(frozen=True) +class PlantConfig: + thermal_mass: float = 400.0 + heat_input_per_second: float = 55.0 + heat_loss_coefficient: float = 0.085 + ambient_f: float = 70.0 + firebox_delay_seconds: int = 35 + + + +PLANT_PROFILES = { + "small": PlantConfig(250.0, 48.0, 0.075, 70.0, 20), + "medium": PlantConfig(400.0, 55.0, 0.085, 70.0, 35), + "large": PlantConfig(650.0, 70.0, 0.100, 70.0, 50), +} + +@dataclass(frozen=True) +class Scenario: + name: str + duration_seconds: int + initial_pit_f: float + transitions: tuple[tuple[int, float], ...] + + def setpoint_at(self, second: int) -> float: + target = self.transitions[0][1] + for transition_second, transition_target in self.transitions: + if transition_second > second: + break + target = transition_target + return target + + +@dataclass(frozen=True) +class Sample: + second: int + setpoint_f: float + pit_temp_f: float + duty_ratio: float + auger_fraction: float + auger_on: bool + setpoint_mode: str + model_applied_duty: float + prediction_active: bool + predicted_temperature: Optional[float] + estimated_gain_f_per_duty: Optional[float] + estimated_tau_seconds: Optional[float] + estimated_theta_seconds: Optional[float] + model_confidence: Optional[float] + model_residual: Optional[float] + + +@dataclass(frozen=True) +class SegmentMetrics: + segment_number: int + target_f: float + start_second: int + end_second: int + integrated_absolute_error: float + percent_within_five_f: float + max_overshoot: float + settling_time_minutes: Optional[float] + mean_duty_ratio: float + + +@dataclass(frozen=True) +class SimulationResult: + scenario_name: str + controller_name: str + setpoint_mode: str + integrated_absolute_error: float + percent_within_five_f: float + max_overshoot: float + mean_duty_ratio: float + segment_metrics: tuple[SegmentMetrics, ...] + samples: tuple[Sample, ...] + controller_update_seconds: tuple[int, ...] + controller_start_seconds: tuple[int, ...] + model_applied_duty: float + prediction_active: bool + predicted_temperature: Optional[float] + estimated_gain_f_per_duty: Optional[float] + estimated_tau_seconds: Optional[float] + estimated_theta_seconds: Optional[float] + model_confidence: Optional[float] + model_residual: Optional[float] + identifier_activation_second: Optional[int] + + +SCENARIOS = { + "250": Scenario( + "250", + 14_400, + 200.0, + ((0, 250.0), (5_400, 275.0), (10_800, 250.0)), + ), + "350": Scenario( + "350", + 14_400, + 300.0, + ((0, 350.0), (5_400, 325.0), (10_800, 350.0)), + ), + "450": Scenario( + "450", + 14_400, + 400.0, + ((0, 450.0), (5_400, 425.0), (10_800, 450.0)), + ), + "600": Scenario( + "600", + 14_400, + 550.0, + ((0, 600.0),), + ), +} + + +def build_identification_scenario(plant: PlantConfig) -> Scenario: + tau_seconds = plant.thermal_mass / plant.heat_loss_coefficient + return Scenario( + "identification", + round(4.5 * tau_seconds), + 200.0, + ( + (0, 250.0), + (round(tau_seconds), 350.0), + (round(2.0 * tau_seconds), 450.0), + (round(3.0 * tau_seconds), 300.0), + ), + ) + + +class SimulationClock: + def __init__(self) -> None: + self.current = 0.0 + + def time(self) -> float: + return self.current + + +def _create_noop_logger(*args, **_) -> logging.Logger: + name = str(args[0]) if args else "controller" + logger = logging.Logger(f"pid_simulator.{name}") + logger.addHandler(logging.NullHandler()) + logger.propagate = False + logger.disabled = True + return logger + + +def load_controller_module(controller_name: str) -> types.ModuleType: + if controller_name not in CONTROLLER_NAMES: + raise ValueError(f"Unknown controller: {controller_name}") + + module_name = f"controller.{controller_name}" + controller_package = importlib.import_module("controller") + had_package_attribute = hasattr(controller_package, controller_name) + previous_package_attribute = getattr(controller_package, controller_name, None) + previous_controller = sys.modules.pop(module_name, None) + previous_common = sys.modules.pop("common", None) + common_shim = types.ModuleType("common") + setattr(common_shim, "create_logger", _create_noop_logger) + sys.modules["common"] = common_shim + + try: + module = importlib.import_module(module_name) + finally: + sys.modules.pop(module_name, None) + if previous_controller is not None: + sys.modules[module_name] = previous_controller + if had_package_attribute: + setattr(controller_package, controller_name, previous_package_attribute) + elif hasattr(controller_package, controller_name): + delattr(controller_package, controller_name) + sys.modules.pop("common", None) + if previous_common is not None: + sys.modules["common"] = previous_common + + return module + + +def _controller_defaults(controller_name: str) -> dict: + metadata_path = Path(__file__).parent / "controller" / "controllers.json" + metadata = json.loads(metadata_path.read_text())["metadata"][controller_name] + return { + option["option_name"]: option["option_default"] for option in metadata["config"] + } + + +@contextmanager +def _module_clock( + controller_module, clock: SimulationClock +) -> Generator[None, None, None]: + clock_modules = [controller_module] + controller_base = sys.modules.get("controller.base") + if controller_base is not None and hasattr(controller_base, "time"): + clock_modules.append(controller_base) + original_times = [(module, module.time) for module in clock_modules] + for module, _ in original_times: + module.time = clock + try: + yield + finally: + for module, original_time in reversed(original_times): + module.time = original_time + + +def _finite_optional_float(value: object) -> Optional[float]: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + value = float(value) + return value if math.isfinite(value) else None + + +def _model_diagnostics( + controller, applied_duty: float +) -> tuple[ + float, + bool, + Optional[float], + Optional[float], + Optional[float], + Optional[float], + Optional[float], + Optional[float], +]: + status = ( + controller_runtime.diagnostics(controller) + if controller_runtime.supports(controller, "get_status") + else {} + ) + if not isinstance(status, dict): + status = {} + return ( + float(applied_duty), + bool(status.get("prediction_active", False)), + _finite_optional_float(status.get("predicted_temperature")), + _finite_optional_float(status.get("estimated_gain_f_per_duty")), + _finite_optional_float(status.get("estimated_tau_seconds")), + _finite_optional_float(status.get("estimated_theta_seconds")), + _finite_optional_float(status.get("model_confidence")), + _finite_optional_float(status.get("model_residual")), + ) + + +def _validate_simulation_inputs( + scenario: Scenario, + plant: PlantConfig, + cycle_seconds: int, + setpoint_mode: str, +) -> None: + if setpoint_mode not in SETPOINT_MODES: + raise ValueError(f"Unknown setpoint mode: {setpoint_mode}") + if cycle_seconds <= 0: + raise ValueError("cycle_seconds must be positive") + if plant.firebox_delay_seconds < 0: + raise ValueError("firebox_delay_seconds must be non-negative") + if not math.isfinite(plant.ambient_f): + raise ValueError("ambient_f must be finite") + if not math.isfinite(plant.thermal_mass) or plant.thermal_mass <= 0: + raise ValueError("thermal_mass must be a positive finite number") + if ( + not math.isfinite(plant.heat_input_per_second) + or plant.heat_input_per_second <= 0 + ): + raise ValueError("heat_input_per_second must be a positive finite number") + if ( + not math.isfinite(plant.heat_loss_coefficient) + or plant.heat_loss_coefficient <= 0 + ): + raise ValueError("heat_loss_coefficient must be a positive finite number") + if not scenario.transitions or scenario.transitions[0][0] != 0: + raise ValueError("scenario must have an initial transition at second zero") + if scenario.duration_seconds <= scenario.transitions[-1][0]: + raise ValueError("scenario duration must extend past its final transition") + + +def _fractional_auger_duty( + second: int, + cycle_start_second: int, + cycle_seconds: int, + duty_ratio: float, +) -> float: + cycle_phase = (second - cycle_start_second) % cycle_seconds + on_window_end = duty_ratio * cycle_seconds + return max(0.0, min(cycle_phase + 1.0, on_window_end) - cycle_phase) + + +def _segment_metrics( + segment_number: int, + target_f: float, + heating_step: bool, + start_second: int, + end_second: int, + samples: Sequence[Sample], +) -> SegmentMetrics: + segment_samples = samples[start_second:end_second] + absolute_errors = [abs(sample.pit_temp_f - target_f) for sample in segment_samples] + integrated_absolute_error = sum(absolute_errors) / 60.0 + percent_within_five_f = ( + 100.0 * sum(error <= 5.0 for error in absolute_errors) / len(segment_samples) + ) + if heating_step: + overshoot_values = (sample.pit_temp_f - target_f for sample in segment_samples) + else: + overshoot_values = (target_f - sample.pit_temp_f for sample in segment_samples) + max_overshoot = max(0.0, max(overshoot_values)) + mean_duty_ratio = sum(sample.auger_fraction for sample in segment_samples) / len( + segment_samples + ) + + settling_time_minutes = None + consecutive_in_band = 0 + for index, error in enumerate(absolute_errors): + consecutive_in_band = consecutive_in_band + 1 if error <= 5.0 else 0 + if consecutive_in_band >= SETTLING_WINDOW_SECONDS: + window_start = index - SETTLING_WINDOW_SECONDS + 1 + settling_time_minutes = window_start / 60.0 + break + + return SegmentMetrics( + segment_number=segment_number, + target_f=target_f, + start_second=start_second, + end_second=end_second, + integrated_absolute_error=integrated_absolute_error, + percent_within_five_f=percent_within_five_f, + max_overshoot=max_overshoot, + settling_time_minutes=settling_time_minutes, + mean_duty_ratio=mean_duty_ratio, + ) + + +def _simulate_with_clock( + controller_name: str, + scenario: Scenario, + plant: PlantConfig, + cycle_seconds: int, + setpoint_mode: str, + controller_module: types.ModuleType, + controller_config: dict, + cycle_data: dict, + clock: SimulationClock, +) -> SimulationResult: + def create_controller(target: float): + controller = controller_module.Controller( + dict(controller_config), + "F", + dict(cycle_data), + ) + controller.set_target(target) + controller_runtime.record_output(controller, U_MIN) + return controller + + current_target = scenario.setpoint_at(0) + controller = create_controller(current_target) + pit_temperature = scenario.initial_pit_f + duty_ratio = U_MIN + cycle_start_second = 0 + next_controller_update = cycle_seconds + controller_start_seconds = [0] + controller_update_seconds = [] + samples = [] + transitions = dict(scenario.transitions) + delay_line = deque([0.0] * plant.firebox_delay_seconds) + model_applied_duty = U_MIN + prediction_active = False + predicted_temperature = None + estimated_gain_f_per_duty = None + estimated_tau_seconds = None + estimated_theta_seconds = None + model_confidence = None + model_residual = None + identifier_activation_second = None + + def refresh_model_diagnostics() -> None: + nonlocal model_applied_duty + nonlocal prediction_active + nonlocal predicted_temperature + nonlocal estimated_gain_f_per_duty + nonlocal estimated_tau_seconds + nonlocal estimated_theta_seconds + nonlocal model_confidence + nonlocal model_residual + nonlocal identifier_activation_second + ( + model_applied_duty, + prediction_active, + predicted_temperature, + estimated_gain_f_per_duty, + estimated_tau_seconds, + estimated_theta_seconds, + model_confidence, + model_residual, + ) = _model_diagnostics(controller, duty_ratio) + if identifier_activation_second is None and prediction_active: + identifier_activation_second = int(clock.current) + + refresh_model_diagnostics() + + for second in range(scenario.duration_seconds): + clock.current = float(second) + + if second != 0 and second in transitions: + current_target = transitions[second] + if setpoint_mode == "production-reset": + controller = create_controller(current_target) + controller_start_seconds.append(second) + duty_ratio = U_MIN + cycle_start_second = second + refresh_model_diagnostics() + else: + controller.set_target(current_target) + next_controller_update = second + cycle_seconds + + if second >= next_controller_update: + raw_output = controller.update(pit_temperature) + if not math.isfinite(raw_output): + raise ValueError( + f"{controller_name} returned non-finite output at second {second}" + ) + duty_ratio = float(min(max(raw_output, U_MIN), U_MAX)) + controller_runtime.record_output(controller, duty_ratio) + refresh_model_diagnostics() + controller_update_seconds.append(second) + cycle_start_second = second + next_controller_update = second + cycle_seconds + + auger_fraction = _fractional_auger_duty( + second, + cycle_start_second, + cycle_seconds, + duty_ratio, + ) + if delay_line: + delayed_auger_fraction = delay_line.popleft() + delay_line.append(auger_fraction) + else: + delayed_auger_fraction = auger_fraction + + heat_input = plant.heat_input_per_second * delayed_auger_fraction + heat_loss = plant.heat_loss_coefficient * (pit_temperature - plant.ambient_f) + pit_temperature += (heat_input - heat_loss) / plant.thermal_mass + if not math.isfinite(pit_temperature): + raise ValueError(f"Plant temperature became non-finite at second {second}") + + samples.append( + Sample( + second=second, + setpoint_f=current_target, + pit_temp_f=pit_temperature, + duty_ratio=duty_ratio, + auger_fraction=auger_fraction, + auger_on=auger_fraction > 0.0, + setpoint_mode=setpoint_mode, + model_applied_duty=model_applied_duty, + prediction_active=prediction_active, + predicted_temperature=predicted_temperature, + estimated_gain_f_per_duty=estimated_gain_f_per_duty, + estimated_tau_seconds=estimated_tau_seconds, + estimated_theta_seconds=estimated_theta_seconds, + model_confidence=model_confidence, + model_residual=model_residual, + ) + ) + + segment_metrics = [] + for index, (start_second, target_f) in enumerate(scenario.transitions): + end_second = ( + scenario.transitions[index + 1][0] + if index + 1 < len(scenario.transitions) + else scenario.duration_seconds + ) + previous_target = ( + scenario.initial_pit_f if index == 0 else scenario.transitions[index - 1][1] + ) + segment_metrics.append( + _segment_metrics( + segment_number=index + 1, + target_f=target_f, + heating_step=target_f >= previous_target, + start_second=start_second, + end_second=end_second, + samples=samples, + ) + ) + + absolute_errors = [abs(sample.pit_temp_f - sample.setpoint_f) for sample in samples] + return SimulationResult( + scenario_name=scenario.name, + controller_name=controller_name, + setpoint_mode=setpoint_mode, + integrated_absolute_error=sum(absolute_errors) / 60.0, + percent_within_five_f=( + 100.0 * sum(error <= 5.0 for error in absolute_errors) / len(samples) + ), + max_overshoot=max(segment.max_overshoot for segment in segment_metrics), + mean_duty_ratio=( + sum(sample.auger_fraction for sample in samples) / len(samples) + ), + segment_metrics=tuple(segment_metrics), + samples=tuple(samples), + controller_update_seconds=tuple(controller_update_seconds), + controller_start_seconds=tuple(controller_start_seconds), + model_applied_duty=model_applied_duty, + prediction_active=prediction_active, + predicted_temperature=predicted_temperature, + estimated_gain_f_per_duty=estimated_gain_f_per_duty, + estimated_tau_seconds=estimated_tau_seconds, + estimated_theta_seconds=estimated_theta_seconds, + model_confidence=model_confidence, + model_residual=model_residual, + identifier_activation_second=identifier_activation_second, + ) + + +def simulate_controller( + controller_name: str, + scenario: Scenario, + plant: PlantConfig, + cycle_seconds: int, + setpoint_mode: str, +) -> SimulationResult: + _validate_simulation_inputs(scenario, plant, cycle_seconds, setpoint_mode) + controller_module = load_controller_module(controller_name) + controller_config = _controller_defaults(controller_name) + cycle_data = { + "HoldCycleTime": cycle_seconds, + "u_min": U_MIN, + "u_max": U_MAX, + } + clock = SimulationClock() + with _module_clock(controller_module, clock): + return _simulate_with_clock( + controller_name, + scenario, + plant, + cycle_seconds, + setpoint_mode, + controller_module, + controller_config, + cycle_data, + clock, + ) + + +def _unique(values: Sequence[str]) -> tuple[str, ...]: + return tuple(dict.fromkeys(values)) + + +def run_scenarios( + controller_names: Optional[Sequence[str]], + scenarios: Sequence[Scenario], + plant: PlantConfig, + cycle_seconds: int, + setpoint_modes: Optional[Sequence[str]] = None, +) -> list[SimulationResult]: + selected_controllers = _unique( + CONTROLLER_NAMES if controller_names is None else controller_names + ) + selected_modes = _unique( + SETPOINT_MODES if setpoint_modes is None else setpoint_modes + ) + + unknown_controllers = set(selected_controllers) - set(CONTROLLER_NAMES) + if unknown_controllers: + raise ValueError(f"Unknown controllers: {sorted(unknown_controllers)}") + unknown_modes = set(selected_modes) - set(SETPOINT_MODES) + if unknown_modes: + raise ValueError(f"Unknown setpoint modes: {sorted(unknown_modes)}") + + results = [ + simulate_controller( + controller_name=controller_name, + scenario=scenario, + plant=plant, + cycle_seconds=cycle_seconds, + setpoint_mode=setpoint_mode, + ) + for scenario in scenarios + for setpoint_mode in selected_modes + for controller_name in selected_controllers + ] + scenario_order = {scenario.name: index for index, scenario in enumerate(scenarios)} + mode_order = {mode: index for index, mode in enumerate(selected_modes)} + results.sort( + key=lambda result: ( + scenario_order[result.scenario_name], + mode_order[result.setpoint_mode], + result.integrated_absolute_error, + result.controller_name, + ) + ) + return results + + +def _csv_optional_float(value: Optional[float]) -> str: + return "" if value is None else f"{value:.6f}" + + +def write_csv(path: Path, results: Sequence[SimulationResult]) -> None: + fieldnames = ( + "scenario", + "controller", + "setpoint_mode", + "second", + "setpoint_f", + "pit_temp_f", + "duty_ratio", + "auger_fraction", + "auger_on", + "model_applied_duty", + "prediction_active", + "predicted_temperature", + "estimated_gain_f_per_duty", + "estimated_tau_seconds", + "estimated_theta_seconds", + "model_confidence", + "model_residual", + ) + with Path(path).open("w", newline="") as output_file: + writer = csv.DictWriter(output_file, fieldnames=fieldnames) + writer.writeheader() + for result in results: + for sample in result.samples: + writer.writerow( + { + "scenario": result.scenario_name, + "controller": result.controller_name, + "setpoint_mode": result.setpoint_mode, + "second": sample.second, + "setpoint_f": f"{sample.setpoint_f:.3f}", + "pit_temp_f": f"{sample.pit_temp_f:.6f}", + "duty_ratio": f"{sample.duty_ratio:.6f}", + "auger_fraction": f"{sample.auger_fraction:.6f}", + "auger_on": int(sample.auger_on), + "model_applied_duty": f"{sample.model_applied_duty:.6f}", + "prediction_active": int(sample.prediction_active), + "predicted_temperature": _csv_optional_float( + sample.predicted_temperature + ), + "estimated_gain_f_per_duty": _csv_optional_float( + sample.estimated_gain_f_per_duty + ), + "estimated_tau_seconds": _csv_optional_float( + sample.estimated_tau_seconds + ), + "estimated_theta_seconds": _csv_optional_float( + sample.estimated_theta_seconds + ), + "model_confidence": _csv_optional_float( + sample.model_confidence + ), + "model_residual": _csv_optional_float(sample.model_residual), + } + ) + + +def _profile_name(plant: PlantConfig) -> str: + return next( + ( + name + for name, profile in PLANT_PROFILES.items() + if profile == plant + ), + "custom", + ) + + +def _summary_optional_float(value: Optional[float], precision: int) -> str: + return "n/a" if value is None else f"{value:.{precision}f}" + + +def format_summary( + results: Sequence[SimulationResult], + plant: PlantConfig, + plant_name: Optional[str] = None, +) -> str: + profile_name = plant_name or _profile_name(plant) + lines = [ + "PID controller simulation", + ( + f"Plant: {profile_name}, ambient={plant.ambient_f:.1f}F, " + f"delay={plant.firebox_delay_seconds}s, " + f"thermal_mass={plant.thermal_mass:.1f}, " + f"heat_input={plant.heat_input_per_second:.1f}" + ), + "", + ( + f"{'Scenario':>8} {'Mode':<16} {'Controller':<25} " + f"{'IAE F-min':>10} {'Within 5F':>10} {'Over F':>8} {'Duty':>7}" + ), + "-" * 105, + ] + for result in results: + lines.append( + f"{result.scenario_name:>8} {result.setpoint_mode:<16} " + f"{result.controller_name:<25} " + f"{result.integrated_absolute_error:>10.1f} " + f"{result.percent_within_five_f:>9.1f}% " + f"{result.max_overshoot:>8.1f} " + f"{result.mean_duty_ratio:>7.3f}" + ) + for segment in result.segment_metrics: + settling = ( + f"{segment.settling_time_minutes:.1f} min" + if segment.settling_time_minutes is not None + else "not settled" + ) + lines.append( + f" Segment {segment.segment_number}: " + f"target={segment.target_f:.0f}F, " + f"IAE={segment.integrated_absolute_error:.1f}, " + f"within5={segment.percent_within_five_f:.1f}%, " + f"overshoot={segment.max_overshoot:.1f}F, " + f"settling={settling}, duty={segment.mean_duty_ratio:.3f}" + ) + activation = ( + f"{result.identifier_activation_second}s" + if result.identifier_activation_second is not None + else "not active" + ) + lines.append( + " Model: " + f"active={'yes' if result.prediction_active else 'no'}, " + f"activation={activation}, " + f"applied_duty={result.model_applied_duty:.3f}, " + f"predicted={_summary_optional_float(result.predicted_temperature, 1)}, " + f"gain={_summary_optional_float(result.estimated_gain_f_per_duty, 3)}, " + f"tau={_summary_optional_float(result.estimated_tau_seconds, 1)}, " + f"theta={_summary_optional_float(result.estimated_theta_seconds, 1)}, " + f"confidence={_summary_optional_float(result.model_confidence, 3)}, " + f"residual={_summary_optional_float(result.model_residual, 3)}" + ) + return "\n".join(lines) + + +def _finite_float(value: str) -> float: + parsed = float(value) + if not math.isfinite(parsed): + raise argparse.ArgumentTypeError("must be finite") + return parsed + + +def _positive_int(value: str) -> int: + parsed = int(value) + if parsed <= 0: + raise argparse.ArgumentTypeError("must be positive") + return parsed + + +def _non_negative_int(value: str) -> int: + parsed = int(value) + if parsed < 0: + raise argparse.ArgumentTypeError("must be non-negative") + return parsed + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Compare PiFire PID controllers with a deterministic grill model." + ) + parser.add_argument( + "--plant", + choices=tuple(PLANT_PROFILES), + default="medium", + ) + parser.add_argument( + "--scenario", + choices=tuple(SCENARIOS) + ("all",), + default="all", + ) + parser.add_argument( + "--controller", + action="append", + choices=CONTROLLER_NAMES, + dest="controllers", + ) + parser.add_argument( + "--setpoint-mode", + action="append", + choices=SETPOINT_MODES, + dest="setpoint_modes", + ) + parser.add_argument("--ambient-f", type=_finite_float, default=None) + parser.add_argument("--duration-hours", type=_finite_float, default=4.0) + parser.add_argument("--cycle-seconds", type=_positive_int, default=15) + parser.add_argument("--delay-seconds", type=_non_negative_int, default=None) + parser.add_argument("--csv", type=Path) + return parser + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + if args.duration_hours <= 3.0: + parser.error("--duration-hours must be greater than 3") + + duration_seconds = round(args.duration_hours * 60 * 60) + if duration_seconds <= 10_800: + parser.error("duration must extend past the final setpoint transition") + + selected_scenarios = ( + list(SCENARIOS.values()) + if args.scenario == "all" + else [SCENARIOS[args.scenario]] + ) + selected_scenarios = [ + replace(scenario, duration_seconds=duration_seconds) + for scenario in selected_scenarios + ] + plant = PLANT_PROFILES[args.plant] + if args.ambient_f is not None: + plant = replace(plant, ambient_f=args.ambient_f) + if args.delay_seconds is not None: + plant = replace(plant, firebox_delay_seconds=args.delay_seconds) + results = run_scenarios( + controller_names=args.controllers, + scenarios=selected_scenarios, + plant=plant, + cycle_seconds=args.cycle_seconds, + setpoint_modes=args.setpoint_modes, + ) + print(format_summary(results, plant, args.plant)) + if args.csv is not None: + write_csv(args.csv, results) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_pid_simulator.py b/tests/test_pid_simulator.py new file mode 100644 index 000000000..425f1551b --- /dev/null +++ b/tests/test_pid_simulator.py @@ -0,0 +1,649 @@ +import controller +import csv +import io +import importlib +import json +import math +import subprocess +import sys +import tempfile +import unittest +import types +from unittest.mock import patch +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path + +from pid_simulator import ( + PLANT_PROFILES, + U_MAX, + U_MIN, + PlantConfig, + SCENARIOS, + Scenario, + build_identification_scenario, + format_summary, + load_controller_module, + main, + run_scenarios, + simulate_controller, + write_csv, +) + + +def short_scenario(): + return Scenario("short", 60, 200.0, ((0, 250.0),)) + + +class PidSimulatorTests(unittest.TestCase): + def test_default_plant_uses_35_second_delay_and_400_thermal_mass(self): + self.assertEqual(PlantConfig().firebox_delay_seconds, 35) + self.assertEqual(PlantConfig().thermal_mass, 400.0) + + def test_named_plants_match_exact_physics_and_reach_600(self): + expected = { + "small": (250.0, 48.0, 0.075, 20, 640.0, 3333.3333333333335), + "medium": (400.0, 55.0, 0.085, 35, 647.0588235294117, 4705.882352941177), + "large": (650.0, 70.0, 0.100, 50, 700.0, 6500.0), + } + + self.assertEqual(set(PLANT_PROFILES), set(expected)) + for name, (mass, heat, loss, delay, gain, tau) in expected.items(): + with self.subTest(plant=name): + plant = PLANT_PROFILES[name] + self.assertEqual( + ( + plant.thermal_mass, + plant.heat_input_per_second, + plant.heat_loss_coefficient, + plant.firebox_delay_seconds, + ), + (mass, heat, loss, delay), + ) + self.assertAlmostEqual(plant.heat_input_per_second / loss, gain) + self.assertAlmostEqual(plant.thermal_mass / loss, tau) + required_duty = (600.0 - plant.ambient_f) / gain + self.assertLess(required_duty, U_MAX) + + def test_600_scenario_is_a_four_hour_hold_beginning_at_550f(self): + self.assertEqual( + SCENARIOS["600"], + Scenario("600", 14_400, 550.0, ((0, 600.0),)), + ) + self.assertEqual(set(SCENARIOS), {"250", "350", "450", "600"}) + + def test_identification_scenario_scales_with_plant_time_constant(self): + for name, plant in PLANT_PROFILES.items(): + with self.subTest(plant=name): + tau = plant.thermal_mass / plant.heat_loss_coefficient + scenario = build_identification_scenario(plant) + self.assertEqual(scenario.name, "identification") + self.assertEqual(scenario.initial_pit_f, 200.0) + self.assertEqual( + scenario.transitions, + ( + (0, 250.0), + (round(tau), 350.0), + (round(2 * tau), 450.0), + (round(3 * tau), 300.0), + ), + ) + self.assertEqual(scenario.duration_seconds, round(4.5 * tau)) + + def test_cli_selects_large_plant_and_600_scenario(self): + with redirect_stdout(io.StringIO()) as stdout: + self.assertEqual( + main( + [ + "--plant", + "large", + "--scenario", + "600", + "--controller", + "pid_sp", + "--setpoint-mode", + "continuous", + ] + ), + 0, + ) + + report = stdout.getvalue() + self.assertIn("Plant: large", report) + self.assertIn("600", report) + + def test_cli_applies_explicit_overrides_to_the_selected_profile(self): + with redirect_stdout(io.StringIO()) as stdout: + self.assertEqual( + main( + [ + "--plant", + "small", + "--ambient-f", + "75", + "--delay-seconds", + "10", + "--scenario", + "600", + "--controller", + "pid", + "--setpoint-mode", + "continuous", + ] + ), + 0, + ) + + report = stdout.getvalue() + self.assertIn("Plant: small", report) + self.assertIn("ambient=75.0F", report) + self.assertIn("delay=10s", report) + self.assertIn("thermal_mass=250.0", report) + self.assertIn("heat_input=48.0", report) + + def test_simulator_records_only_clamped_duty_through_adaptive_hook(self): + set_output_calls = [] + raw_outputs = iter((U_MIN - 0.25, U_MAX + 0.25, U_MIN - 0.50)) + + class ClampingSpyController: + def __init__(self, _config, _units, _cycle_data): + pass + + def supported_functions(self): + return ("set_output",) + + def set_target(self, _target): + pass + + def update(self, _pit_temperature): + return next(raw_outputs) + + def set_output(self, duty, identification_allowed=True): + set_output_calls.append((duty, identification_allowed)) + + controller_module = types.ModuleType("clamping_spy") + setattr(controller_module, "Controller", ClampingSpyController) + setattr(controller_module, "time", types.SimpleNamespace()) + scenario = Scenario("clamp-spy", 16, 200.0, ((0, 250.0),)) + + with patch( + "pid_simulator.load_controller_module", + return_value=controller_module, + ): + simulate_controller( + "pid_sp", + scenario, + PLANT_PROFILES["medium"], + 5, + "continuous", + ) + + self.assertEqual( + set_output_calls, + [ + (U_MIN, True), + (U_MIN, True), + (U_MAX, True), + (U_MIN, True), + ], + ) + + def test_model_diagnostics_are_scalar_and_json_safe(self): + result = simulate_controller( + "pid_sp", + short_scenario(), + PLANT_PROFILES["medium"], + 15, + "continuous", + ) + sample = result.samples[15] + sample_diagnostics = { + "model_applied_duty": sample.model_applied_duty, + "prediction_active": sample.prediction_active, + "predicted_temperature": sample.predicted_temperature, + "estimated_gain_f_per_duty": sample.estimated_gain_f_per_duty, + "estimated_tau_seconds": sample.estimated_tau_seconds, + "estimated_theta_seconds": sample.estimated_theta_seconds, + "model_confidence": sample.model_confidence, + "model_residual": sample.model_residual, + } + result_diagnostics = { + "model_applied_duty": result.model_applied_duty, + "prediction_active": result.prediction_active, + "predicted_temperature": result.predicted_temperature, + "estimated_gain_f_per_duty": result.estimated_gain_f_per_duty, + "estimated_tau_seconds": result.estimated_tau_seconds, + "estimated_theta_seconds": result.estimated_theta_seconds, + "model_confidence": result.model_confidence, + "model_residual": result.model_residual, + "identifier_activation_second": result.identifier_activation_second, + } + + self.assertIsInstance(sample.prediction_active, bool) + self.assertIsInstance(result.prediction_active, bool) + self.assertEqual( + json.loads(json.dumps(sample_diagnostics)), + sample_diagnostics, + ) + self.assertEqual( + json.loads(json.dumps(result_diagnostics)), + result_diagnostics, + ) + + def test_default_plant_reaches_250f_band_within_twenty_minutes(self): + result = run_scenarios( + controller_names=["pid_sp"], + scenarios=[SCENARIOS["250"]], + plant=PlantConfig(), + cycle_seconds=15, + setpoint_modes=["continuous"], + )[0] + + first_segment = result.segment_metrics[0] + settling_time = first_segment.settling_time_minutes + if settling_time is None: + self.fail("250F segment did not settle") + self.assertLess(settling_time, 20.0) + self.assertLess(first_segment.max_overshoot, 5.0) + + def test_pid_sp_recovers_450_transition_without_losing_aggregate_gain(self): + expected = { + "production-reset": { + "iae_max": 1078.7, + "within_min": 79.4, + }, + "continuous": { + "iae_max": 1075.6, + "within_min": 79.5, + }, + } + for mode, limits in expected.items(): + with self.subTest(mode=mode): + result = simulate_controller( + "pid_sp", + SCENARIOS["450"], + PLANT_PROFILES["medium"], + 15, + mode, + ) + first = result.segment_metrics[0] + settling = first.settling_time_minutes + self.assertIsNotNone(settling) + assert settling is not None + self.assertLessEqual(first.max_overshoot, 2.2) + self.assertLessEqual(settling, 25.6) + self.assertLessEqual( + result.integrated_absolute_error, + limits["iae_max"], + ) + self.assertGreaterEqual( + result.percent_within_five_f, + limits["within_min"], + ) + self.assertLessEqual( + abs(result.mean_duty_ratio - 0.595), + 0.005, + ) + def test_downward_step_counts_undershoot_not_initial_temperature(self): + result = run_scenarios( + controller_names=["pid_sp"], + scenarios=[SCENARIOS["250"]], + plant=PlantConfig(), + cycle_seconds=15, + setpoint_modes=["continuous"], + )[0] + + downward_segment = result.segment_metrics[2] + segment_samples = result.samples[10_800:14_400] + self.assertGreater(segment_samples[0].pit_temp_f, downward_segment.target_f) + expected_undershoot = max( + 0.0, + max( + downward_segment.target_f - sample.pit_temp_f + for sample in segment_samples + ), + ) + self.assertAlmostEqual( + downward_segment.max_overshoot, + expected_undershoot, + ) + self.assertEqual( + result.max_overshoot, + max(segment.max_overshoot for segment in result.segment_metrics), + ) + + def test_all_default_controllers_produce_finite_metrics_for_every_scenario_and_mode( + self, + ): + results = run_scenarios( + controller_names=None, + scenarios=list(SCENARIOS.values()), + plant=PlantConfig(), + cycle_seconds=15, + setpoint_modes=None, + ) + + expected_controllers = { + "pid", + "pid_clamping", + "pid_clamping_percent_pb", + "pid_parallel", + "pid_ac", + "pid_sp", + } + self.assertEqual(len(results), len(expected_controllers) * len(SCENARIOS) * 2) + self.assertEqual( + {result.controller_name for result in results}, expected_controllers + ) + self.assertEqual({result.scenario_name for result in results}, set(SCENARIOS)) + self.assertEqual( + {result.setpoint_mode for result in results}, + {"production-reset", "continuous"}, + ) + for result in results: + self.assertTrue(math.isfinite(result.integrated_absolute_error)) + self.assertTrue(math.isfinite(result.percent_within_five_f)) + self.assertTrue(math.isfinite(result.max_overshoot)) + self.assertTrue(math.isfinite(result.mean_duty_ratio)) + self.assertEqual( + len(result.segment_metrics), + len(SCENARIOS[result.scenario_name].transitions), + ) + self.assertEqual(len(result.samples), 4 * 60 * 60) + + def test_controller_and_mode_filters_run_only_requested_combination(self): + results = run_scenarios( + controller_names=["pid_sp"], + scenarios=[SCENARIOS["350"]], + plant=PlantConfig(), + cycle_seconds=15, + setpoint_modes=["continuous"], + ) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0].controller_name, "pid_sp") + self.assertEqual(results[0].scenario_name, "350") + self.assertEqual(results[0].setpoint_mode, "continuous") + + def test_explicitly_empty_filters_produce_no_results(self): + for controller_names, setpoint_modes in ( + ([], ["continuous"]), + (["pid"], []), + ): + with self.subTest( + controller_names=controller_names, + setpoint_modes=setpoint_modes, + ): + results = run_scenarios( + controller_names=controller_names, + scenarios=[SCENARIOS["250"]], + plant=PlantConfig(), + cycle_seconds=15, + setpoint_modes=setpoint_modes, + ) + self.assertEqual(results, []) + + def test_controller_loader_restores_package_and_module_cache(self): + module_name = "controller.pid_sp" + previous_module = importlib.import_module(module_name) + previous_package_attribute = getattr(controller, "pid_sp") + + loaded_module = load_controller_module("pid_sp") + + self.assertIsNot(loaded_module, previous_module) + self.assertIs(sys.modules[module_name], previous_module) + self.assertIs(getattr(controller, "pid_sp"), previous_package_attribute) + + def test_csv_contains_one_row_for_every_simulated_second(self): + result = run_scenarios( + controller_names=["pid"], + scenarios=[SCENARIOS["250"]], + plant=PlantConfig(), + cycle_seconds=15, + setpoint_modes=["production-reset"], + )[0] + + with tempfile.TemporaryDirectory() as directory: + output_path = Path(directory) / "result.csv" + write_csv(output_path, [result]) + with output_path.open(newline="") as output_file: + rows = list(csv.DictReader(output_file)) + + self.assertEqual(len(rows), len(result.samples)) + self.assertEqual( + set(rows[0]), + { + "scenario", + "controller", + "setpoint_mode", + "second", + "setpoint_f", + "pit_temp_f", + "duty_ratio", + "auger_fraction", + "auger_on", + "model_applied_duty", + "prediction_active", + "predicted_temperature", + "estimated_gain_f_per_duty", + "estimated_tau_seconds", + "estimated_theta_seconds", + "model_confidence", + "model_residual", + }, + ) + + def test_cli_prints_summary_for_selected_scenario_and_controller(self): + stdout = io.StringIO() + with redirect_stdout(stdout): + exit_code = main(["--scenario", "450", "--controller", "pid_sp"]) + + self.assertEqual(exit_code, 0) + report = stdout.getvalue() + self.assertIn("PID controller simulation", report) + self.assertIn("450", report) + self.assertIn("pid_sp", report) + self.assertIn("IAE", report) + self.assertIn("Segment", report) + self.assertIn("production-reset", report) + self.assertIn("continuous", report) + self.assertIn("Model", report) + + def test_non_divisor_cycle_waits_a_full_interval_after_transition(self): + result = run_scenarios( + controller_names=["pid"], + scenarios=[SCENARIOS["250"]], + plant=PlantConfig(), + cycle_seconds=17, + setpoint_modes=["production-reset"], + )[0] + + self.assertEqual(result.controller_update_seconds[:2], (17, 34)) + self.assertNotIn(5_406, result.controller_update_seconds) + self.assertIn(5_417, result.controller_update_seconds) + self.assertAlmostEqual(result.samples[5_400].duty_ratio, 0.05) + self.assertAlmostEqual(result.samples[5_400].auger_fraction, 0.85) + self.assertGreater(result.samples[5_417].auger_fraction, 0.0) + + def test_setpoint_modes_restart_or_retain_the_controller(self): + results = run_scenarios( + controller_names=["pid_sp"], + scenarios=[SCENARIOS["250"]], + plant=PlantConfig(), + cycle_seconds=15, + setpoint_modes=["production-reset", "continuous"], + ) + by_mode = {result.setpoint_mode: result for result in results} + + self.assertEqual( + by_mode["production-reset"].controller_start_seconds, + (0, 5_400, 10_800), + ) + self.assertEqual(by_mode["continuous"].controller_start_seconds, (0,)) + self.assertAlmostEqual( + by_mode["production-reset"].samples[5_400].duty_ratio, + 0.05, + ) + + def test_invalid_cli_timing_values_are_rejected(self): + for arguments in ( + ["--duration-hours", "3"], + ["--cycle-seconds", "0"], + ["--delay-seconds", "-1"], + ["--ambient-f", "nan"], + ): + with self.subTest(arguments=arguments): + with redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit): + main(arguments) + + def test_fractional_auger_window_preserves_requested_duty(self): + result = run_scenarios( + controller_names=["pid"], + scenarios=[SCENARIOS["250"]], + plant=PlantConfig(), + cycle_seconds=15, + setpoint_modes=["production-reset"], + )[0] + + first_cycle = result.samples[:15] + self.assertAlmostEqual( + sum(sample.auger_fraction for sample in first_cycle), + 0.75, + ) + + def test_default_plant_can_sustain_highest_target_below_maximum_duty(self): + plant = PlantConfig() + max_equilibrium_f = ( + plant.ambient_f + + plant.heat_input_per_second * 0.9 / plant.heat_loss_coefficient + ) + highest_target_f = max( + target + for scenario in SCENARIOS.values() + for _, target in scenario.transitions + ) + + self.assertGreater(max_equilibrium_f, highest_target_f) + + def test_pid_sp_identifies_every_named_plant_from_closed_loop_data(self): + for name, plant in PLANT_PROFILES.items(): + with self.subTest(plant=name): + scenario = build_identification_scenario(plant) + result = simulate_controller( + "pid_sp", + scenario, + plant, + 15, + "continuous", + ) + exact_gain = ( + plant.heat_input_per_second / plant.heat_loss_coefficient + ) + exact_tau = plant.thermal_mass / plant.heat_loss_coefficient + + self.assertIsNotNone(result.identifier_activation_second) + estimated_gain = result.estimated_gain_f_per_duty + estimated_tau = result.estimated_tau_seconds + estimated_theta = result.estimated_theta_seconds + self.assertIsNotNone( + estimated_gain, + f"{name} identifier did not estimate gain", + ) + self.assertIsNotNone( + estimated_tau, + f"{name} identifier did not estimate time constant", + ) + self.assertIsNotNone( + estimated_theta, + f"{name} identifier did not estimate delay", + ) + assert estimated_gain is not None + assert estimated_tau is not None + assert estimated_theta is not None + self.assertAlmostEqual( + estimated_gain, + exact_gain, + delta=0.10 * exact_gain, + ) + self.assertAlmostEqual( + estimated_tau, + exact_tau, + delta=0.15 * exact_tau, + ) + self.assertAlmostEqual( + estimated_theta, + plant.firebox_delay_seconds, + delta=5.0, + ) + self.assertTrue( + all(math.isfinite(sample.pit_temp_f) for sample in result.samples) + ) + self.assertTrue( + all(U_MIN <= sample.duty_ratio <= U_MAX for sample in result.samples) + ) + + def test_pid_sp_nominal_aggregate_gains_remain_above_pre_smith_baseline(self): + limits = { + ("250", "production-reset"): (831.7, 83.1), + ("250", "continuous"): (834.8, 83.0), + ("350", "production-reset"): (877.6, 82.5), + ("350", "continuous"): (878.7, 82.5), + } + for (scenario_name, mode), (iae_max, within_min) in limits.items(): + with self.subTest(scenario=scenario_name, mode=mode): + result = simulate_controller( + "pid_sp", + SCENARIOS[scenario_name], + PLANT_PROFILES["medium"], + 15, + mode, + ) + self.assertLessEqual(result.integrated_absolute_error, iae_max) + self.assertGreaterEqual( + result.percent_within_five_f, + within_min, + ) + def test_every_named_plant_sustains_600_below_maximum_duty(self): + for name, plant in PLANT_PROFILES.items(): + with self.subTest(plant=name): + result = simulate_controller( + "pid_sp", + SCENARIOS["600"], + plant, + 15, + "continuous", + ) + final_window = result.samples[-600:] + self.assertLessEqual( + max( + abs(sample.pit_temp_f - 600.0) + for sample in final_window + ), + 5.0, + ) + final_applied_duty_mean = sum( + sample.duty_ratio for sample in final_window + ) / len(final_window) + self.assertLess(final_applied_duty_mean, U_MAX) + + def test_logging_controllers_run_without_site_packages(self): + completed = subprocess.run( + [ + sys.executable, + "-S", + "pid_simulator.py", + "--scenario", + "250", + "--controller", + "pid_clamping", + ], + capture_output=True, + text=True, + check=False, + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + + +if __name__ == "__main__": + unittest.main() From c3d5f9db52376b8bfeea126d0faa0f95c5a86557 Mon Sep 17 00:00:00 2001 From: Daniel Berlin Date: Sun, 26 Jul 2026 22:01:44 -0400 Subject: [PATCH 4/4] Document adaptive PID-SP design and tuning # Conflicts: # controller/pid_sp.py # Conflicts: # controller/pid_sp.py --- common/adaptive_controller_state.py | 47 +- control.py | 30 + controller/pid_sp.py | 62 +- controller/runtime.py | 72 +- controller/smith_predictor.py | 101 +- .../2026-07-24-adaptive-smith-predictor.md | 1116 +++++++++++++++++ .../2026-07-24-pid-controller-simulator.md | 541 ++++++++ ...7-25-high-temperature-transition-tuning.md | 698 +++++++++++ ...6-07-24-adaptive-smith-predictor-design.md | 295 +++++ ...6-07-24-pid-controller-simulator-design.md | 106 ++ ...gh-temperature-transition-tuning-design.md | 123 ++ 11 files changed, 3167 insertions(+), 24 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-24-adaptive-smith-predictor.md create mode 100644 docs/superpowers/plans/2026-07-24-pid-controller-simulator.md create mode 100644 docs/superpowers/plans/2026-07-25-high-temperature-transition-tuning.md create mode 100644 docs/superpowers/specs/2026-07-24-adaptive-smith-predictor-design.md create mode 100644 docs/superpowers/specs/2026-07-24-pid-controller-simulator-design.md create mode 100644 docs/superpowers/specs/2026-07-25-high-temperature-transition-tuning-design.md diff --git a/common/adaptive_controller_state.py b/common/adaptive_controller_state.py index 32676df54..eac064ec8 100644 --- a/common/adaptive_controller_state.py +++ b/common/adaptive_controller_state.py @@ -1,4 +1,14 @@ -"""Durable storage for trusted adaptive controller model snapshots.""" +"""Crash-safe persistence for trusted adaptive-controller model snapshots. + +Only the small, validated physical model crosses a process restart. Live RLS +covariance, command history, predictor branches, and confirmation windows remain +runtime state and are rebuilt after restore. + +Callers stage newer revisions after PID updates. Routine flushes are throttled +to protect storage; shutdown forces a final attempt. A write becomes committed +only after a same-directory temporary file is flushed, fsynced, and atomically +replaced over the prior state file. +""" import logging import json @@ -35,7 +45,13 @@ class AdaptiveControllerStateStore: - """Atomically persist only validated, physical controller model snapshots.""" + """Validate, stage, and atomically persist trusted physical models. + + ``_models`` is the last durable generation and ``_pending`` is newer + in-memory work. Pending entries are cleared only after a successful atomic + replacement, so a transient write failure can be retried without losing the + latest learned model. + """ def __init__( self, @@ -50,6 +66,8 @@ def __init__( self.path = Path(path) self._clock = clock self._min_write_interval = interval + # Treat a missing or invalid file as an empty store. A corrupt snapshot + # must never partially restore model parameters into a controller. loaded_models = self._read_models() self._models = {} if loaded_models is None else loaded_models self._pending = {} @@ -58,6 +76,8 @@ def __init__( ) def load(self, name): + # Return a copy so controller restore code cannot mutate durable state + # before a newer, explicitly validated revision is staged. """Return a copy of a persisted trusted snapshot, if it is valid.""" snapshot = self._models.get(name) return None if snapshot is None else dict(snapshot) @@ -67,10 +87,14 @@ def stage(self, name, snapshot): if not isinstance(name, str) or not name: return False + # Validate the complete snapshot at the storage boundary. Persistence + # accepts no extra keys, booleans-as-numbers, NaN, or unphysical values. validated = self._validate_model(snapshot) if validated is None: return False + # Revisions are monotonic per controller name. Compare against pending + # first so repeated staging cannot replace newer unsaved work. current = self._pending.get(name, self._models.get(name)) if current is not None and validated["revision"] <= current["revision"]: return False @@ -80,8 +104,12 @@ def stage(self, name, snapshot): def flush(self, force=False): """Atomically write pending models when the routine-write interval permits.""" + # No pending generation means there is nothing to commit. ``False`` is + # intentionally also used for a throttled/no-op flush. if not self._pending: return False + # Routine writes are rate limited, but ``force=True`` bypasses only the + # timer—not validation or atomic-write safety. if ( not force and self._last_successful_write is not None @@ -90,12 +118,15 @@ def flush(self, force=False): ): return False + # Build the next complete generation without mutating the durable view. + # If writing fails, both _models and _pending remain retryable. models = dict(self._models) models.update(self._pending) root = {"version": _ROOT_VERSION, "models": models} if not self._write(root): return False + # Publish the in-memory generation only after os.replace succeeded. self._models = models self._pending.clear() self._last_successful_write = self._now() @@ -105,6 +136,8 @@ def _now(self): return float(self._clock()) def _read_models(self): + # Loading is fail-closed: any I/O, JSON, root-schema, or member error + # rejects the entire file rather than mixing trusted and suspect models. try: with self.path.open("r", encoding="utf-8") as state_file: root = json.load(state_file) @@ -129,6 +162,8 @@ def _read_models(self): return models def _validate_model(self, snapshot): + # Exact versioned schemas make forward/backward incompatibility explicit + # and keep persistence limited to immutable physical model parameters. if type(snapshot) is not dict or set(snapshot) != _MODEL_KEYS: return None if type(snapshot["version"]) is not int or snapshot["version"] != _MODEL_VERSION: @@ -159,6 +194,8 @@ def _validate_model(self, snapshot): def _write(self, root): temporary_path = None try: + # The temporary file shares the destination directory, allowing + # os.replace to provide an atomic same-filesystem cutover. self.path.parent.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile( mode="w", @@ -168,9 +205,13 @@ def _write(self, root): encoding="utf-8", ) as state_file: temporary_path = Path(state_file.name) + # Flush Python buffers and then the OS file descriptor before + # replacement so a successful return represents durable bytes. json.dump(root, state_file, sort_keys=True, indent=2) state_file.flush() os.fsync(state_file.fileno()) + # Atomic replacement preserves the previous complete generation if + # the process fails before this point. os.replace(temporary_path, self.path) temporary_path = None return True @@ -180,6 +221,8 @@ def _write(self, root): ) return False finally: + # A failed write may leave a named temporary file; remove it without + # masking the original persistence error. Pending state is retained. if temporary_path is not None: try: temporary_path.unlink() diff --git a/control.py b/control.py index 1bdbf5eea..34f154d5e 100755 --- a/control.py +++ b/control.py @@ -339,6 +339,9 @@ def _work_cycle(mode, grill_platform, probe_complex, display_device, dist_device settings = read_settings() control = read_control() pelletdb = read_pellet_db() + # One store spans the mode loop. It holds the last durable models plus newer + # staged revisions, allowing routine writes to be throttled without losing + # the latest trusted model in memory. adaptive_store = AdaptiveControllerStateStore() controllerCore = None adaptive_model_pending = False @@ -458,6 +461,9 @@ def _work_cycle(mode, grill_platform, probe_complex, display_device, dist_device if controller_status == 'Inactive': status = 'Inactive' else: + # Restore physical knowledge before the first PID update, then seed + # both adaptive timelines with the minimum duty already being applied. + # Restore intentionally does not revive stale RLS or predictor history. restore_model( controllerCore, adaptive_store, settings['controller']['selected'] ) @@ -639,6 +645,9 @@ def _work_cycle(mode, grill_platform, probe_complex, display_device, dist_device settings = read_settings() controllerCore, controller_status = _init_controller(settings, control) if controller_status == 'Active': + # A settings change replaces the controller object. Restore its + # trusted model, then seed the replacement with current actuator + # reality so its history does not begin with a fictitious zero. restore_model( controllerCore, adaptive_store, settings['controller']['selected'] ) @@ -648,6 +657,9 @@ def _work_cycle(mode, grill_platform, probe_complex, display_device, dist_device if manual_override_active else False ) + # Manual auger state has precedence during reinitialization and is + # recorded as ineligible; otherwise preserve the clamped cycle duty + # and the current lid/fan identification gate. seed_duty, seed_identification_allowed = ( controller_reinit_output_seed( CycleRatio, @@ -721,6 +733,9 @@ def _work_cycle(mode, grill_platform, probe_complex, display_device, dist_device grill_platform.auger_off() eventLogger.debug('Auger OFF') manual_override['auger'] = override_time # Set override time + # Prediction must see the exact manual on/off transition, but + # identification must not attribute its temperature response to + # normal PID control. record_output( controllerCore, manual_override_duty(control['manual']['output']), @@ -762,6 +777,9 @@ def _work_cycle(mode, grill_platform, probe_complex, display_device, dist_device if mode == 'Hold': # Check to see if it's time to update pid and update if needed. if hold_pid_update_due(now, controllerCycleStart, CycleTime): + # First compute from the measured/Smith-selected temperature. + # A newly trusted model may be staged now, but routine flushes + # remain storage-throttled and never block model use in memory. pid_output = controllerCore.update(ptemp) if stage_model( controllerCore, adaptive_store, settings['controller']['selected'] @@ -770,6 +788,9 @@ def _work_cycle(mode, grill_platform, probe_complex, display_device, dist_device if adaptive_store.flush(): adaptive_model_pending = False controllerCycleStart = now + # The controller returns an unclamped request. Apply lid safety, + # minimum/fan-PID handling, and the maximum before feeding duty + # back; the model must learn what reached the actuator. CycleRatio = RawCycleRatio = settings['cycle_data']['u_min'] if LidOpenDetect else pid_output # If ratio is less than min set auger ratio to min and control further via fan. if CycleRatio < settings['cycle_data']['u_min']: @@ -785,6 +806,9 @@ def _work_cycle(mode, grill_platform, probe_complex, display_device, dist_device ControlFanPid = False # Don't set ratio over maximum. CycleRatio = min(CycleRatio, settings['cycle_data']['u_max']) + # Do not overwrite an active manual command with a simultaneous + # normal PID sample. The eligibility flag separately excludes + # lid-open and fan-PID-disturbed intervals from identification. if normal_pid_output_recording_allowed( manual_override['auger'], now ): @@ -798,6 +822,9 @@ def _work_cycle(mode, grill_platform, probe_complex, display_device, dist_device ), ) if manual_override['auger'] < now: + # When a timed manual override expires, mark the transition back to + # normal duty as ineligible. This prevents one mixed interval from + # being treated as an ordinary PID experiment. if manual_override['auger']: record_output(controllerCore, CycleRatio, False) manual_override['auger'] = 0 @@ -1166,6 +1193,9 @@ def _work_cycle(mode, grill_platform, probe_complex, display_device, dist_device grill_platform.power_off() eventLogger.debug('Fan OFF, Power OFF') + # Capture the newest trusted model before exit. A forced flush bypasses only + # the routine timer; validation and atomic replacement still apply. Failed + # writes leave pending state intact and are logged rather than hidden. try: if stage_model( controllerCore, adaptive_store, settings['controller']['selected'] diff --git a/controller/pid_sp.py b/controller/pid_sp.py index aef1635d4..45b6c1a3e 100755 --- a/controller/pid_sp.py +++ b/controller/pid_sp.py @@ -8,6 +8,17 @@ Description: This object will be used to calculate PID for maintaining temperature in the grill. + PID-SP keeps the legacy proportional-band PID law, but chooses its controller + input through an optional adaptive Smith predictor: + + 1. Observe the measured pit temperature and update the model identifier. + 2. Promote a newly trusted physical model into the predictor. + 3. Select measured or delay-corrected temperature through the predictor. + 4. Run transition gating and P/I/D from that one selected temperature. + 5. Later, receive the duty actually applied after production safety clamps. + + Model identification and prediction are optional. Invalid or insufficient + adaptive state falls back to the measured probe temperature. This software was developed by GitHub user DBorello as part of his excellent PiSmoker project: https://github.com/DBorello/PiSmoker @@ -43,6 +54,13 @@ Class Definition ''' class Controller(ControllerBase): + '''Proportional-band PID composed with optional adaptive Smith feedback. + + ``update`` consumes temperature and returns an unclamped duty request. + ``set_output`` is called later with the duty production actually applied. + Keeping those directions separate prevents safety clamps and overrides from + being misrepresented as plant behavior commanded by the PID. + ''' def __init__(self, config, units, cycle_data): super().__init__(config, units, cycle_data) self.function_list.append('set_gains') @@ -65,6 +83,8 @@ def __init__(self, config, units, cycle_data): self.pb = pb self.units = units + # The identifier learns a trusted physical model; the predictor consumes + # that model. Both receive the same applied-duty history via set_output. self._identifier = AdaptiveFOPDTIdentifier(units, clock=lambda: time.time()) self._predictor = SmithPredictor(units, clock=lambda: time.time()) @@ -120,14 +140,20 @@ def _reset_approach_state(self): self.slow_approach_samples = 0 def update(self, current): - # Elapsed time since last update + # Phase 1: timestamp this observation before touching either adaptive + # component so identification, prediction, and PID share one interval. current_time = time.time() first_selected_sample = self.previous_controller_input is None + # Identification always sees the measured probe value. A newly + # published model is promoted before selecting this cycle's PID input. trusted_update = self._identifier.observe(current, current_time) if trusted_update is not None: self._predictor.set_model(trusted_update) current_time = time.time() + # Prediction returns either a safe delay-corrected temperature or the + # measured value unchanged. Every PID and transition term below uses + # this one selected input; mixing inputs would create false dynamics. controller_input = self._predictor.update(current, current_time) dt = current_time - self.last_update self._predicted_temperature = controller_input @@ -139,12 +165,17 @@ def update(self, current): else: selected_rate = (controller_input - previous_controller_input) / dt + # Phase 2: maintain target-transition state in selected-temperature + # coordinates. This matters when a trusted prediction differs from the + # probe measurement during a live setpoint change. # Seed setpoint-transition bookkeeping from the first selected sample. if first_selected_sample: self.derv = 0.0 if self.new_target: self.start_change_temp = controller_input + # Reaching the native-unit capture band—or crossing an upward target + # between samples—ends approach damping and restores normal integration. capture_band = self._target_capture_band() upward_transition = self.set_point > self.start_change_temp captured_target = abs(error) <= capture_band or (upward_transition and error >= 0.0) @@ -152,7 +183,8 @@ def update(self, current): self.new_target = False self._reset_approach_state() - # Determine output + # Phase 3: calculate the raw PID request. Production applies u_min/u_max, + # lid-open, fan-PID, and manual overrides after this method returns. if error < -self.pb: self.u = 1.0 # If overshooting, minimize output @@ -194,6 +226,9 @@ def update(self, current): if abs(error) < self.pb and current_time - self.last_set_time < self.cycle_time * 3: self.u *= 0.65 + # Mark only a genuinely output-limited upward approach. The complete + # candidate (including derivative and startup scaling) must reach the + # external clamp before extended integral damping is justified. u_max = self.cycle_data.get('u_max', 1.0) if reached_halfway and upward_transition and self.u >= u_max: if self.ti <= 0.0: @@ -202,11 +237,17 @@ def update(self, current): else: self.output_limited_approach = True + # Once active, process approach state after every output branch. A + # stalled or reversed temperature can cross proportional-band branches; + # skipping those samples would leave stale confirmation state. if self.output_limited_approach and self.new_target: if self.ti <= 0.0: self.new_target = False self._reset_approach_state() else: + # Three slow selected-temperature samples indicate that momentum + # no longer explains the residual. Re-enable integral action so + # it can remove the remaining P-only offset. rate_threshold = capture_band / (2.0 * self.ti) if selected_rate <= rate_threshold: self.slow_approach_samples += 1 @@ -216,7 +257,8 @@ def update(self, current): self.new_target = False self._reset_approach_state() - # Update for next cycle + # Phase 4: commit exactly the selected input and timestamp used above. + # The next derivative and delay-rate decision compare like with like. self.error = error self.last = current self.previous_controller_input = controller_input @@ -225,6 +267,8 @@ def update(self, current): return self.u def set_target(self, set_point): + # A target change resets PID/approach transients but deliberately keeps + # learned model, predictor branches, and applied-duty history. self.set_point = set_point self.error = 0.0 self.inter = 0.0 @@ -232,12 +276,15 @@ def set_target(self, set_point): self._reset_approach_state() self.last_update = time.time() self.last_set_time = self.last_update + # Live changes begin from the last selected controller temperature. + # Measured temperature is only a startup fallback before selection exists. if self.previous_controller_input is not None: self.start_change_temp = self.previous_controller_input else: self.start_change_temp = self.last if self.last is not None else 0.0 self.new_target = True - # Dynamically set self.center depending on set_point. Higher centers are needed to achieve higher temps, lower centers for lower temps. + # Center is a feed-forward baseline. Preserve the legacy high-target + # scaling while transition damping controls temporary approach integral. if self.units == "F": if set_point <= 240: self.center = set_point * self.center_factor @@ -251,6 +298,8 @@ def set_target(self, set_point): self._update_integral_limit() def set_gains(self, pb, ti, td): + # Gain changes retain the physical model. Non-positive Ti disables and + # clears integral state immediately so no stale contribution survives. self._calculate_gains(pb,ti,td) self._update_integral_limit() if self.ti <= 0.0: @@ -260,11 +309,16 @@ def set_gains(self, pb, ti, td): def get_k(self): return self.kp, self.ki, self.kd def set_output(self, applied_ratio, identification_allowed=True): + # Feed both adaptive components the duty that production actually + # applied. ``identification_allowed`` blocks learning during overrides + # while retaining the complete command timeline needed for prediction. current_time = time.time() self._identifier.record_output(applied_ratio, identification_allowed, timestamp=current_time) self._predictor.record_output(applied_ratio, identification_allowed, timestamp=current_time) def get_model_snapshot(self) -> Optional[dict]: + # Persist only a trusted immutable physical model. Dynamic predictor and + # RLS state are intentionally reconstructed after process restart. model = self._identifier.trusted_model if model is None: return None diff --git a/controller/runtime.py b/controller/runtime.py index abbed92ab..220f64757 100644 --- a/controller/runtime.py +++ b/controller/runtime.py @@ -1,8 +1,22 @@ -"""Optional adaptive-controller runtime integration helpers.""" +"""Capability-based bridge between ``control.py`` and adaptive controllers. + +Production control calls these helpers without knowing which controller module +is active. Adaptive methods run only when a controller explicitly advertises +them; legacy controllers keep their existing behavior through safe no-ops. + +This boundary also centralizes which actuator periods are valid identification +data. Prediction may consume every applied command, but the identifier must not +learn from lid-open safety, manual auger commands, or fan-PID modulation. +""" def supports(controller, function_name): - """Return whether a controller declares an optional function available.""" + """Check the controller's advertised optional-function protocol. + + Attribute presence alone is insufficient: controllers define their public + runtime surface through ``supported_functions``. Malformed legacy objects + are treated as unsupported rather than breaking the control loop. + """ supported_functions = getattr(controller, "supported_functions", None) if not callable(supported_functions): return False @@ -13,7 +27,13 @@ def supports(controller, function_name): def record_output(controller, duty, identification_allowed=True): - """Record an applied output when the controller supports adaptive history.""" + """Feed back the duty that reached the actuator, when supported. + + Call this after every production clamp or override decision. Recording the + raw PID request would teach the model an input the grill never received. + ``identification_allowed`` affects learning only; prediction still needs the + complete applied-duty timeline. + """ method = _supported_method(controller, "set_output") if method is not None: method(duty, identification_allowed) @@ -21,17 +41,21 @@ def record_output(controller, duty, identification_allowed=True): def record_lid_open_transition(controller): - """Record the exact auger-off output enforced for a lid-open event.""" + """Record the immediate auger-off command imposed by lid-open safety. + + The predictor needs the zero-duty transition at the time it occurred, while + the identifier must ignore the safety-disturbed response. + """ record_output(controller, 0.0, identification_allowed=False) def identification_allowed(lid_open, manual_override_active, fan_pid_active): - """Return whether applied output can contribute to model identification.""" + """Gate learning during actuator paths the PID model did not command.""" return not (lid_open or manual_override_active or fan_pid_active) def manual_override_duty(output): - """Convert a manual auger command into an exact applied duty.""" + """Map manual auger state to its exact binary applied duty.""" return 1.0 if output else 0.0 @@ -42,7 +66,12 @@ def controller_reinit_output_seed( fan_pid_active, auger_output, ): - """Return the applied output and identification gate for a replacement PID.""" + """Describe actuator state inherited by a replacement controller. + + Reinitialization discards command history, so the new controller must be + seeded with what the auger is doing now. Manual output takes precedence and + is never eligible identification data. + """ if manual_override_active: return manual_override_duty(auger_output), False return cycle_ratio, identification_allowed( @@ -50,7 +79,11 @@ def controller_reinit_output_seed( ) def restore_model(controller, store, name): - """Restore a persisted trusted model only for an adaptive controller.""" + """Restore durable physical knowledge into an adaptive controller. + + Runtime estimator history is intentionally not restored. Unsupported + controllers and absent snapshots are normal no-op cases. + """ method = _supported_method(controller, "restore_model") if method is None: return False @@ -62,7 +95,11 @@ def restore_model(controller, store, name): def stage_model(controller, store, name): - """Stage a controller's current trusted model when it is available.""" + """Validate and stage the controller's newest trusted physical model. + + Staging is separate from flushing so frequent PID updates do not translate + into frequent storage writes. + """ method = _supported_method(controller, "get_model_snapshot") if method is None: return False @@ -74,7 +111,7 @@ def stage_model(controller, store, name): def diagnostics(controller): - """Return adaptive diagnostics or preserve the legacy plain-controller payload.""" + """Expose adaptive diagnostics without changing legacy controller payloads.""" method = _supported_method(controller, "get_status") if method is not None: return method() @@ -82,7 +119,12 @@ def diagnostics(controller): def apply_live_hold_target(controller, active_mode, control): - """Apply only a target-only Hold update without consuming other changes.""" + """Apply a target-only Hold change without rebuilding the controller. + + Preserving the object preserves its trusted model, predictor branches, and + applied-duty history. Unit, mode, or gain/configuration changes are left for + the normal reinitialization path and are not consumed here. + """ if active_mode != "Hold" or control.get("mode") != "Hold": return False if ( @@ -108,23 +150,25 @@ def apply_live_hold_target(controller, active_mode, control): def apply_live_hold_target_and_restart_cycle(controller, active_mode, control, now): - """Apply a live Hold target and return its new PID cycle start time.""" + """Apply a live target and restart PID timing at the same instant.""" if not apply_live_hold_target(controller, active_mode, control): return None return now def hold_pid_update_due(now, controller_cycle_start, cycle_time): - """Return whether a Hold PID update is due.""" + """Keep PID scheduling semantics in one testable runtime boundary.""" return now - controller_cycle_start > cycle_time def normal_pid_output_recording_allowed(manual_override_until, now): - """Return whether a PID output may replace manual auger history.""" + """Prevent a normal PID result from overwriting active manual-duty history.""" return manual_override_until < now def _supported_method(controller, function_name): + # Resolve only methods the controller deliberately exposed. This avoids + # accidentally invoking similarly named implementation details. if not supports(controller, function_name): return None method = getattr(controller, function_name, None) diff --git a/controller/smith_predictor.py b/controller/smith_predictor.py index 6ef18dea3..8d2abfe47 100644 --- a/controller/smith_predictor.py +++ b/controller/smith_predictor.py @@ -1,4 +1,18 @@ -"""Exact first-order-plus-dead-time Smith predictor primitives.""" +"""Adaptive FOPDT identification and Smith prediction for PID-SP. + +The production lifecycle is: + +1. ``record_output`` stores the duty that the grill actually applied. +2. ``AdaptiveFOPDTIdentifier.observe`` updates a bank of fixed-dead-time + recursive least-squares estimators from eligible temperature intervals. +3. A model is published only after excitation, residual separation, physical + bounds, uncertainty, and repeated-stability checks all pass. +4. ``SmithPredictor.update`` advances delayed and delay-free copies of that + model and adds their difference to the measured temperature. + +Every unsafe or incomplete path falls back to the measured temperature. The +adaptive model may improve control, but it is never required for safe control. +""" import math from collections import deque @@ -6,6 +20,8 @@ from typing import Callable, Deque, List, Optional, Sequence, Tuple +# Physical bounds reject mathematically valid fits that cannot represent a +# grill. They also bound every state used to correct a probe measurement. MIN_GAIN_F = 50.0 MAX_GAIN_F = 2000.0 MIN_TAU_SECONDS = 300.0 @@ -13,6 +29,8 @@ MIN_PREDICTED_F = -100.0 MAX_PREDICTED_F = 1200.0 +# Dead time is selected from a fixed bank. Production evaluates all 25 +# candidates (0, 5, ..., 120 seconds); it does not narrow around a prior winner. MAX_DELAY_SECONDS = 120.0 DELAY_CANDIDATE_STEP_SECONDS = 5.0 DELAY_CANDIDATE_GRID = tuple( @@ -21,6 +39,9 @@ 0, int(MAX_DELAY_SECONDS) + 1, int(DELAY_CANDIDATE_STEP_SECONDS) ) ) +# Publication gates deliberately require much more evidence than one good fit. +# The identifier learns continuously, but promotes only separated and stable +# estimates after the grill has supplied enough input and temperature movement. RLS_FORGETTING_FACTOR = 0.9995 MIN_ACCEPTED_SECONDS = 3600.0 MIN_ACCEPTED_OBSERVATIONS = 240 @@ -74,7 +95,12 @@ class _DutyCommand: class DutyHistory: - """Piecewise-constant duty commands with their identification eligibility.""" + """Timeline of the duty physically applied to the auger. + + Commands are piecewise constant. The same timeline supplies delayed model + inputs and marks intervals that identification must ignore, such as lid-open + handling, manual auger control, or fan-PID modulation. + """ def __init__(self, max_age_seconds=300.0): if not self._is_finite(max_age_seconds) or max_age_seconds < 0.0: @@ -124,6 +150,8 @@ def average(self, start, end, delay_seconds=0.0): if not self._is_finite(delay_seconds): raise ValueError("delay_seconds must be finite") + # Shift the entire observation interval backward by a candidate's dead + # time, then integrate every duty command crossing that shifted window. shifted_start = float(start) - float(delay_seconds) shifted_end = float(end) - float(delay_seconds) if shifted_end == shifted_start: @@ -166,6 +194,8 @@ def interval_allowed(self, start, end): return True def prune(self, now): + # Keep the command immediately before the cutoff: its value remains in + # force until the first retained command and is needed for integration. self._validate_time(now) cutoff = float(now) - self.max_age_seconds first_at_or_after_cutoff = 0 @@ -228,7 +258,13 @@ def _advance_state(state, duty, duration, model): class SmithPredictor: - """Applies a validated FOPDT correction to native-unit temperature samples.""" + """Correct measured temperature with a trusted FOPDT model. + + Two model branches see the same applied-duty history. The delayed branch + represents the grill response; the undelayed branch represents the response + without transport delay. Their difference is the Smith correction. Probe + measurement remains the baseline so model drift cannot replace reality. + """ def __init__(self, units: str, clock: Callable[[], float]) -> None: if units not in ("F", "C"): @@ -272,6 +308,8 @@ def set_model(self, model: FOPDTModel) -> None: now = float(self._clock()) self._history._validate_time(now) self._history.prune(now) + # Start both branches at the equilibrium implied by the currently + # applied duty. Equal states mean promotion itself adds no correction. state = model.gain_f_per_duty * self._history.value_at(now) self._model = model @@ -293,6 +331,8 @@ def clear_dynamic_state(self) -> None: def update( self, measured_temperature: float, timestamp: Optional[float] = None ) -> float: + # Phase 1: validate external data and predictor state. Any uncertainty + # disables prediction and returns the probe measurement unchanged. if not DutyHistory._is_finite(measured_temperature): self._deactivate(timestamp) return measured_temperature @@ -320,6 +360,8 @@ def update( self._deactivate(now) return measured_temperature + # Phase 2: replay applied-duty changes through both model branches. The + # only difference is whether command changes are shifted by theta. previous_time = self._last_time previous_delayed_state = self._delayed_state try: @@ -340,6 +382,9 @@ def update( self._deactivate(now) return measured_temperature + # Phase 3: compare measured movement with the delayed model's movement. + # Four consecutive extreme mismatches indicate a stale or unsafe model; + # isolated disturbances do not permanently disable prediction. if self._last_measured_f is not None and now > previous_time: if self._history.interval_allowed( previous_time - self._model.theta_seconds, @@ -364,6 +409,8 @@ def update( self._consecutive_implausible_residuals = 0 self._last_measured_f = measured_f + # Phase 4: remove only the modeled transport delay. The correction is + # added to the fresh measurement rather than using a free-running model. correction_f = undelayed_state - delayed_state predicted_f = measured_f + correction_f if not self._is_safe_prediction(correction_f, predicted_f): @@ -385,6 +432,8 @@ def status(self): } def _advance_branch(self, state, start, end, delay_seconds): + # Split the interval at each effective command change so a long PID + # cycle is integrated exactly even when duty changed inside the window. duty = self._history.value_at(start - delay_seconds) cursor = start for command in self._history._commands: @@ -413,6 +462,8 @@ def _is_safe_prediction(self, correction_f, predicted_f): ) def _deactivate(self, timestamp=None): + # Deactivation is fail-safe, not destructive: retain the trusted model + # and seed equal branches so a later valid sample can reactivate it. previous_time = self._last_time self._prediction_active = False self._last_time = None @@ -511,7 +562,13 @@ def _matrix_vector(matrix, vector): class AdaptiveFOPDTIdentifier: - """Identifies a bounded FOPDT model from permitted applied-duty samples.""" + """Learn a bounded FOPDT model from eligible applied-duty history. + + Each accepted observation updates every configured dead-time candidate. + Candidates independently estimate gain and time constant; residual error + chooses the delay. Selection is provisional until excitation, runner-up + separation, uncertainty, and 20 consecutive stability checks all pass. + """ def __init__( self, @@ -526,6 +583,8 @@ def __init__( self.units = units self._clock = clock + # Each delay owns independent RLS coefficients, covariance, residual, + # and update count. A numerical failure resets only that candidate. self._delay_candidates = self._validate_delay_candidates(delay_candidates) self._history = DutyHistory( max_age_seconds=2.0 * MAX_DELAY_SECONDS + MIN_TRANSITION_SECONDS @@ -568,6 +627,9 @@ def record_output( if self._last_output_time is not None and now < self._last_output_time: raise ValueError("output timestamp precedes identifier output history") + # A material, permitted duty transition starts the excitation clock. + # Ineligible commands remain in history for prediction but cannot teach + # the identifier a response caused by an override or safety mechanism. self._mark_sustained_transition(now) self._history.record(now, float(duty), identification_allowed) self._last_output_time = now @@ -589,6 +651,8 @@ def observe( temperature_f = self._to_fahrenheit(float(temperature)) if not DutyHistory._is_finite(temperature_f): return None + # Phase 1: establish or repair the temperature baseline. Non-monotonic + # timestamps break confirmation continuity because order is meaningful. if self._last_time is None: self._set_baseline(now, temperature_f) self._history.prune(now) @@ -599,6 +663,8 @@ def observe( self._history.prune(now) return None + # Phase 2: accept only intervals whose complete applied-duty history is + # eligible. Rejected intervals still advance the observation baseline. previous_time = self._last_time previous_temperature_f = self._last_temperature_f self._mark_sustained_transition(now) @@ -618,6 +684,9 @@ def observe( return published_model def restore_trusted_model(self, model: FOPDTModel) -> None: + # Persisted state is trusted physical knowledge, not live estimator + # state. Restore the model, then restart evidence collection and RLS + # candidates so stale covariance/history cannot cross process restarts. model.validate() self._trusted_model = model self._candidates = self._fresh_candidates() @@ -732,6 +801,8 @@ def _accept_observation( if not DutyHistory._is_finite(applied_duty): return None + # Track excitation globally before evaluating a fit. These statistics + # prevent a quiet, nearly constant cook from appearing identifiable. self._accepted_seconds += duration self._accepted_observations += 1 self._update_duty_statistics(applied_duty) @@ -747,6 +818,8 @@ def _accept_observation( if self._temperature_max_f is None else max(self._temperature_max_f, interval_max_f) ) + # Every accepted sample updates the full fixed-delay bank. Delay is + # recovered by comparing the candidates, not estimated as an RLS term. for index in range(len(self._candidates)): self._update_candidate( index, @@ -773,6 +846,8 @@ def _update_candidate( ) -> None: candidate = self._candidates[index] try: + # A candidate sees the applied duty shifted by its assumed delay. + # If any part of that interval was ineligible, skip this update. if not self._history.interval_allowed( previous_time - candidate.delay_seconds, now - candidate.delay_seconds, @@ -798,6 +873,8 @@ def _update_candidate( ): raise ValueError("candidate update is non-finite") + # Regress temperature rate against offset, temperature, and delayed + # applied duty. Scaling temperature keeps the covariance well sized. z = ( previous_temperature_f - self._temperature_reference_f ) / 500.0 @@ -806,6 +883,8 @@ def _update_candidate( if not all(DutyHistory._is_finite(value) for value in phi + [y]): raise ValueError("candidate regression values are non-finite") + # Standard recursive least squares: compute P*phi, innovation gain, + # prediction error, then update coefficients and covariance. p_phi = _matrix_vector(candidate.covariance, phi) denominator = RLS_FORGETTING_FACTOR + _dot(phi, p_phi) if ( @@ -843,6 +922,8 @@ def _update_candidate( ] for row in range(3) ] + # Residual EWMA makes recent prediction quality comparable across + # candidates without retaining every historical observation. squared_error = error * error residual_ewma = ( squared_error @@ -976,6 +1057,9 @@ def _select_candidate( winner = None runner_up_residual = None eligible_candidates = 0 + # Only physically bounded, sufficiently sampled, low-uncertainty fits + # compete. Keep both best residuals so delay must win decisively rather + # than by numerical noise between adjacent five-second candidates. for candidate in self._candidates: estimate = self._candidate_estimate(candidate) if estimate is None: @@ -995,6 +1079,8 @@ def _select_candidate( return winner, delay_margin, eligible_candidates def _consider_estimate(self) -> Optional[FOPDTModel]: + # Phase 1: require a viable winner, at least 10% residual separation, + # and enough independent excitation to identify the plant. estimate, delay_margin, _ = self._select_candidate() if ( estimate is None @@ -1004,9 +1090,14 @@ def _consider_estimate(self) -> Optional[FOPDTModel]: self._confirmations.clear() return None + # Phase 2: avoid republishing insignificant movement around an existing + # trusted model; such churn would reset predictor state needlessly. if self._trusted_model is not None and not self._is_material(estimate): self._confirmations.clear() return None + # Phase 3: confirmation is consecutive. A changing winning delay clears + # the window because gain/tau stability under different delays is not + # evidence that any single model is trustworthy. if self._confirmations and self._confirmations[0][2] != estimate[2]: self._confirmations.clear() self._confirmations.append( @@ -1023,6 +1114,8 @@ def _consider_estimate(self) -> Optional[FOPDTModel]: if not self._confirmation_is_stable(): return None + # Phase 4: publish after 20 stable winners. Smooth gain and tau when + # revising a model, but take the discrete winning delay directly. confirmation_count = len(self._confirmations) confidence = max( 0.0, diff --git a/docs/superpowers/plans/2026-07-24-adaptive-smith-predictor.md b/docs/superpowers/plans/2026-07-24-adaptive-smith-predictor.md new file mode 100644 index 000000000..f2a1a3c63 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-adaptive-smith-predictor.md @@ -0,0 +1,1116 @@ +# Adaptive Smith Predictor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace PID-SP's rate extrapolation with a confidence-gated adaptive FOPDT Smith predictor driven by applied auger duty, persist trusted physical parameters, and verify it across small, medium, and large simulated grills through 600°F. + +**Architecture:** `controller/smith_predictor.py` owns bounded command history, exact first-order model propagation, banked-delay RLS identification, and immutable physical-model snapshots. PID-SP composes those objects and exposes optional runtime hooks; pure runtime/persistence adapters keep `control.py` changes narrow and JSON-safe. The simulator supplies deterministic time, exact applied-duty feedback, three named plants, and model diagnostics. + +**Tech Stack:** Python 3.14 standard library, `unittest`, existing PiFire controller interface, JSON, deterministic simulator. + +## Global Constraints + +- Follow `docs/superpowers/specs/2026-07-24-adaptive-smith-predictor-design.md` exactly. +- Runtime estimator/predictor and simulator remain Python-standard-library only. +- Keep all per-update work and memory bounded for Raspberry Pi operation. +- Do not inject identification excitation into production commands. +- Feed the model only actual applied duty after clamping; record manual 0/1 override transitions exactly. +- Preserve other PID controllers' equations and behavior. +- Use one Smith temperature consistently for PID-SP proportional, integral, and derivative terms. +- Persist trusted `K`, `tau`, and `theta` indefinitely; never persist dynamic model state or command history. +- Keep Python syntax compatible with 3.9; do not use `X | Y` annotations or parameterized built-in collections where existing compatibility checks reject them. +- Use the repository-safe VCS workflow from the required `jujutsu` skill before every commit/status operation. + +--- + +### Task 1: Capture the pre-change PID-SP baseline + +**Files:** +- Read: `pid_simulator.py` +- Create runtime artifact only: `/tmp/adaptive-smith-before.txt` + +**Interfaces:** +- Consumes: Current `pid_simulator.py` CLI. +- Produces: Immutable before-results used by Task 8; no source changes. + +- [ ] **Step 1: Run the existing medium-plant PID-SP comparison** + +Run: + +```bash +python3 pid_simulator.py --scenario all --controller pid_sp > /tmp/adaptive-smith-before.txt +``` + +Expected: exit 0; output begins with `PID controller simulation` and contains six summary rows: three scenarios × two setpoint modes. + +- [ ] **Step 2: Verify the baseline artifact is complete** + +Run: + +```bash +python3 -c "from pathlib import Path; p=Path('/tmp/adaptive-smith-before.txt'); s=p.read_text(); assert s.startswith('PID controller simulation'); assert all(x in s for x in ('250','350','450','production-reset','continuous','pid_sp')); print(len(s.splitlines()))" +``` + +Expected: a positive line count and exit 0. + +--- + +### Task 2: Implement bounded duty history and exact Smith model propagation + +**Files:** +- Create: `controller/smith_predictor.py` +- Create: `tests/test_smith_predictor.py` + +**Interfaces:** +- Produces: + - `FOPDTModel(gain_f_per_duty, tau_seconds, theta_seconds, confidence, residual, observations, revision)` immutable dataclass. + - `DutyHistory(max_age_seconds=300.0)` with `record(timestamp, duty, identification_allowed)`, `value_at(timestamp)`, `average(start, end, delay_seconds=0.0)`, `interval_allowed(start, end)`, and `prune(now)`. + - `SmithPredictor(units, clock)` with `record_output(duty, identification_allowed=True, timestamp=None)`, `set_model(model)`, `clear_dynamic_state()`, `update(measured_temperature, timestamp=None)`, and `status()`. +- Consumes: A zero-argument clock callable and native-unit temperatures. + +- [ ] **Step 1: Write failing history and predictor tests** + +Add tests with a deterministic callable clock: + +```python +class FakeClock: + def __init__(self): + self.now = 0.0 + + def __call__(self): + return self.now + + +def model(gain=600.0, tau=300.0, delay=20.0, revision=1): + return FOPDTModel(gain, tau, delay, 0.95, 0.1, 300, revision) +``` + +Required test contracts: + +```python +def test_duty_history_time_weights_fractional_boundaries(self): + history = DutyHistory(max_age_seconds=300.0) + history.record(0.0, 0.2, True) + history.record(10.0, 0.8, True) + self.assertAlmostEqual(history.average(5.0, 15.0), 0.5) + self.assertAlmostEqual(history.average(25.0, 35.0, delay_seconds=20.0), 0.5) + + +def test_duty_history_retains_one_predecessor_when_pruned(self): + history = DutyHistory(max_age_seconds=30.0) + for second in range(0, 101, 10): + history.record(float(second), second / 100.0, True) + history.prune(100.0) + self.assertEqual(history.value_at(70.0), 0.7) + self.assertLessEqual(history.command_count, 5) + + +def test_smith_predictor_starts_with_zero_correction(self): + clock = FakeClock() + predictor = SmithPredictor("F", clock) + predictor.record_output(0.2) + predictor.set_model(model()) + self.assertEqual(predictor.update(250.0), 250.0) + + +def test_smith_predictor_integrates_delayed_boundary_exactly(self): + clock = FakeClock() + predictor = SmithPredictor("F", clock) + predictor.record_output(0.2) + predictor.set_model(model(gain=600.0, tau=300.0, delay=20.0)) + predictor.update(250.0) + clock.now = 10.0 + predictor.record_output(0.8) + clock.now = 40.0 + predicted = predictor.update(250.0) + undelayed = 120.0 * math.exp(-30.0 / 300.0) + 480.0 * (1.0 - math.exp(-30.0 / 300.0)) + delayed = 120.0 * math.exp(-10.0 / 300.0) + 480.0 * (1.0 - math.exp(-10.0 / 300.0)) + self.assertAlmostEqual(predicted, 250.0 + undelayed - delayed, places=9) + + +def test_invalid_model_and_nonfinite_state_fall_back_to_measured(self): + predictor = SmithPredictor("F", FakeClock()) + with self.assertRaises(ValueError): + predictor.set_model(model(tau=0.0)) + predictor.record_output(0.2) + predictor.set_model(model()) + predictor._undelayed_state = math.nan + self.assertEqual(predictor.update(275.0), 275.0) + self.assertFalse(predictor.status()["prediction_active"]) + + +def test_celsius_uses_the_same_fahrenheit_model_correction(self): + fahrenheit_clock = FakeClock() + celsius_clock = FakeClock() + fahrenheit = SmithPredictor("F", fahrenheit_clock) + celsius = SmithPredictor("C", celsius_clock) + for predictor in (fahrenheit, celsius): + predictor.record_output(0.2) + predictor.set_model(model()) + fahrenheit.update(250.0) + celsius.update((250.0 - 32.0) * 5.0 / 9.0) + fahrenheit_clock.now = celsius_clock.now = 10.0 + fahrenheit.record_output(0.8) + celsius.record_output(0.8) + fahrenheit_clock.now = celsius_clock.now = 40.0 + f_correction = fahrenheit.update(250.0) - 250.0 + measured_c = (250.0 - 32.0) * 5.0 / 9.0 + c_correction = celsius.update(measured_c) - measured_c + self.assertAlmostEqual(c_correction, f_correction * 5.0 / 9.0) + + +def test_four_implausible_one_step_residuals_disable_prediction(self): + clock = FakeClock() + predictor = SmithPredictor("F", clock) + predictor.record_output(0.2) + predictor.set_model(model()) + predictor.update(250.0) + for index, temperature in enumerate((400.0, 550.0, 700.0, 850.0), 1): + clock.now = index * 15.0 + predictor.update(temperature) + self.assertFalse(predictor.status()["prediction_active"]) +``` + +- [ ] **Step 2: Run the new tests and confirm the missing-module failure** + +Run: + +```bash +python3 -m unittest tests.test_smith_predictor -v +``` + +Expected: FAIL because `controller.smith_predictor` does not exist. + +- [ ] **Step 3: Implement immutable model validation and bounded history** + +Use these exact invariants in `controller/smith_predictor.py`: + +```python +MIN_GAIN_F = 50.0 +MAX_GAIN_F = 2000.0 +MIN_TAU_SECONDS = 300.0 +MAX_TAU_SECONDS = 20000.0 +MIN_PREDICTED_F = -100.0 +MAX_PREDICTED_F = 1200.0 + +@dataclass(frozen=True) +class FOPDTModel: + gain_f_per_duty: float + tau_seconds: float + theta_seconds: float + confidence: float + residual: float + observations: int + revision: int = 0 + + def validate(self): + values = (self.gain_f_per_duty, self.tau_seconds, self.theta_seconds, + self.confidence, self.residual) + if not all(math.isfinite(value) for value in values): + raise ValueError("FOPDT model values must be finite") + if not MIN_GAIN_F <= self.gain_f_per_duty <= MAX_GAIN_F: + raise ValueError("gain is outside physical bounds") + if not MIN_TAU_SECONDS <= self.tau_seconds <= MAX_TAU_SECONDS: + raise ValueError("tau is outside physical bounds") + if not 0.0 <= self.theta_seconds <= 120.0: + raise ValueError("theta is outside candidate bounds") + if not 0.0 <= self.confidence <= 1.0 or self.residual < 0.0: + raise ValueError("confidence or residual is invalid") + if self.observations < 0 or self.revision < 0: + raise ValueError("counts must be non-negative") +``` + +`DutyHistory.record()` must reject non-finite timestamps/duties and duty outside `[0, 1]`; replace the last command at an identical timestamp; collapse only truly identical adjacent commands. `average()` must integrate piecewise-constant duty exactly, treating the first known command as prehistory. `interval_allowed()` must return false if any segment intersecting the interval is marked invalid. `prune()` must retain the command immediately before the cutoff. + +- [ ] **Step 4: Implement exact first-order Smith propagation** + +Use the closed-form segment update, never Euler stepping: + +```python +def _advance_state(state, duty, duration, model): + equilibrium = model.gain_f_per_duty * duty + return equilibrium + (state - equilibrium) * math.exp(-duration / model.tau_seconds) +``` + +At model activation initialize both states to `K × current_duty`. For update interval `[last_time, now]`, advance the undelayed branch at actual command timestamps and the delayed branch at timestamps shifted by `theta`. Convert only the final correction between Fahrenheit and Celsius (`delta_C = delta_F × 5/9`). Track the one-step residual as measured change minus delayed-model change only across identification-allowed intervals; four consecutive absolute residuals above 100°F disable dynamic prediction. Return measured temperature and deactivate dynamic prediction if any state/correction/output is non-finite or outside broad physical bounds. + +- [ ] **Step 5: Run predictor tests** + +Run: + +```bash +python3 -m unittest tests.test_smith_predictor -v +``` + +Expected: all Task 2 tests PASS. + +- [ ] **Step 6: Commit the predictor core** + +```bash +git add controller/smith_predictor.py tests/test_smith_predictor.py +git commit -m "Add exact FOPDT Smith model core" +``` + +--- + +### Task 3: Implement the banked-delay adaptive identifier + +**Files:** +- Modify: `controller/smith_predictor.py` +- Modify: `tests/test_smith_predictor.py` + +**Interfaces:** +- Produces `AdaptiveFOPDTIdentifier(units, clock, delay_candidates=None)` with: + - `record_output(duty, identification_allowed=True, timestamp=None)` + - `observe(temperature, timestamp=None) -> Optional[FOPDTModel]` + - `restore_trusted_model(model)` + - `trusted_model` property + - `status() -> dict` +- Consumes `FOPDTModel` and `DutyHistory` from Task 2. + +- [ ] **Step 1: Write failing estimator recovery and confidence tests** + +Add a deterministic FOPDT generator that advances at one-second resolution, changes commanded duty every 300–900 seconds, records controller observations every 15 seconds, and supports irregular observation offsets `(15, 31, 46, 62, ...)`. Use exact plant triples from the specification. + +Required contracts: + +```python +def test_identifier_recovers_small_medium_and_large_models(self): + profiles = ( + (640.0, 3333.3333333333335, 20.0), + (647.0588235294117, 4705.882352941177, 35.0), + (700.0, 6500.0, 50.0), + ) + for gain, tau, delay in profiles: + with self.subTest(gain=gain, tau=tau, delay=delay): + estimate = identify_synthetic_fopdt(gain, tau, delay) + self.assertIsNotNone(estimate) + self.assertAlmostEqual(estimate.gain_f_per_duty, gain, delta=gain * 0.10) + self.assertAlmostEqual(estimate.tau_seconds, tau, delta=tau * 0.15) + self.assertAlmostEqual(estimate.theta_seconds, delay, delta=5.0) + + +def test_identifier_does_not_trust_constant_duty(self): + estimate, status = identify_constant_duty(duration_seconds=8 * 3600) + self.assertIsNone(estimate) + self.assertFalse(status["trusted"]) + self.assertLess(status["duty_stddev"], 0.05) + + +def test_identifier_rejects_ambiguous_delay(self): + identifier = AdaptiveFOPDTIdentifier("F", FakeClock()) + feed_data_with_no_delayed_input_separation(identifier) + self.assertIsNone(identifier.trusted_model) + self.assertLess(identifier.status()["delay_residual_margin"], 0.10) + + +def test_disturbed_interval_does_not_create_observation(self): + clock = FakeClock() + identifier = AdaptiveFOPDTIdentifier("F", clock) + identifier.record_output(0.2, True) + identifier.observe(200.0) + clock.now = 15.0 + identifier.record_output(1.0, False) + clock.now = 45.0 + identifier.observe(240.0) + self.assertEqual(identifier.status()["accepted_observations"], 0) + clock.now = 60.0 + identifier.record_output(0.3, True) + identifier.observe(241.0) + clock.now = 75.0 + identifier.observe(242.0) + self.assertEqual(identifier.status()["accepted_observations"], 1) +``` + +Also test negative gain, unstable beta, NaN, and out-of-bounds candidates remain ineligible. + +- [ ] **Step 2: Run the identifier tests and confirm failure** + +Run: + +```bash +python3 -m unittest tests.test_smith_predictor -v +``` + +Expected: FAIL because `AdaptiveFOPDTIdentifier` is missing. + +- [ ] **Step 3: Implement fixed-size RLS candidates** + +For every candidate `theta = 0, 5, ..., 120`, maintain only: + +```python +@dataclass +class _RLSCandidate: + delay_seconds: float + coefficients: list + covariance: list + residual_ewma: Optional[float] = None + valid_updates: int = 0 + + @classmethod + def create(cls, delay_seconds): + return cls( + float(delay_seconds), + [0.0, 0.0, 0.0], + [[1e6, 0.0, 0.0], [0.0, 1e6, 0.0], [0.0, 0.0, 1e6]], + ) + + +def _dot(left, right): + return sum(a * b for a, b in zip(left, right)) + + +def _matrix_vector(matrix, vector): + return [_dot(row, vector) for row in matrix] +``` + +For `z = (T_previous_F - temperature_reference_F) / 500.0`, `phi = [1.0, z, delayed_average_duty]`, and `y = (T_current_F - T_previous_F) / dt`, update with: + +```python +p_phi = _matrix_vector(candidate.covariance, phi) +denominator = 0.9995 + _dot(phi, p_phi) +gain_vector = [value / denominator for value in p_phi] +error = y - _dot(phi, candidate.coefficients) +candidate.coefficients = [ + value + gain_component * error + for value, gain_component in zip(candidate.coefficients, gain_vector) +] +candidate.covariance = [ + [ + (candidate.covariance[row][column] + - gain_vector[row] * sum(phi[index] * candidate.covariance[index][column] for index in range(3))) + / 0.9995 + for column in range(3) + ] + for row in range(3) +] +candidate.residual_ewma = ( + error * error if candidate.residual_ewma is None + else 0.98 * candidate.residual_ewma + 0.02 * error * error +) +``` + +Transform coefficients back with: + +```python +beta_t = coefficients[1] / 500.0 +beta_u = coefficients[2] +beta_0 = coefficients[0] - beta_t * temperature_reference_f +tau = -1.0 / beta_t +gain_f = -beta_u / beta_t +offset_f = -beta_0 / beta_t +``` + +Guard every division and update with finite checks. Reset only the failed candidate to its initial matrix. + +- [ ] **Step 4: Implement excitation, uncertainty, delay, and confirmation gates** + +Use the exact thresholds from the specification: 3600 accepted seconds, 240 observations, duty standard deviation 0.05, a 0.05 duty transition held 60 seconds, 15°F temperature span, physical bounds, relative standard errors 20% for gain and 25% for tau, winning residual margin 10%, and 20 stable estimates with gain spread ≤5%, tau spread ≤7.5%, and one unchanged delay. +Maintain excitation with constant-memory Welford count/mean/M2 statistics for accepted applied duty, running temperature min/max, accepted seconds/count, and a boolean sustained-transition latch. Keep only the 20-sample confirmation deque; never retain an unbounded observation list. + +Compute uncertainty with residual-scaled covariance and the delta method: + +```python +var_beta_t = residual * covariance[1][1] / (500.0 * 500.0) +var_beta_u = residual * covariance[2][2] +cov_beta = residual * covariance[1][2] / 500.0 +var_tau = var_beta_t / (beta_t ** 4) +d_gain_d_beta_t = beta_u / (beta_t ** 2) +d_gain_d_beta_u = -1.0 / beta_t +var_gain = ( + d_gain_d_beta_t ** 2 * var_beta_t + + d_gain_d_beta_u ** 2 * var_beta_u + + 2.0 * d_gain_d_beta_t * d_gain_d_beta_u * cov_beta +) +``` +For a gate-passing estimate, publish bounded confidence with: + +```python +confidence = max( + 0.0, + min( + 1.0, + delay_residual_margin / 0.10, + 0.20 / max(gain_relative_standard_error, 1e-12), + 0.25 / max(tau_relative_standard_error, 1e-12), + confirmation_count / 20.0, + ), +) +``` + +On first trust publish revision 1. A later candidate is material only after the same confirmation and a ≥5% gain/tau or ≥5-second delay change; blend gain/tau by 0.1 and increment revision. Restoration sets the trusted model immediately but leaves RLS matrices fresh. + +- [ ] **Step 5: Run estimator tests** + +Run: + +```bash +python3 -m unittest tests.test_smith_predictor -v +``` + +Expected: predictor and identifier tests PASS for all three physical profiles. + +- [ ] **Step 6: Commit adaptive identification** + +```bash +git add controller/smith_predictor.py tests/test_smith_predictor.py +git commit -m "Identify FOPDT parameters from applied duty" +``` + +--- + +### Task 4: Replace PID-SP extrapolation with the adaptive Smith signal + +**Files:** +- Modify: `controller/pid_sp.py` +- Modify: `controller/controllers.json` +- Modify: `tests/test_pid_sp.py` + +**Interfaces:** +- Consumes `AdaptiveFOPDTIdentifier`, `FOPDTModel`, and `SmithPredictor` from Task 3. +- Produces optional controller hooks: + - `set_output(applied_ratio, identification_allowed=True)` + - `get_model_snapshot() -> Optional[dict]` + - `restore_model(snapshot) -> bool` + - `get_status() -> dict` +- Preserves `Controller(config, units, cycle_data)`, `update(current)`, `set_target(set_point)`, `set_gains(...)`, and `get_k()`. + +- [ ] **Step 1: Rewrite PID-SP tests around observable Smith behavior** + +Remove `tau`/`theta` from `CONFIG`, remove the obsolete configured-parameter rejection and metadata-minimum tests, and retain first-sample, target baseline, startup reduction, and integral-bound contracts without asserting `roc`. + +Add: + +```python +def trusted_snapshot(gain=647.0588235294117, tau=4705.882352941177, theta=35.0): + return { + "version": 1, + "gain_f_per_duty": gain, + "tau_seconds": tau, + "theta_seconds": theta, + "confidence": 0.95, + "residual": 0.01, + "observations": 500, + "revision": 1, + } + + +def test_measured_temperature_is_used_before_model_trust(self): + controller = self.make_controller() + controller.set_target(250.0) + controller.update(240.0) + self.assertFalse(controller.get_status()["prediction_active"]) + self.assertEqual(controller.get_status()["controller_input_temperature"], 240.0) + + +def test_restored_model_drives_one_temperature_for_p_i_and_d(self): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + self.assertTrue(controller.restore_model(trusted_snapshot())) + controller.set_target(250.0) + controller.set_output(0.2) + with patch("controller.pid_sp.time.time", return_value=15.0): + controller.update(240.0) + controller.set_output(0.8) + previous_selected = controller.get_status()["controller_input_temperature"] + with patch("controller.pid_sp.time.time", return_value=60.0): + controller.update(242.0) + status = controller.get_status() + selected = status["controller_input_temperature"] + self.assertAlmostEqual(controller.p, controller.kp * (selected - 250.0) + controller.center) + self.assertAlmostEqual(controller.derv, (selected - previous_selected) / 45.0) + + +def test_set_target_preserves_model_and_identifier_state(self): + controller = self.make_controller() + controller.restore_model(trusted_snapshot()) + controller.set_output(0.3) + before = controller.get_model_snapshot() + accepted = controller.get_status()["accepted_observations"] + controller.set_target(350.0) + self.assertEqual(controller.get_model_snapshot(), before) + self.assertEqual(controller.get_status()["accepted_observations"], accepted) + + +def test_snapshot_validation_and_status_are_json_safe(self): + controller = self.make_controller() + self.assertFalse(controller.restore_model(dict(trusted_snapshot(), tau_seconds=0.0))) + controller.restore_model(trusted_snapshot()) + json.dumps(controller.get_status()) + json.dumps(controller.get_model_snapshot()) + + +def test_metadata_no_longer_exposes_fixed_model_parameters(self): + metadata = json.loads(Path("controller/controllers.json").read_text())["metadata"]["pid_sp"] + option_names = {option["option_name"] for option in metadata["config"]} + self.assertNotIn("tau", option_names) + self.assertNotIn("theta", option_names) +``` + +- [ ] **Step 2: Run PID-SP tests and verify they fail** + +Run: + +```bash +python3 -m unittest tests.test_pid_sp -v +``` + +Expected: FAIL on missing runtime hooks and still-present metadata. + +- [ ] **Step 3: Integrate identifier and predictor in PID-SP** + +In `__init__`, append `set_output`, `get_model_snapshot`, `restore_model`, and `get_status` to `function_list`; instantiate both components with `clock=lambda: time.time()` so later simulator replacements and unit-test patches remain visible; remove configured `tau`, `theta`, `_validate_predictor_config`, and `roc` state; add `previous_controller_input = None`. + +Use this update order: + +```python +current_time = time.time() +trusted_update = self._identifier.observe(current, current_time) +if trusted_update is not None: + self._predictor.set_model(trusted_update) +controller_input = self._predictor.update(current, current_time) +error = controller_input - self.set_point +``` + +Use `error` for overshoot, reset, P, and I decisions. Compute derivative as zero on the first selected sample, otherwise `(controller_input - previous_controller_input) / dt`. Save measured `current` in `self.last` for setpoint-transition bookkeeping and selected input in `previous_controller_input` for derivative. Preserve the corrected output-first startup reduction and integral accumulator clamp. + +`set_output()` must pass the same timestamp and command to identifier and predictor. `set_target()` must not recreate either component or clear model/duty state. + +- [ ] **Step 4: Implement snapshots and JSON-safe diagnostics** + +Snapshot keys must exactly match `trusted_snapshot()` above. `restore_model()` checks version 1, constructs/validates `FOPDTModel`, restores it into both components, and returns false rather than raising for malformed external data. `get_status()` returns only scalar JSON values and includes `prediction_active`, `controller_input_temperature`, `predicted_temperature`, `undelayed_model`, `delayed_model`, `estimated_gain_f_per_duty`, `estimated_tau_seconds`, `estimated_theta_seconds`, `model_confidence`, `model_residual`, `accepted_observations`, and `model_revision`. + +- [ ] **Step 5: Remove fixed model options from metadata** + +Delete the complete `tau` and `theta` option objects from PID-SP's `config` array. Update the PID-SP description to say gain, time constant, and dead time are learned from applied duty and temperature. Reformat the JSON with the existing `jq` convention. + +- [ ] **Step 6: Run PID-SP and Smith tests** + +Run: + +```bash +python3 -m unittest tests.test_smith_predictor tests.test_pid_sp -v +``` + +Expected: all tests PASS. + +- [ ] **Step 7: Commit PID-SP integration** + +```bash +git add controller/pid_sp.py controller/controllers.json tests/test_pid_sp.py +git commit -m "Use adaptive Smith feedback in PID-SP" +``` + +--- + +### Task 5: Add atomic durable model storage and optional-controller adapters + +**Files:** +- Create: `common/adaptive_controller_state.py` +- Create: `controller/runtime.py` +- Create: `tests/test_adaptive_controller_state.py` +- Modify: `.gitignore` + +**Interfaces:** +- Produces `AdaptiveControllerStateStore(path=Path("adaptive_controller_state.json"), clock=time.time, min_write_interval=1800.0)` with `load(name)`, `stage(name, snapshot)`, and `flush(force=False)`. +- Produces runtime adapter functions: + - `supports(controller, function_name)` + - `record_output(controller, duty, identification_allowed=True)` + - `restore_model(controller, store, name)` + - `stage_model(controller, store, name)` + - `diagnostics(controller)` + - `apply_live_hold_target(controller, active_mode, control) -> bool` + +- [ ] **Step 1: Write failing state-store and adapter tests** + +Use `tempfile.TemporaryDirectory`, fake clocks, and fake controllers. Required contracts: + +```python +def test_state_store_round_trips_without_age_expiry(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "adaptive.json" + clock = FakeClock() + store = AdaptiveControllerStateStore(path, clock) + store.stage("pid_sp", trusted_snapshot()) + self.assertTrue(store.flush(force=True)) + clock.now = 10 * 365 * 24 * 3600 + restored = AdaptiveControllerStateStore(path, clock).load("pid_sp") + self.assertEqual(restored["gain_f_per_duty"], trusted_snapshot()["gain_f_per_duty"]) + + +def test_state_store_throttles_then_force_flushes_atomically(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "adaptive.json" + clock = FakeClock() + store = AdaptiveControllerStateStore(path, clock) + store.stage("pid_sp", trusted_snapshot()) + self.assertTrue(store.flush()) + clock.now = 10.0 + store.stage("pid_sp", dict(trusted_snapshot(), revision=2)) + self.assertFalse(store.flush()) + self.assertTrue(store.flush(force=True)) + self.assertFalse(list(path.parent.glob(path.name + ".tmp-*"))) + + +def test_corrupt_state_is_ignored(self): + path.write_text("not json") + self.assertIsNone(AdaptiveControllerStateStore(path).load("pid_sp")) + + +def test_runtime_uses_optional_hooks_and_json_status(self): + controller = FakeAdaptiveController() + record_output(controller, 0.4, False) + self.assertEqual(controller.outputs[-1], (0.4, False)) + self.assertEqual(diagnostics(controller), controller.get_status()) + + +def test_live_hold_target_update_only_handles_target_only_hold_change(self): + controller = FakeAdaptiveController(set_point=250.0) + control = {"updated": True, "mode": "Hold", "primary_setpoint": 275.0, + "units_change": False} + self.assertTrue(apply_live_hold_target(controller, "Hold", control)) + self.assertEqual(controller.targets, [275.0]) + self.assertFalse(control["updated"]) + control.update(updated=True, mode="Stop") + self.assertFalse(apply_live_hold_target(controller, "Hold", control)) +``` + +Also prove plain controllers without hooks are no-ops and that `diagnostics()` returns `dict(controller.__dict__)`, preserving the existing diagnostics payload for every non-adaptive controller. + +- [ ] **Step 2: Run the new tests and verify missing-module failures** + +Run: + +```bash +python3 -m unittest tests.test_adaptive_controller_state -v +``` + +Expected: FAIL because the new modules do not exist. + +- [ ] **Step 3: Implement atomic throttled storage** + +Persist this root schema: + +```json +{ + "version": 1, + "models": { + "pid_sp": { + "version": 1, + "gain_f_per_duty": 647.0588235294117, + "tau_seconds": 4705.882352941177, + "theta_seconds": 35.0, + "confidence": 0.95, + "residual": 0.01, + "observations": 500, + "revision": 1 + } + } +} +``` + +`stage()` ignores snapshots whose revision is not newer than the stored/pending revision. `flush(False)` writes only when pending and 1800 seconds have elapsed since the last successful write; the first routine write may occur immediately when no file exists. `flush(True)` writes any pending state. Write with `tempfile.NamedTemporaryFile(mode="w", dir=path.parent, prefix=path.name + ".tmp-", delete=False)`, `json.dump(..., sort_keys=True, indent=2)`, `flush()`, `os.fsync()`, and `os.replace()`. Always remove an un-replaced temp file in `finally`. + +- [ ] **Step 4: Implement optional runtime adapters** + +`supports()` calls `controller.supported_functions()` and never assumes an adaptive method exists. `record_output`, restore, stage, and diagnostics dispatch only when supported; diagnostics returns `dict(controller.__dict__)` unchanged when `get_status` is unsupported. `apply_live_hold_target()` returns true only when active mode and requested mode are both `Hold`, `updated` is true, `units_change` is false, target differs, and `set_target` is supported; it calls `set_target`, clears `updated`, and leaves every other update for normal work-cycle teardown. + +- [ ] **Step 5: Ignore runtime state and run tests** + +Append `/adaptive_controller_state.json` to `.gitignore`, then run: + +```bash +python3 -m unittest tests.test_adaptive_controller_state -v +``` + +Expected: all state and adapter tests PASS. + +- [ ] **Step 6: Commit persistence support** + +```bash +git add .gitignore common/adaptive_controller_state.py controller/runtime.py tests/test_adaptive_controller_state.py +git commit -m "Persist trusted adaptive controller models" +``` + +--- + +### Task 6: Wire applied duty, live targets, diagnostics, and persistence into production + +**Files:** +- Modify: `control.py:257-277` (`_init_controller` call path) +- Modify: `control.py:430-450` (work-cycle controller initialization) +- Modify: `control.py:574-615` (control updates and reinitialization) +- Modify: `control.py:650-735` (manual override and PID output clamping) +- Modify: `control.py:744-746` (PID diagnostics) +- Modify: `control.py:1079-1113` (work-cycle cleanup) +- Modify: `tests/test_adaptive_controller_state.py` + +**Interfaces:** +- Consumes `AdaptiveControllerStateStore` and all `controller.runtime` adapters from Task 5. +- Produces production behavior only; no controller API changes. + +- [ ] **Step 1: Add a source-isolated production integration regression** + +Because importing `control.py` initializes hardware/Redis dependencies, inspect the work-cycle through a small AST/source-isolated test that executes only newly extracted pure helper functions; do not assert source text. Move these decisions into testable functions in `controller/runtime.py` if needed: + +```python +def identification_allowed(lid_open, manual_override_active, fan_pid_active): + return not (lid_open or manual_override_active or fan_pid_active) + + +def manual_override_duty(output): + return 1.0 if output else 0.0 +``` + +Tests must cover every truth-table input and exact 0/1 duty conversion. The production smoke check in Step 6 verifies imports/compilation after call-site edits. + +- [ ] **Step 2: Restore and seed adaptive state at every controller creation** + +Create one `AdaptiveControllerStateStore` per `_work_cycle`. After each successful `_init_controller`: + +```python +restore_model(controllerCore, adaptive_store, settings["controller"]["selected"]) +record_output(controllerCore, CycleRatio, identification_allowed=True) +``` + +Apply this after initial construction and controller-settings reinitialization. Seeding must happen under the current command timestamp and before the first PID update. + +- [ ] **Step 3: Retain live PID-SP for target-only Hold changes** + +Immediately after `control = read_control()` and before the generic `if control['updated']: break`: + +```python +if apply_live_hold_target(controllerCore, mode, control): + write_control(control, direct_write=True, origin="control") +elif control["updated"]: + break +``` + +Do not intercept unit, mode, controller, or controller-configuration changes. + +- [ ] **Step 4: Feed exact normal and manual commands** + +After normal min/max/lid clamp calculation: + +```python +record_output( + controllerCore, + CycleRatio, + identification_allowed=identification_allowed( + LidOpenDetect, + manual_override["auger"] >= now, + ControlFanPid, + ), +) +``` + +At manual auger override start call `record_output(controllerCore, manual_override_duty(control['manual']['output']), False)`. At override expiration call `record_output(controllerCore, CycleRatio, False)` before normal cyclic control resumes. + +- [ ] **Step 5: Use JSON-safe diagnostics and stage model revisions** + +Replace PID-SP's direct `controllerCore.__dict__` notification payload with `diagnostics(controllerCore)`. After each controller update: + +```python +stage_model(controllerCore, adaptive_store, settings["controller"]["selected"]) +adaptive_store.flush() +``` + +At every work-cycle exit, stage once more and call `adaptive_store.flush(force=True)` before returning. Persistence failure must log and continue without clearing the in-memory model. + +- [ ] **Step 6: Run focused production compatibility checks** + +Run: + +```bash +python3 -m unittest tests.test_adaptive_controller_state tests.test_pid_sp -v +python3 -m py_compile control.py common/adaptive_controller_state.py controller/runtime.py controller/pid_sp.py controller/smith_predictor.py +``` + +Expected: all tests PASS and `py_compile` exits 0. + +- [ ] **Step 7: Commit production integration** + +```bash +git add control.py tests/test_adaptive_controller_state.py +git commit -m "Feed applied auger duty to PID-SP" +``` + +--- + +### Task 7: Add grill profiles, 600°F coverage, and model diagnostics to the simulator + +**Files:** +- Modify: `pid_simulator.py` +- Modify: `tests/test_pid_simulator.py` + +**Interfaces:** +- Produces: + - `PLANT_PROFILES: dict[str, PlantConfig]` with `small`, `medium`, `large`. + - `--plant {small,medium,large}`, default `medium`. + - Scenario `600`. + - `build_identification_scenario(plant) -> Scenario`. + - Model diagnostic fields on `Sample`/`SimulationResult`. +- Consumes PID-SP optional `set_output` and `get_status` hooks through `controller.runtime`. + +- [ ] **Step 1: Write failing plant-profile and CLI tests** + +Add contracts: + +```python +def test_named_plants_match_exact_physics_and_reach_600(self): + expected = { + "small": (250.0, 48.0, 0.075, 20, 640.0, 3333.3333333333335), + "medium": (400.0, 55.0, 0.085, 35, 647.0588235294117, 4705.882352941177), + "large": (650.0, 70.0, 0.100, 50, 700.0, 6500.0), + } + for name, (mass, heat, loss, delay, gain, tau) in expected.items(): + plant = PLANT_PROFILES[name] + self.assertEqual((plant.thermal_mass, plant.heat_input_per_second, + plant.heat_loss_coefficient, plant.firebox_delay_seconds), + (mass, heat, loss, delay)) + self.assertAlmostEqual(plant.heat_input_per_second / loss, gain) + self.assertAlmostEqual(plant.thermal_mass / loss, tau) + required_duty = (600.0 - plant.ambient_f) / gain + self.assertLess(required_duty, U_MAX) + + +def test_cli_selects_large_plant_and_600_scenario(self): + with redirect_stdout(io.StringIO()) as stdout: + self.assertEqual(main(["--plant", "large", "--scenario", "600", + "--controller", "pid_sp", "--setpoint-mode", "continuous"]), 0) + report = stdout.getvalue() + self.assertIn("Plant: large", report) + self.assertIn("600", report) + + +def test_simulator_feedback_uses_clamped_duty(self): + result = simulate_controller("pid_sp", short_scenario(), PLANT_PROFILES["medium"], 15, "continuous") + self.assertTrue(all(U_MIN <= sample.duty_ratio <= U_MAX for sample in result.samples)) + self.assertEqual(result.samples[15].model_applied_duty, result.samples[15].duty_ratio) +``` + +Update existing scenario-count tests for four scenarios and preserve explicit-empty-filter behavior. + +- [ ] **Step 2: Run simulator tests and verify failures** + +Run: + +```bash +python3 -m unittest tests.test_pid_simulator -v +``` + +Expected: FAIL on missing profiles, scenario, CLI option, and diagnostics. + +- [ ] **Step 3: Add immutable profiles and profile-aware CLI overrides** + +Define: + +```python +PLANT_PROFILES = { + "small": PlantConfig(250.0, 48.0, 0.075, 70.0, 20), + "medium": PlantConfig(400.0, 55.0, 0.085, 70.0, 35), + "large": PlantConfig(650.0, 70.0, 0.100, 70.0, 50), +} +``` + +Add `--plant`, default `medium`. Change `--ambient-f` and `--delay-seconds` parser defaults to `None`; apply them with `dataclasses.replace` only when explicitly supplied, so named profile values survive ordinary CLI use. Add `600` as a four-hour constant Hold beginning at 550°F. + +- [ ] **Step 4: Feed initial and clamped applied duty under deterministic time** + +After controller creation and target assignment, call the optional output hook with initial `U_MIN`. After every `update()` clamp, call it with the new duty before advancing the plant. For production-reset transitions, seed the new controller at `U_MIN`; continuous transitions retain controller/model history. + +Sample model diagnostics only through `get_status()` and add JSON/CSV-safe scalar fields: applied model duty, prediction active, predicted temperature, estimated gain/tau/theta, confidence, and residual. Add final model fields and activation second to `SimulationResult` and `format_summary()`. + +- [ ] **Step 5: Build a profile-aware identification scenario** + +For `tau = plant.thermal_mass / plant.heat_loss_coefficient`, create a scenario with transitions at `0`, `round(tau)`, `round(2*tau)`, and `round(3*tau)`, targets `250`, `350`, `450`, and `300`, initial pit 200°F, and duration `round(4.5*tau)`. This scenario is an API/test fixture, not part of default `--scenario all` output. + +- [ ] **Step 6: Run simulator unit tests** + +Run: + +```bash +python3 -m unittest tests.test_pid_simulator -v +``` + +Expected: all existing and new simulator tests PASS. + +- [ ] **Step 7: Commit simulator profiles and diagnostics** + +```bash +git add pid_simulator.py tests/test_pid_simulator.py +git commit -m "Simulate adaptive PID-SP across grill sizes" +``` + +--- + +### Task 8: Prove closed-loop identification on all plants and compare performance + +**Files:** +- Modify: `tests/test_pid_simulator.py` +- Runtime artifacts: `/tmp/adaptive-smith-after.txt`, `/tmp/adaptive-smith-identification.txt` + +**Interfaces:** +- Consumes `PLANT_PROFILES`, `build_identification_scenario`, simulator model diagnostics, and `/tmp/adaptive-smith-before.txt`. +- Produces regression gates and measured before/after evidence. + +- [ ] **Step 1: Add failing closed-loop recovery tests** + +Add: + +```python +def test_pid_sp_identifies_every_named_plant_from_closed_loop_data(self): + for name, plant in PLANT_PROFILES.items(): + with self.subTest(plant=name): + scenario = build_identification_scenario(plant) + result = simulate_controller("pid_sp", scenario, plant, 15, "continuous") + exact_gain = plant.heat_input_per_second / plant.heat_loss_coefficient + exact_tau = plant.thermal_mass / plant.heat_loss_coefficient + self.assertIsNotNone(result.identifier_activation_second) + self.assertAlmostEqual(result.estimated_gain_f_per_duty, exact_gain, delta=0.10 * exact_gain) + self.assertAlmostEqual(result.estimated_tau_seconds, exact_tau, delta=0.15 * exact_tau) + self.assertAlmostEqual(result.estimated_theta_seconds, plant.firebox_delay_seconds, delta=5.0) + self.assertTrue(all(math.isfinite(sample.pit_temp_f) for sample in result.samples)) + self.assertTrue(all(U_MIN <= sample.duty_ratio <= U_MAX for sample in result.samples)) + + +def test_every_named_plant_sustains_600_below_maximum_duty(self): + for name, plant in PLANT_PROFILES.items(): + result = simulate_controller("pid_sp", SCENARIOS["600"], plant, 15, "continuous") + final_window = result.samples[-600:] + self.assertLessEqual(max(abs(sample.pit_temp_f - 600.0) for sample in final_window), 5.0) + self.assertLess(result.segment_metrics[0].mean_duty_ratio, U_MAX) +``` + +- [ ] **Step 2: Run recovery tests and use failures only to correct source defects** + +Run: + +```bash +python3 -m unittest \ + tests.test_pid_simulator.PidSimulatorTests.test_pid_sp_identifies_every_named_plant_from_closed_loop_data \ + tests.test_pid_simulator.PidSimulatorTests.test_every_named_plant_sustains_600_below_maximum_duty -v +``` + +Expected: PASS. If a profile fails, inspect estimator diagnostics. Fix incorrect history alignment, coefficient scaling, uncertainty math, or confidence bookkeeping at the source. Do not add profile-specific constants, loosen the approved ±10%/±15%/±5-second tolerances, or inject excitation. + +- [ ] **Step 3: Re-run focused estimator and closed-loop tests after any correction** + +Run: + +```bash +python3 -m unittest tests.test_smith_predictor tests.test_pid_sp tests.test_pid_simulator -v +``` + +Expected: all focused tests PASS. + +- [ ] **Step 4: Generate after and identification reports** + +Run: + +```bash +python3 pid_simulator.py --plant medium --scenario all --controller pid_sp > /tmp/adaptive-smith-after.txt +python3 -c "from pid_simulator import PLANT_PROFILES, build_identification_scenario, format_summary, run_scenarios; p=PLANT_PROFILES; r=[]; [r.extend(run_scenarios(['pid_sp'], [build_identification_scenario(v)], v, 15, ['continuous'])) for v in p.values()]; print('\n\n'.join(format_summary([x], v) for x, v in zip(r, p.values())))" > /tmp/adaptive-smith-identification.txt +``` + +Expected: both commands exit 0; after report contains all default PID-SP rows; identification report contains trusted estimates for small, medium, and large. + +- [ ] **Step 5: Print a factual before/after comparison** + +Run a standard-library comparison script that parses the summary rows/segment lines from `/tmp/adaptive-smith-before.txt` and `/tmp/adaptive-smith-after.txt` and prints per scenario/mode deltas for IAE, within-5°F, directional overshoot, settling, and mean duty. Preserve the raw files; do not select only favorable rows. + +Expected: all 250/350/450°F × production-reset/continuous segments appear. There is no required performance win; values must be finite and commands bounded. + +- [ ] **Step 6: Commit closed-loop recovery gates and any source correction** + +```bash +git add controller/smith_predictor.py controller/pid_sp.py pid_simulator.py tests/test_smith_predictor.py tests/test_pid_sp.py tests/test_pid_simulator.py +git commit -m "Verify adaptive Smith identification end to end" +``` + +--- + +### Task 9: Complete regression, compatibility, smoke, and artifact review + +**Files:** +- Review: all files changed by Tasks 2–8 +- Modify only if verification identifies a source defect. + +**Interfaces:** +- Consumes every prior deliverable. +- Produces completion evidence and a clean implementation ready for branch integration. + +- [ ] **Step 1: Run the full permanent regression suite** + +Run: + +```bash +python3 -m unittest discover -s tests -p 'test_*.py' -v +``` + +Expected: all tests PASS. + +- [ ] **Step 2: Verify Python 3.14 syntax compatibility** + +Run: + +```bash +uv run --python 3.14 python -m py_compile \ + control.py pid_simulator.py \ + controller/pid_sp.py controller/smith_predictor.py controller/runtime.py \ + common/adaptive_controller_state.py \ + tests/test_pid_sp.py tests/test_smith_predictor.py \ + tests/test_adaptive_controller_state.py tests/test_pid_simulator.py +``` + +Expected: exit 0 with no output. + +- [ ] **Step 3: Run CLI smoke scenarios** + +Run: + +```bash +python3 pid_simulator.py --plant small --scenario 600 --controller pid_sp --setpoint-mode continuous +python3 pid_simulator.py --plant medium --scenario 250 --controller pid_sp --setpoint-mode production-reset +python3 pid_simulator.py --plant large --scenario 600 --controller pid_sp --setpoint-mode continuous --csv /tmp/adaptive-smith-large.csv +``` + +Expected: every command exits 0 with finite metrics; the CSV has exactly one header plus one row per simulated second. + +- [ ] **Step 4: Run LSP diagnostics on every changed Python file** + +Use `xd://lsp` diagnostics for: + +```text +control.py +pid_simulator.py +controller/pid_sp.py +controller/smith_predictor.py +controller/runtime.py +common/adaptive_controller_state.py +tests/test_pid_sp.py +tests/test_smith_predictor.py +tests/test_adaptive_controller_state.py +tests/test_pid_simulator.py +``` + +Expected: no errors. Fix real type/syntax findings and rerun the affected diagnostic/test. + +- [ ] **Step 5: Review the implementation against the approved specification** + +Check each contract explicitly: applied clamped duty and manual transitions, all-PID-term Smith input, bounded histories, exact fractional delay propagation, all three online parameters, confidence fallback, indefinite physical-parameter persistence, live Hold target retention, JSON-safe diagnostics, three plants, 600°F capability, profile-independent recovery, and measured before/after output. + +Expected: every item maps to implemented code and a passing test or smoke scenario; no obsolete `tau`/`theta` configuration or rate-extrapolation path remains. + +- [ ] **Step 6: Request code review and address only source-grounded findings** + +Use the `requesting-code-review` skill. Review all commits from the pre-Task-2 baseline through final HEAD, including production `control.py`, numerical estimator code, persistence, simulator changes, and tests. Apply valid Important/Critical findings, then repeat Steps 1–4. + +- [ ] **Step 7: Commit final review corrections if any** + +```bash +git add control.py pid_simulator.py controller common tests .gitignore +git commit -m "Harden adaptive Smith predictor" +``` + +Skip this commit only when review produces no changes. diff --git a/docs/superpowers/plans/2026-07-24-pid-controller-simulator.md b/docs/superpowers/plans/2026-07-24-pid-controller-simulator.md new file mode 100644 index 000000000..c72a38c30 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-pid-controller-simulator.md @@ -0,0 +1,541 @@ +# PID Controller Simulator Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a deterministic Python CLI that compares PiFire PID controllers in realistic pellet-grill scenarios, then correct the confirmed PID-SP defects and quantify their effect. + +**Architecture:** `pid_simulator.py` is a standard-library-only executable that instantiates PiFire's actual controller classes, supplies a deterministic per-module clock, and advances a delayed first-order thermal plant one second at a time. Every scenario runs in both `production-reset` and `continuous` setpoint-transition modes by default. Its scenario runner returns structured results for terminal formatting and optional CSV export. PID-SP remains a production controller; its testable state/validation fixes stay confined to `controller/pid_sp.py` and controller metadata. + +**Tech Stack:** Python 3 standard library (`argparse`, `csv`, `dataclasses`, `math`, `statistics`, `unittest`); existing PiFire controller modules. + +## Global Constraints + +- Run from the repository root as `python3 pid_simulator.py`; use no third-party packages, real-time sleeps, hardware, Redis, Flask, or network access. +- Simulate exactly the production controller classes: `pid`, `pid_clamping`, `pid_clamping_percent_pb`, `pid_parallel`, `pid_ac`, and `pid_sp`. +- Model a delayed first-order pellet-grill plant at one-second resolution; default controller interval is 15 seconds and default auger-to-firebox delay is 35 seconds. +- Default scenarios are 4-hour Hold-mode 250°F, 350°F, and 450°F cooks. Each starts 50°F below its initial target, then changes target at 90 and 180 minutes. +- Compare both `production-reset` and `continuous` setpoint-transition modes by default; permit repeatable CLI filtering. +- Report IAE, ±5°F time, maximum overshoot, 10-minute-window settling time, mean duty ratio, and per-segment metrics. +- Retain no legacy/broken PID-SP implementation in shipped code. Record baseline and fixed CLI output during implementation verification instead. +- Add deterministic `unittest` tests and make `tests/` trackable despite the repository’s broad ignored-test-file patterns. +- Change no controller other than PID-SP and no production behavior outside PID-SP predictor configuration metadata. + +--- + +### Task 1: Build the deterministic simulator CLI + +**Files:** +- Create: `pid_simulator.py` +- Create: `tests/__init__.py` +- Create: `tests/test_pid_simulator.py` +- Modify: `.gitignore:100-104` + +**Interfaces:** +- Produces `PlantConfig`, `Scenario`, `SegmentMetrics`, `SimulationResult`, `simulate_controller()`, `run_scenarios()`, `write_csv()`, `format_summary()`, and `main()` from `pid_simulator.py`. +- `simulate_controller(controller_name: str, scenario: Scenario, plant: PlantConfig, cycle_seconds: int, setpoint_mode: str) -> SimulationResult` returns finite scalar metrics, one `Sample` per one-second timestep with `auger_fraction: float`, `controller_update_seconds: tuple[int, ...]`, and `controller_start_seconds: tuple[int, ...]`. +- `run_scenarios(controller_names: Sequence[str] | None, scenarios: Sequence[Scenario], plant: PlantConfig, cycle_seconds: int, setpoint_modes: Sequence[str] | None) -> list[SimulationResult]` runs the Cartesian product of selected production controllers, scenarios, and modes; `None` selects every supported value. +- Task 2 consumes the CLI to capture PID-SP baseline and fixed results. + +- [ ] **Step 1: Make tests trackable and write the failing simulator contract tests.** + + Add these negations after the existing generic test-file ignores in `.gitignore`: + + ```gitignore + !tests/ + !tests/**/*.py + ``` + + Create `tests/__init__.py` as an empty file and create `tests/test_pid_simulator.py`: + + ```python + import csv + import io + import math + import tempfile + import subprocess + import sys + import unittest + from contextlib import redirect_stdout + from pathlib import Path + + from pid_simulator import ( + PlantConfig, + SCENARIOS, + format_summary, + main, + run_scenarios, + write_csv, + ) + + + class PidSimulatorTests(unittest.TestCase): + def test_all_default_controllers_produce_finite_metrics_for_every_scenario_and_mode(self): + results = run_scenarios( + controller_names=None, + scenarios=list(SCENARIOS.values()), + plant=PlantConfig(), + cycle_seconds=15, + setpoint_modes=None, + ) + + expected_controllers = { + "pid", + "pid_clamping", + "pid_clamping_percent_pb", + "pid_parallel", + "pid_ac", + "pid_sp", + } + self.assertEqual(len(results), len(expected_controllers) * len(SCENARIOS) * 2) + self.assertEqual({result.controller_name for result in results}, expected_controllers) + self.assertEqual({result.scenario_name for result in results}, set(SCENARIOS)) + self.assertEqual({result.setpoint_mode for result in results}, {"production-reset", "continuous"}) + for result in results: + self.assertTrue(math.isfinite(result.integrated_absolute_error)) + self.assertTrue(math.isfinite(result.percent_within_five_f)) + self.assertTrue(math.isfinite(result.max_overshoot)) + self.assertTrue(math.isfinite(result.mean_duty_ratio)) + self.assertEqual(len(result.segment_metrics), 3) + self.assertEqual(len(result.samples), 4 * 60 * 60) + + def test_controller_and_mode_filters_run_only_requested_combination(self): + results = run_scenarios( + controller_names=["pid_sp"], + scenarios=[SCENARIOS["350"]], + plant=PlantConfig(), + cycle_seconds=15, + setpoint_modes=["continuous"], + ) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0].controller_name, "pid_sp") + self.assertEqual(results[0].scenario_name, "350") + self.assertEqual(results[0].setpoint_mode, "continuous") + + def test_csv_contains_one_row_for_every_simulated_second(self): + result = run_scenarios( + controller_names=["pid"], + scenarios=[SCENARIOS["250"]], + plant=PlantConfig(), + cycle_seconds=15, + setpoint_modes=["production-reset"], + )[0] + + with tempfile.TemporaryDirectory() as directory: + output_path = Path(directory) / "result.csv" + write_csv(output_path, [result]) + with output_path.open(newline="") as output_file: + rows = list(csv.DictReader(output_file)) + + self.assertEqual(len(rows), len(result.samples)) + self.assertEqual( + set(rows[0]), + {"scenario", "controller", "setpoint_mode", "second", "setpoint_f", "pit_temp_f", "duty_ratio", "auger_fraction", "auger_on"}, + ) + + def test_cli_prints_summary_for_selected_scenario_and_controller(self): + stdout = io.StringIO() + with redirect_stdout(stdout): + exit_code = main(["--scenario", "450", "--controller", "pid_sp"]) + + self.assertEqual(exit_code, 0) + report = stdout.getvalue() + self.assertIn("PID controller simulation", report) + self.assertIn("450", report) + self.assertIn("pid_sp", report) + self.assertIn("IAE", report) + self.assertIn("Segment", report) + self.assertIn("production-reset", report) + self.assertIn("continuous", report) + + def test_non_divisor_cycle_waits_a_full_interval_after_transition(self): + result = run_scenarios( + controller_names=["pid"], + scenarios=[SCENARIOS["250"]], + plant=PlantConfig(), + cycle_seconds=17, + setpoint_modes=["production-reset"], + )[0] + + self.assertEqual(result.controller_update_seconds[:2], (17, 34)) + self.assertNotIn(5_406, result.controller_update_seconds) + self.assertIn(5_417, result.controller_update_seconds) + self.assertAlmostEqual(result.samples[5_400].duty_ratio, 0.05) + self.assertAlmostEqual(result.samples[5_400].auger_fraction, 0.85) + self.assertGreater(result.samples[5_417].auger_fraction, 0.0) + + def test_setpoint_modes_restart_or_retain_the_controller(self): + results = run_scenarios( + controller_names=["pid_sp"], + scenarios=[SCENARIOS["250"]], + plant=PlantConfig(), + cycle_seconds=15, + setpoint_modes=["production-reset", "continuous"], + ) + by_mode = {result.setpoint_mode: result for result in results} + + self.assertEqual(by_mode["production-reset"].controller_start_seconds, (0, 5_400, 10_800)) + self.assertEqual(by_mode["continuous"].controller_start_seconds, (0,)) + self.assertAlmostEqual(by_mode["production-reset"].samples[5_400].duty_ratio, 0.05) + + def test_invalid_cli_timing_values_are_rejected(self): + for arguments in ( + ["--duration-hours", "3"], + ["--cycle-seconds", "0"], + ["--delay-seconds", "-1"], + ["--ambient-f", "nan"], + ): + with self.subTest(arguments=arguments), self.assertRaises(SystemExit): + main(arguments) + + def test_fractional_auger_window_preserves_requested_duty(self): + result = run_scenarios( + controller_names=["pid"], + scenarios=[SCENARIOS["250"]], + plant=PlantConfig(), + cycle_seconds=15, + setpoint_modes=["production-reset"], + )[0] + first_cycle = result.samples[:15] + self.assertAlmostEqual(sum(sample.auger_fraction for sample in first_cycle), 0.75) + + def test_default_plant_can_sustain_highest_target_below_maximum_duty(self): + plant = PlantConfig() + max_equilibrium_f = ( + plant.ambient_f + + plant.heat_input_per_second * 0.9 / plant.heat_loss_coefficient + ) + highest_target_f = max( + target for scenario in SCENARIOS.values() for _, target in scenario.transitions + ) + + self.assertGreater(max_equilibrium_f, highest_target_f) + + def test_logging_controllers_run_without_site_packages(self): + completed = subprocess.run( + [ + sys.executable, + "-S", + "pid_simulator.py", + "--scenario", + "250", + "--controller", + "pid_clamping", + ], + capture_output=True, + text=True, + check=False, + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + + + if __name__ == "__main__": + unittest.main() + ``` + +- [ ] **Step 2: Run the simulator tests and verify they fail because the CLI module does not exist.** + + Run: + + ```console + python3 -m unittest tests.test_pid_simulator -v + ``` + + Expected: `ModuleNotFoundError: No module named 'pid_simulator'` during test collection. + +- [ ] **Step 3: Implement the CLI, simulator clock, and delayed thermal plant.** + + Create `pid_simulator.py` with these implementation boundaries: + + ```python + @dataclass(frozen=True) + class PlantConfig: + thermal_mass: float = 400.0 + heat_input_per_second: float = 55.0 + heat_loss_coefficient: float = 0.085 + ambient_f: float = 70.0 + firebox_delay_seconds: int = 35 + + + @dataclass(frozen=True) + class Scenario: + name: str + duration_seconds: int + initial_pit_f: float + transitions: tuple[tuple[int, float], ...] + + def setpoint_at(self, second: int) -> float: ... + + + SCENARIOS = { + "250": Scenario("250", 14_400, 200.0, ((0, 250.0), (5_400, 275.0), (10_800, 250.0))), + "350": Scenario("350", 14_400, 300.0, ((0, 350.0), (5_400, 325.0), (10_800, 350.0))), + "450": Scenario("450", 14_400, 400.0, ((0, 450.0), (5_400, 425.0), (10_800, 450.0))), + } + ``` + + Implement a `SimulationClock` exposing `time() -> float`. For every controller instance, temporarily replace only that controller module’s `time` global with the clock object while calling its constructor, `set_target()`, and `update()`. Restore the exact original module object in `finally`. Do not monkeypatch the global `time` module or modify production controller timing. + + In `simulate_controller()`: + + 1. Load production controller modules through a `load_controller_module()` helper. For modules that use `from common import create_logger`, temporarily place a synthetic `common` module in `sys.modules` whose only export is `create_logger`; it must return a disabled standard-library logger with a `NullHandler`. Save and remove any existing canonical target controller module before importing; after capturing the new module object, remove it and restore both the exact prior controller module and exact prior `common` entry in `finally`. This prevents Redis/logging imports and process-wide module-cache pollution while preserving production formulas. + 2. Define `create_controller(target)` to instantiate a new production `Controller` under the simulated clock with defaults from `controller/controllers.json`, units `"F"`, and `{"HoldCycleTime": cycle_seconds, "u_min": 0.05, "u_max": 0.9}`, then call `set_target(target)`. At second zero, create the controller for `scenario.setpoint_at(0)`, initialize `pit_temperature = scenario.initial_pit_f`, `duty_ratio = u_min`, `cycle_start_second = 0`, `next_controller_update = cycle_seconds`, `controller_start_seconds = [0]`, and an empty `controller_update_seconds` list. + 3. At each target transition after second zero, branch on `setpoint_mode`. For `production-reset`, discard the old controller, call `create_controller(new_target)`, append the transition second to `controller_start_seconds`, reset `duty_ratio = u_min`, and set `cycle_start_second = second`. For `continuous`, call `controller.set_target(new_target)` under the simulated clock, retaining the live object, current duty ratio, and auger-cycle anchor. In both modes set `next_controller_update = second + cycle_seconds`. + 4. When `second >= next_controller_update`, call `update(pit_temperature)`, append `second` to `controller_update_seconds`, apply PiFire’s Hold clamp, and set both `cycle_start_second = second` and `next_controller_update = second + cycle_seconds`. + 5. Preserve fractional on-times. Set `cycle_phase = (second - cycle_start_second) % cycle_seconds` so continuous mode keeps cycling its prior duty between a target change and the deferred update. For each one-second interval `[cycle_phase, cycle_phase + 1)`, compute `auger_fraction` as its overlap with `[0, duty_ratio * cycle_seconds)`, yielding a value from 0 through 1. Store `auger_fraction` on each sample and derive `auger_on = auger_fraction > 0` only for display/CSV compatibility. + 6. Push `auger_fraction` through a delay line initialized with `firebox_delay_seconds` zeroes. For a positive delay, pop the oldest fraction before appending the current fraction; for zero delay, use the current fraction directly. Apply `heat_input_per_second * delayed_auger_fraction`. + 7. Advance the pit by one Euler step using the specified energy balance, append a `Sample` carrying `setpoint_mode`, and keep all sample fields finite. Return `controller_update_seconds` and `controller_start_seconds` on `SimulationResult`. + + Calculate segment metrics using transition boundaries. Compute IAE as the sum of `abs(pit_temp_f - setpoint_f) / 60`; compute `percent_within_five_f` from samples inside ±5°F; compute directional overshoot as `max(0.0, max(pit_temp_f - setpoint_f))` for upward steps and `max(0.0, max(setpoint_f - pit_temp_f))` for downward steps; compute `mean_duty_ratio` from actual per-second `auger_fraction`; and find the first second whose following 600 samples remain inside ±5°F without crossing that segment's end. Use `None` for an unsettled segment rather than inventing a settling time. Set overall maximum overshoot to the maximum corrected segment overshoot. Sort result rows by IAE within each scenario and mode. + + Implement `argparse` for `--scenario`, repeatable `--controller`, repeatable `--setpoint-mode`, `--ambient-f`, `--duration-hours`, `--cycle-seconds`, `--delay-seconds`, and `--csv`. Default modes are `production-reset` and `continuous`; reject any other mode. `--ambient-f` must be finite; `--delay-seconds` must be a non-negative integer; `--cycle-seconds` must be a positive integer; and `--duration-hours` must be finite and strictly greater than 3. The options override the corresponding default plant/scenario values while retaining the 90/180-minute transitions. Reject unknown controllers/scenarios and every invalid numeric value with `parser.error`. `format_summary()` must print a heading, a mode column in its compact metrics table, then an indented `Segment` line per target segment. `main()` returns `0` on success. + +- [ ] **Step 4: Run simulator contract tests and verify they pass.** + + Run: + + ```console + python3 -m unittest tests.test_pid_simulator -v + ``` + + Expected: all ten tests pass. The complete default comparison may take only seconds because it advances simulated time rather than sleeping. + +- [ ] **Step 5: Smoke-test the actual CLI and save the pre-fix PID-SP baseline.** + + Run: + + ```console + python3 pid_simulator.py + python3 pid_simulator.py --scenario all --controller pid_sp > /tmp/pid-sp-before-fix.txt + python3 pid_simulator.py --scenario 350 --controller pid_sp --csv /tmp/pid-sp-350-before-fix.csv + ``` + + Expected: terminal output contains every requested scenario/controller in both modes, compact per-controller metrics, and three segment lines per result. The CSV contains one header plus 28,800 rows for the selected controller/scenario across two modes. Preserve `/tmp/pid-sp-before-fix.txt` through Task 2 for the before/after comparison. + +- [ ] **Step 6: Commit the simulator CLI and tests.** + + ```console + git add .gitignore pid_simulator.py tests/__init__.py tests/test_pid_simulator.py + git commit -m "Add PID controller simulator" + ``` + +### Task 2: Correct PID-SP state, output reduction, and predictor validation + +**Files:** +- Create: `tests/test_pid_sp.py` +- Modify: `controller/pid_sp.py:43-190` +- Modify: `controller/controllers.json:245-340` + +**Interfaces:** +- `controller.pid_sp.Controller.update(current: float) -> float` keeps its existing public signature and returns a finite raw cycle ratio for finite inputs/configuration. +- `Controller` rejects invalid predictor configuration with `ValueError`: non-finite or non-positive `tau`, and non-finite or negative `theta`. +- PID-SP exposes existing diagnostic fields `roc`, `d`, `inter`, and `inter_max`; after every update `abs(inter) <= inter_max` when `ki != 0`. +- Task 1's simulator uses this fixed controller unchanged and produces the after-fix comparison. + +- [ ] **Step 1: Write failing PID-SP regression tests.** + + Create `tests/test_pid_sp.py`: + + ```python + import json + import math + import unittest + from pathlib import Path + from unittest.mock import patch + + from controller.pid_sp import Controller + + + CONFIG = { + "PB": 60.0, + "Ti": 180.0, + "Td": 45.0, + "center_factor": 0.001, + "stable_window": 12.0, + "tau": 115.0, + "theta": 65.0, + } + CYCLE_DATA = {"HoldCycleTime": 15} + + + class PidSmithPredictorTests(unittest.TestCase): + def make_controller(self, config=None): + return Controller(config or CONFIG, "F", CYCLE_DATA) + + def test_first_sample_has_zero_rate_and_derivative(self): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.set_target(225.0) + with patch("controller.pid_sp.time.time", return_value=15.0): + controller.update(225.0) + + self.assertEqual(controller.roc, 0.0) + self.assertEqual(controller.d, 0.0) + self.assertEqual(controller.last, 225.0) + + def test_initial_target_uses_first_real_sample_for_later_reset_logic(self): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.set_target(350.0) + for second, temperature in ((15.0, 300.0), (30.0, 330.0), (45.0, 340.0)): + with patch("controller.pid_sp.time.time", return_value=second): + controller.update(temperature) + + self.assertEqual(controller.start_change_temp, 300.0) + self.assertIsInstance(controller.u, float) + + def test_startup_reduction_scales_newly_calculated_output(self): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.set_target(100.0) + controller.last = 100.0 + controller.last_update = 0.0 + with patch("controller.pid_sp.time.time", return_value=15.0): + output = controller.update(100.0) + + self.assertAlmostEqual(output, (controller.p + controller.i + controller.d) * 0.65) + + def test_invalid_predictor_parameters_are_rejected(self): + for key, value in (("tau", 0.0), ("tau", -1.0), ("tau", math.nan), ("tau", math.inf), + ("theta", -1.0), ("theta", math.nan), ("theta", math.inf)): + with self.subTest(key=key, value=value): + config = dict(CONFIG, **{key: value}) + with self.assertRaises(ValueError): + self.make_controller(config) + + def test_integral_accumulator_is_bounded(self): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.set_target(100.0) + controller.set_gains(CONFIG["PB"], CONFIG["Ti"], CONFIG["Td"]) + controller.last = 99.0 + for second in range(15, 3_015, 15): + with patch("controller.pid_sp.time.time", return_value=float(second)): + controller.update(99.0) + + self.assertLessEqual(abs(controller.inter), controller.inter_max) + + def test_pid_sp_metadata_constrains_predictor_values(self): + metadata = json.loads(Path("controller/controllers.json").read_text())["metadata"]["pid_sp"] + options = {option["option_name"]: option for option in metadata["config"]} + + self.assertEqual(options["tau"]["option_min"], 1) + self.assertEqual(options["theta"]["option_min"], 0) + + + if __name__ == "__main__": + unittest.main() + ``` + +- [ ] **Step 2: Run the regression tests and verify each failure identifies current buggy behavior.** + + Run: + + ```console + python3 -m unittest tests.test_pid_sp -v + ``` + + Expected failures: + + - first sample reports a non-zero rate because `last` starts at `150`; + - an initial target retains the sentinel rather than a real first sample for later reset logic; + - startup reduction returns the unscaled `p + i + d` because it is overwritten; + - invalid `tau`/`theta` values construct successfully; + - `inter` exceeds the unused `inter_max` limit; + - predictor metadata has unconstrained minimums. + +- [ ] **Step 3: Make the narrow PID-SP and metadata corrections.** + + In `controller/pid_sp.py`: + + 1. Import `Optional` from `typing` and set `self.last: Optional[float] = None` during initialization. + 2. Add a private `_validate_predictor_config()` using `math.isfinite()` that raises `ValueError("tau must be a positive finite number")` for invalid `tau` and `ValueError("theta must be a non-negative finite number")` for invalid `theta`. Call it immediately after assigning both values in `__init__`. + 3. Add a private `_update_integral_limit()` that sets `inter_max = abs(center / ki)` when `ki != 0`, otherwise `0.0`. Call it after gains/center are initialized, after `set_target()` recalculates `center`, and after `set_gains()` recalculates gains. + 4. In `update()`, capture `current_time` and `dt` as today. If `last is None`, set `roc = 0.0`, use `predicted_temp = current`, set derivative contribution to `0.0`, and set `start_change_temp = current`; otherwise retain the existing rate/prediction calculation. Always persist `last = current` and `last_update = current_time` before returning. + 5. Clamp the accumulator immediately after `self.inter += predicted_error * dt`: + + ```python + self.inter = max(-self.inter_max, min(self.inter, self.inter_max)) + ``` + + Leave the existing `self.i` clamp in place. + 6. Compute `self.u = self.p + self.i + self.d` before the startup-reduction condition. Replace that condition with: + + ```python + if abs(error) < self.pb and current_time - self.last_set_time < self.cycle_time * 3: + self.u *= 0.65 + ``` + + This applies the documented reduction to the newly calculated output and matches the phrase “within PB”. + 7. Keep all existing public method signatures and normal later-cycle PID-SP equations unchanged. + + In `controller/controllers.json`, set PID-SP `tau.option_min` to `1` and `theta.option_min` to `0`. Do not alter defaults or other controller options. + +- [ ] **Step 4: Run the PID-SP regression tests and simulator tests to verify green.** + + Run: + + ```console + python3 -m unittest tests.test_pid_sp -v + python3 -m unittest tests.test_pid_simulator -v + ``` + + Expected: all PID-SP regressions and all simulator contracts pass with no warnings or errors. + +- [ ] **Step 5: Re-run the same PID-SP simulations and show the effect of the fixes.** + + Run: + + ```console + python3 pid_simulator.py --scenario all --controller pid_sp > /tmp/pid-sp-after-fix.txt + diff -u /tmp/pid-sp-before-fix.txt /tmp/pid-sp-after-fix.txt + python3 pid_simulator.py --scenario all + ``` + + Expected: the diff reports PID-SP metric changes caused by the corrected initial sample, integral state, and startup reduction, separately for `production-reset` and `continuous`. The complete comparison still prints finite metrics for every controller, scenario, and mode. Report the observed before/after metrics and the reset-vs-continuous comparison in the final delivery rather than committing temporary baseline files. + +- [ ] **Step 6: Commit the PID-SP corrections and regressions.** + + ```console + git add controller/pid_sp.py controller/controllers.json tests/test_pid_sp.py + git commit -m "Fix PID Smith Predictor state handling" + ``` + +### Task 3: Final verification and maintainability review + +**Files:** +- Modify only if verification reveals a defect in: `pid_simulator.py`, `controller/pid_sp.py`, `tests/test_pid_simulator.py`, or `tests/test_pid_sp.py` + +**Interfaces:** +- Verifies the public CLI invocation and the unchanged production `Controller.update(current)` contract. + +- [ ] **Step 1: Run the complete new regression suite.** + + Run: + + ```console + python3 -m unittest discover -s tests -p 'test_*.py' -v + ``` + + Expected: every simulator and PID-SP regression passes. If an existing tracked test is discovered, it must also pass; do not narrow the command to hide it. + +- [ ] **Step 2: Run end-to-end CLI smoke scenarios.** + + Run: + + ```console + python3 pid_simulator.py --scenario 250 --controller pid --setpoint-mode production-reset + python3 pid_simulator.py --scenario 350 --controller pid_sp --setpoint-mode continuous --ambient-f 85 --delay-seconds 45 + python3 pid_simulator.py --scenario 450 --controller pid_parallel --cycle-seconds 20 --csv /tmp/pid-parallel-450.csv + ``` + + Expected: each command exits zero and prints segment metrics. The first two commands each show only the selected mode; the CSV command includes both modes with the documented header and one record per simulated second per mode. + +- [ ] **Step 3: Inspect only the changed files for scope and source hygiene.** + + Confirm the simulator imports no non-standard packages, production code contains no simulator-specific branches, temporary `/tmp` artifacts are not tracked, and `pid_sp.py` retains no hard-coded initial temperature sentinel. + +- [ ] **Step 4: Commit any verification-only correction if one was necessary.** + + If and only if Steps 1–3 required a production correction, commit it separately with an imperative message describing that correction. Otherwise make no additional commit. diff --git a/docs/superpowers/plans/2026-07-25-high-temperature-transition-tuning.md b/docs/superpowers/plans/2026-07-25-high-temperature-transition-tuning.md new file mode 100644 index 000000000..15b2fc678 --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-high-temperature-transition-tuning.md @@ -0,0 +1,698 @@ +# PID-SP High-Temperature Transition Tuning Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Prevent output-limited upward transitions from accumulating approach integral, recovering the original 450°F overshoot and settling envelope while preserving nominal and 600°F behavior. + +**Architecture:** Extend PID-SP's existing `new_target` bookkeeping with two runtime-only fields. An output-limited upward transition retains integral damping until it enters the canonical 3°F capture band, crosses the target, or produces three consecutive selected-temperature rates at or below `capture_band / (2 × Ti)`; no Smith, identifier, persistence, simulator, or production-control interface changes. + +**Tech Stack:** Python 3.14 standard library, `unittest`, existing PID-SP/controller interface, deterministic simulator. + +## Global Constraints + +- Follow `docs/superpowers/specs/2026-07-25-high-temperature-transition-tuning-design.md` exactly. +- Modify only `controller/pid_sp.py`, `tests/test_pid_sp.py`, and `tests/test_pid_simulator.py`; `/tmp` comparison artifacts are runtime-only. +- Do not add a setpoint threshold, plant/profile constant, user-facing option, nominal process model, or rate-extrapolation predictor. +- P, I, D, transition error, and approach rate use the same selected Smith-or-measured controller temperature. +- The approach rate is the raw selected-temperature rate before any derivative-term suppression. +- Fahrenheit capture is exactly `3.0°F`; Celsius capture is exactly `3.0 * 5.0 / 9.0°C`. +- Slow-rate release is exactly `capture_band / (2.0 * Ti)`, requires three consecutive qualifying updates, and performs no division when `Ti <= 0`. +- Existing `u_min`/`u_max`, applied-duty, Smith trust/fallback, persistence, lid/manual/fan, and generic-controller behavior remains unchanged. +- Use TDD and commit each task atomically. Run focused commands only within Task 1; run the complete suite once in Task 2. + +--- + +### Task 1: Implement rate-gated transition integral release + +**Files:** +- Modify: `controller/pid_sp.py:51-235` +- Modify: `tests/test_pid_sp.py:10-108` +- Modify: `tests/test_pid_simulator.py:232-247,547-568` +- Runtime artifact: `/tmp/pid-sp-transition-current.txt` + +**Interfaces:** +- Consumes: existing `Controller.update(current) -> float`, `Controller.set_target(set_point)`, configured `Ti`, `Controller.cycle_data`, `Controller.previous_controller_input`, `simulate_controller(...)`, and immutable `SimulationResult` metrics. +- Produces: stored `Controller.ti: float`, runtime-only `Controller.output_limited_approach: bool`, `Controller.slow_approach_samples: int`, `_target_capture_band() -> float`, `_reset_approach_state() -> None`, and the unchanged public controller API. + +- [ ] **Step 1: Capture the current deterministic comparison before editing** + +Run: + +```bash +python3 pid_simulator.py --plant medium --scenario all --controller pid_sp \ + > /tmp/pid-sp-transition-current.txt +``` + +Expected: exit `0`; eight PID-SP summary rows are present, including both 450°F modes and both 600°F modes. Preserve this file unchanged. + +- [ ] **Step 2: Add focused unit-test setup for the transition state** + +In `tests/test_pid_sp.py`, extend `CYCLE_DATA` so unit tests exercise the production clamp and add a deterministic update helper: + +```python +CYCLE_DATA = { + "HoldCycleTime": 15, + "u_min": 0.05, + "u_max": 0.90, +} + + +def update_at(controller, second, temperature): + with patch("controller.pid_sp.time.time", return_value=float(second)): + return controller.update(float(temperature)) +``` + +Use native controller attributes in assertions; do not inspect source text. + +- [ ] **Step 3: Replace the old saturation assertion and add stalled/noisy approach tests** + +Replace `test_halfway_transition_resets_integral_and_completes_when_saturated` with the first test below, then add the other two tests to `PidSmithPredictorTests`: + +```python +def test_output_limited_approach_keeps_integral_damped_while_rising(self): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.set_target(600.0) + + for second, temperature in ((15, 550), (30, 575), (45, 575)): + update_at(controller, second, temperature) + + self.assertTrue(controller.output_limited_approach) + self.assertTrue(controller.new_target) + self.assertLessEqual( + abs(controller.inter), + abs(controller.error * controller.cycle_time), + ) + + update_at(controller, 60, 577.0) + self.assertEqual(controller.slow_approach_samples, 0) + self.assertTrue(controller.new_target) + + +def test_output_limited_approach_releases_after_three_slow_samples(self): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.set_target(600.0) + + for second, temperature in ( + (15, 550), + (30, 575), + (45, 575), + (60, 575), + (75, 575), + ): + update_at(controller, second, temperature) + + self.assertFalse(controller.output_limited_approach) + self.assertFalse(controller.new_target) + self.assertEqual(controller.slow_approach_samples, 0) + + +def test_fast_sample_breaks_slow_approach_confirmation(self): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.set_target(600.0) + + for second, temperature in ( + (15, 550), + (30, 575), + (45, 575), + (60, 575), + ): + update_at(controller, second, temperature) + self.assertGreater(controller.slow_approach_samples, 0) + + update_at(controller, 75, 575.18) + self.assertEqual(controller.slow_approach_samples, 0) + self.assertTrue(controller.new_target) +``` + +The first update that enters `output_limited_approach` participates in slow-sample confirmation, matching the design's Step 6 → Step 7 order. + +Extend the existing derivative-reduced counterexample with: + +```python +self.assertFalse(controller.output_limited_approach) +self.assertEqual(controller.slow_approach_samples, 0) +``` + +- [ ] **Step 4: Add failing unit tests for capture, reset, units, and disabled integral** + +Add: + +```python +def test_capture_band_and_target_crossing_clear_approach_state(self): + for temperature in (598.0, 604.0): + with self.subTest(temperature=temperature): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.set_target(600.0) + controller.output_limited_approach = True + controller.slow_approach_samples = 2 + controller.previous_controller_input = 590.0 + controller.last_update = 0.0 + + update_at(controller, 15, temperature) + + self.assertFalse(controller.new_target) + self.assertFalse(controller.output_limited_approach) + self.assertEqual(controller.slow_approach_samples, 0) + + +def test_set_target_clears_approach_state(self): + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller() + controller.output_limited_approach = True + controller.slow_approach_samples = 2 + + with patch("controller.pid_sp.time.time", return_value=15.0): + controller.set_target(350.0) + + self.assertFalse(controller.output_limited_approach) + self.assertEqual(controller.slow_approach_samples, 0) + + +def test_capture_and_slow_rate_thresholds_scale_between_units(self): + fahrenheit = self.make_controller() + celsius = Controller(CONFIG, "C", CYCLE_DATA) + + self.assertAlmostEqual(fahrenheit._target_capture_band(), 3.0) + self.assertAlmostEqual(celsius._target_capture_band(), 3.0 * 5.0 / 9.0) + self.assertAlmostEqual( + celsius._target_capture_band() / celsius.ti, + (fahrenheit._target_capture_band() / fahrenheit.ti) * 5.0 / 9.0, + ) + + +def test_zero_ti_completes_output_limited_halfway_without_division(self): + config = dict(CONFIG) + config["Ti"] = 0.0 + with patch("controller.pid_sp.time.time", return_value=0.0): + controller = self.make_controller(config) + controller.set_target(600.0) + + for second, temperature in ((15, 550), (30, 575), (45, 575)): + output = update_at(controller, second, temperature) + + self.assertTrue(math.isfinite(output)) + self.assertFalse(controller.output_limited_approach) + self.assertFalse(controller.new_target) + self.assertEqual(controller.i, 0.0) +``` + +Add `import math` to `tests/test_pid_sp.py`. + +The `604.0°F` subcase is outside the `±3.0°F` capture band and therefore exercises the upward-crossing disjunct rather than ordinary capture-band clearance. + +- [ ] **Step 5: Add the failing end-to-end 450°F acceptance test** + +In `tests/test_pid_simulator.py`, add: + +```python +def test_pid_sp_recovers_450_transition_without_losing_aggregate_gain(self): + expected = { + "production-reset": { + "iae_max": 1078.7, + "within_min": 79.4, + }, + "continuous": { + "iae_max": 1075.6, + "within_min": 79.5, + }, + } + for mode, limits in expected.items(): + with self.subTest(mode=mode): + result = simulate_controller( + "pid_sp", + SCENARIOS["450"], + PLANT_PROFILES["medium"], + 15, + mode, + ) + first = result.segment_metrics[0] + settling = first.settling_time_minutes + self.assertIsNotNone(settling) + assert settling is not None + self.assertLessEqual(first.max_overshoot, 2.2) + self.assertLessEqual(settling, 25.6) + self.assertLessEqual(result.integrated_absolute_error, limits["iae_max"]) + self.assertGreaterEqual(result.percent_within_five_f, limits["within_min"]) + self.assertLessEqual(abs(result.mean_duty_ratio - 0.595), 0.005) +``` + +Also add an aggregate 250/350 baseline-envelope regression: + +```python +def test_pid_sp_nominal_aggregate_gains_remain_above_pre_smith_baseline(self): + limits = { + ("250", "production-reset"): (831.7, 83.1), + ("250", "continuous"): (834.8, 83.0), + ("350", "production-reset"): (877.6, 82.5), + ("350", "continuous"): (878.7, 82.5), + } + for (scenario_name, mode), (iae_max, within_min) in limits.items(): + with self.subTest(scenario=scenario_name, mode=mode): + result = simulate_controller( + "pid_sp", + SCENARIOS[scenario_name], + PLANT_PROFILES["medium"], + 15, + mode, + ) + self.assertLessEqual(result.integrated_absolute_error, iae_max) + self.assertGreaterEqual(result.percent_within_five_f, within_min) +``` + +- [ ] **Step 6: Run the new tests and verify the expected RED state** + +Run: + +```bash +python3 -m unittest \ + tests.test_pid_sp.PidSmithPredictorTests.test_output_limited_approach_keeps_integral_damped_while_rising \ + tests.test_pid_sp.PidSmithPredictorTests.test_output_limited_approach_releases_after_three_slow_samples \ + tests.test_pid_sp.PidSmithPredictorTests.test_fast_sample_breaks_slow_approach_confirmation \ + tests.test_pid_sp.PidSmithPredictorTests.test_capture_band_and_target_crossing_clear_approach_state \ + tests.test_pid_sp.PidSmithPredictorTests.test_set_target_clears_approach_state \ + tests.test_pid_sp.PidSmithPredictorTests.test_capture_and_slow_rate_thresholds_scale_between_units \ + tests.test_pid_sp.PidSmithPredictorTests.test_zero_ti_completes_output_limited_halfway_without_division \ + tests.test_pid_simulator.PidSimulatorTests.test_pid_sp_recovers_450_transition_without_losing_aggregate_gain \ + tests.test_pid_simulator.PidSimulatorTests.test_pid_sp_nominal_aggregate_gains_remain_above_pre_smith_baseline -v +``` + +Expected: unit tests fail because the new state/helper members do not exist; the 450°F test fails at `5.4°F > 2.2°F` and `30.0 min > 25.6 min`. Record the exact failure summary. + +- [ ] **Step 7: Add bounded transition state and helper methods** + +In `Controller.__init__`, initialize: + +```python +self.output_limited_approach = False +self.slow_approach_samples = 0 +``` + +Store the configured integration time in `_calculate_gains()` so constructor and `set_gains()` changes use the same threshold: + +```python +def _calculate_gains(self, pb, ti, td): + self.ti = float(ti) + if pb == 0: + self.kp = 0 + else: + self.kp = -1 / pb + if self.ti <= 0.0: + self.ki = 0.0 + else: + self.ki = self.kp / self.ti + self.kd = self.kp * td +``` + +When `set_gains()` makes `Ti <= 0.0`, clear `inter` and `i` along with disabling `ki`, so a live gain change cannot retain an integral contribution. + +Add these private methods before `update()`: + +```python +def _target_capture_band(self): + if self.units == "F": + return 3.0 + return 3.0 * 5.0 / 9.0 + + +def _reset_approach_state(self): + self.output_limited_approach = False + self.slow_approach_samples = 0 +``` + +Call `_reset_approach_state()` from `set_target()` immediately after resetting `inter` and `derv`. + +- [ ] **Step 8: Implement the exact update-state ordering** + +In `update()`, compute the raw selected rate once, before transition/PID decisions: + +```python +previous_controller_input = self.previous_controller_input +if previous_controller_input is None: + selected_rate = 0.0 +else: + selected_rate = (controller_input - previous_controller_input) / dt + +if first_selected_sample and self.new_target: + self.start_change_temp = controller_input + +capture_band = self._target_capture_band() +upward_transition = self.set_point > self.start_change_temp +captured_target = abs(error) <= capture_band or ( + upward_transition and error >= 0.0 +) +if self.new_target and captured_target: + self.new_target = False + self._reset_approach_state() +``` + +Reuse `selected_rate` for `self.derv` instead of recalculating it. Preserve the existing first-sample derivative zero rule. + +On a live target change, seed `start_change_temp` from `previous_controller_input` when one exists; use the measured `last` value only before any selected controller input exists. + +After complete candidate output and startup scaling are calculated, keep activation in that candidate-output branch, then process an already-active approach after every outer output-selection branch: + +```python +# Inside the candidate-output branch: +u_max = self.cycle_data.get("u_max", 1.0) +if reached_halfway and upward_transition and self.u >= u_max: + if self.ti <= 0.0: + self.new_target = False + self._reset_approach_state() + else: + self.output_limited_approach = True + +# After all outer output-selection branches: +if self.output_limited_approach and self.new_target: + if self.ti <= 0.0: + self.new_target = False + self._reset_approach_state() + else: + rate_threshold = capture_band / (2.0 * self.ti) + if selected_rate <= rate_threshold: + self.slow_approach_samples += 1 + else: + self.slow_approach_samples = 0 + if self.slow_approach_samples >= 3: + self.new_target = False + self._reset_approach_state() +``` + +Do not clamp `self.u` in PID-SP; production/simulator external clamping and applied-duty feedback remain unchanged. + +- [ ] **Step 9: Run focused unit tests and correct only state-order defects** + +Run: + +```bash +python3 -m unittest tests.test_pid_sp -v +``` + +Expected: all PID-SP tests pass. If a test disagrees about whether the activation update counts, preserve the design order: the activation update counts as the first confirmation sample. + +- [ ] **Step 10: Run simulator acceptance and all adaptive focused tests** + +Run: + +```bash +python3 -m unittest \ + tests.test_pid_simulator.PidSimulatorTests.test_pid_sp_recovers_450_transition_without_losing_aggregate_gain \ + tests.test_pid_simulator.PidSimulatorTests.test_pid_sp_nominal_aggregate_gains_remain_above_pre_smith_baseline \ + tests.test_pid_simulator.PidSimulatorTests.test_every_named_plant_sustains_600_below_maximum_duty \ + tests.test_pid_simulator.PidSimulatorTests.test_pid_sp_identifies_every_named_plant_from_closed_loop_data -v + +python3 -m unittest \ + tests.test_smith_predictor tests.test_pid_sp \ + tests.test_adaptive_controller_state tests.test_pid_simulator -v +``` + +Expected: all commands exit `0`; 450°F satisfies both first-segment limits; existing 600°F and identification tolerances remain unchanged. Do not relax thresholds or modify simulator physics. + +- [ ] **Step 11: Run source diagnostics and Python 3.14 compilation** + +Run `xd://lsp` diagnostics for: + +```text +controller/pid_sp.py +tests/test_pid_sp.py +tests/test_pid_simulator.py +``` + +Fix real new diagnostics. The worktree-root artifact may resolve PID-SP tests against the parent checkout; distinguish that from changed-line errors. + +Run: + +```bash +uv run --python 3.14 python -m py_compile \ + controller/pid_sp.py tests/test_pid_sp.py tests/test_pid_simulator.py +``` + +Expected: exit `0` with no output. + +- [ ] **Step 12: Review and commit the controller change** + +Review against every Task 1 design invariant. Request independent spec and quality review; fix every Critical/Important source-grounded finding and rerun Steps 9–11. + +Commit: + +```bash +git add controller/pid_sp.py tests/test_pid_sp.py tests/test_pid_simulator.py +git commit -m "Gate PID-SP integral during hot approaches" +``` + +--- + +### Task 2: Prove complete performance and integration behavior + +**Files:** +- Review: all implementation files from Task 1 +- Runtime artifacts: `/tmp/pid-sp-transition-current.txt`, `/tmp/pid-sp-transition-tuned.txt`, `/tmp/pid-sp-transition-comparison.txt`, `/tmp/pid-sp-transition-large.csv` +- Modify only if final verification identifies a source defect required by the approved design. + +**Interfaces:** +- Consumes: Task 1 controller state machine and simulator acceptance tests. +- Produces: complete deterministic comparison, full regression evidence, Python 3.14 compatibility evidence, and final independent review verdict. + +- [ ] **Step 1: Generate the tuned medium-plant report** + +Run: + +```bash +python3 pid_simulator.py --plant medium --scenario all --controller pid_sp \ + > /tmp/pid-sp-transition-tuned.txt +``` + +Expected: exit `0`; eight PID-SP rows are present. + +- [ ] **Step 2: Generate a strict current-versus-tuned comparison** + +Create `/tmp/pid-sp-transition-comparison.py` with this complete standard-library parser: + +```python +import math +import re +from pathlib import Path + + +SUMMARY = re.compile( + r"^\s*(250|350|450|600)\s+" + r"(production-reset|continuous)\s+pid_sp\s+" + r"([0-9.]+)\s+([0-9.]+)%\s+([0-9.]+)\s+([0-9.]+)$" +) +SEGMENT = re.compile( + r"^\s*Segment (\d+): target=([0-9.]+)F, " + r"IAE=([0-9.]+), within5=([0-9.]+)%, " + r"overshoot=([0-9.]+)F, settling=([0-9.]+|n/a) min, " + r"duty=([0-9.]+)$" +) +EXPECTED_KEYS = { + (scenario, mode) + for scenario in ("250", "350", "450", "600") + for mode in ("production-reset", "continuous") +} +EXPECTED_SEGMENTS = {"250": 3, "350": 3, "450": 3, "600": 1} + + +def parse(path): + rows = {} + current_key = None + for line in Path(path).read_text().splitlines(): + summary = SUMMARY.match(line) + if summary: + scenario, mode = summary.group(1, 2) + current_key = (scenario, mode) + if current_key in rows: + raise AssertionError(f"duplicate summary: {current_key}") + values = tuple(float(value) for value in summary.groups()[2:]) + if not all(math.isfinite(value) for value in values): + raise AssertionError(f"non-finite summary: {current_key}") + rows[current_key] = {"summary": values, "segments": []} + continue + segment = SEGMENT.match(line) + if segment: + if current_key is None: + raise AssertionError("segment before summary") + number = int(segment.group(1)) + target, iae, within, overshoot = ( + float(value) for value in segment.group(2, 3, 4, 5) + ) + settling_text = segment.group(6) + settling = ( + None if settling_text == "n/a" else float(settling_text) + ) + duty = float(segment.group(7)) + finite = (target, iae, within, overshoot, duty) + if not all(math.isfinite(value) for value in finite): + raise AssertionError(f"non-finite segment: {current_key}/{number}") + if settling is None or not math.isfinite(settling): + raise AssertionError(f"missing/non-finite settling: {current_key}/{number}") + if not 0.05 <= duty <= 0.90: + raise AssertionError(f"unbounded duty: {current_key}/{number}") + rows[current_key]["segments"].append( + (number, target, iae, within, overshoot, settling, duty) + ) + if set(rows) != EXPECTED_KEYS: + raise AssertionError( + f"inventory mismatch: missing={EXPECTED_KEYS - set(rows)}, " + f"extra={set(rows) - EXPECTED_KEYS}" + ) + for (scenario, mode), row in rows.items(): + expected = EXPECTED_SEGMENTS[scenario] + if len(row["segments"]) != expected: + raise AssertionError( + f"segment count {scenario}/{mode}: " + f"{len(row['segments'])} != {expected}" + ) + numbers = [segment[0] for segment in row["segments"]] + if numbers != list(range(1, expected + 1)): + raise AssertionError(f"segment order {scenario}/{mode}: {numbers}") + return rows + + +current = parse("/tmp/pid-sp-transition-current.txt") +tuned = parse("/tmp/pid-sp-transition-tuned.txt") +for key in EXPECTED_KEYS: + current_shape = [ + (segment[0], segment[1]) for segment in current[key]["segments"] + ] + tuned_shape = [ + (segment[0], segment[1]) for segment in tuned[key]["segments"] + ] + if current_shape != tuned_shape: + raise AssertionError( + f"segment target/order mismatch: {key}: " + f"{current_shape} != {tuned_shape}" + ) + +nominal_limits = { + ("250", "production-reset"): (831.7, 83.1), + ("250", "continuous"): (834.8, 83.0), + ("350", "production-reset"): (877.6, 82.5), + ("350", "continuous"): (878.7, 82.5), + ("450", "production-reset"): (1078.7, 79.4), + ("450", "continuous"): (1075.6, 79.5), +} +for key, (iae_max, within_min) in nominal_limits.items(): + iae, within, _overshoot, duty = tuned[key]["summary"] + assert iae <= iae_max, (key, iae, iae_max) + assert within >= within_min, (key, within, within_min) + if key[0] == "450": + assert abs(duty - 0.595) <= 0.005, (key, duty) + first = tuned[key]["segments"][0] + assert first[4] <= 2.2, (key, "overshoot", first[4]) + assert first[5] is not None and first[5] <= 25.6, ( + key, + "settling", + first[5], + ) + +for key in sorted(EXPECTED_KEYS): + before_summary = current[key]["summary"] + after_summary = tuned[key]["summary"] + summary_delta = tuple( + after - before for before, after in zip(before_summary, after_summary) + ) + print( + f"{key[0]} {key[1]} summary " + f"current={before_summary} tuned={after_summary} delta={summary_delta}" + ) + before_segments = current[key]["segments"] + after_segments = tuned[key]["segments"] + for before, after in zip(before_segments, after_segments): + numeric_before = tuple( + math.nan if value is None else float(value) for value in before[2:] + ) + numeric_after = tuple( + math.nan if value is None else float(value) for value in after[2:] + ) + deltas = tuple( + new - old + if math.isfinite(old) and math.isfinite(new) + else math.nan + for old, new in zip(numeric_before, numeric_after) + ) + print( + f" segment {after[0]} target={after[1]:.0f} " + f"current={numeric_before} tuned={numeric_after} delta={deltas}" + ) +``` + +This parses all rows before emitting results and preserves every favorable and unfavorable delta. + +Run: + +```bash +python3 /tmp/pid-sp-transition-comparison.py \ + > /tmp/pid-sp-transition-comparison.txt +``` + +Expected: exit `0`; all eight rows and every segment appear. Preserve unfavorable deltas. + +- [ ] **Step 3: Run the complete permanent suite** + +Run: + +```bash +python3 -m unittest discover -s tests -p 'test_*.py' -v +``` + +Expected: all tests pass with zero failures/errors. + +- [ ] **Step 4: Run Python 3.14 compilation** + +Run: + +```bash +uv run --python 3.14 python -m py_compile \ + control.py pid_simulator.py \ + controller/pid_sp.py controller/smith_predictor.py controller/runtime.py \ + common/adaptive_controller_state.py \ + tests/test_pid_sp.py tests/test_smith_predictor.py \ + tests/test_adaptive_controller_state.py tests/test_pid_simulator.py +``` + +Expected: exit `0` with no output. + +- [ ] **Step 5: Run production-shape CLI smokes and validate CSV cardinality** + +Run: + +```bash +python3 pid_simulator.py --plant medium --scenario 450 --controller pid_sp --setpoint-mode production-reset +python3 pid_simulator.py --plant medium --scenario 450 --controller pid_sp --setpoint-mode continuous +python3 pid_simulator.py --plant small --scenario 600 --controller pid_sp --setpoint-mode continuous +python3 pid_simulator.py --plant large --scenario 600 --controller pid_sp --setpoint-mode continuous --csv /tmp/pid-sp-transition-large.csv +``` + +Expected: all commands exit `0` with finite metrics. The CSV has one 17-column header and exactly 14,400 rows for contiguous seconds `0..14399`; numeric diagnostics are finite or empty and JSON serializes with `allow_nan=False`. + +- [ ] **Step 6: Run final LSP diagnostics and contract audit** + +Run `xd://lsp` diagnostics on every changed Python file. Expected: no new errors. + +Map every specification requirement to a passing test or smoke artifact. Explicitly confirm: + +- no 450°F/setpoint/profile constant was added to production code; +- no nominal model or rate forecast was introduced; +- P/I/D and the gate share selected controller input; +- target/reset/Celsius/`Ti=0` paths are covered; +- 250/350/600 and identification gates pass; +- output remains externally clamped and applied-duty feedback is unchanged. + +- [ ] **Step 7: Request whole-change review and address findings** + +Request independent review from the Task 1 base through final HEAD. Require separate Spec, Quality, Production safety, and Numerical state verdicts. Fix every Critical/Important source-grounded issue, then repeat Steps 1–6. + +- [ ] **Step 8: Commit final corrections only if review changed source** + +If review required changes: + +```bash +git add controller/pid_sp.py tests/test_pid_sp.py tests/test_pid_simulator.py +git commit -m "Harden PID-SP transition integral gating" +``` + +If review is clean, create no empty commit. Preserve all `/tmp` artifacts for factual reporting. diff --git a/docs/superpowers/specs/2026-07-24-adaptive-smith-predictor-design.md b/docs/superpowers/specs/2026-07-24-adaptive-smith-predictor-design.md new file mode 100644 index 000000000..e0d97d448 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-adaptive-smith-predictor-design.md @@ -0,0 +1,295 @@ +# Adaptive Smith Predictor Design + +## Goal + +Replace PID-SP's rate-of-change temperature extrapolator with a real Smith predictor for a first-order-plus-dead-time (FOPDT) pellet-grill plant. The predictor will use the actual clamped auger command, identify process gain, time constant, and dead time passively from closed-loop operation, retain trusted physical parameters across restarts and Hold setpoint changes, and fall back to measured-temperature PID whenever prediction is not trustworthy. + +The deterministic simulator will validate identification and control against small, medium, and large grill models whose exact physical parameters are known. + +## Scope + +This change covers: + +- a standard-library adaptive FOPDT identifier and Smith predictor; +- PID-SP integration using one predicted temperature consistently for P, I, and D; +- feedback of the applied, externally clamped auger ratio; +- live Hold-to-Hold setpoint updates without recreating PID-SP; +- durable storage of trusted physical model parameters; +- simulator plant profiles, a 600°F scenario, estimator diagnostics, and before/after metrics; +- deterministic unit, integration, persistence, and simulation tests. + +It does not add deliberate plant excitation, identify nonlinear or multi-zone models, model fan/combustion physics, persist delayed command history, or change other PID implementations' equations. + +## Process Model and Smith Equation + +Write the FOPDT plant as a constant offset plus a delayed heat-response state: + +```text +T(t) = T_offset + x_d(t) +dx/dt = (K × u - x) / tau +x_d(t) = x(t - theta) +``` + +where: + +- `u` is actual applied auger duty in `[0, 1]`; +- `K` is steady-state temperature gain per unit duty; +- `tau` is the first-order time constant in seconds; +- `theta` is firebox dead time in seconds; +- `T_offset` captures ambient and other constant heat balance terms. + +The Smith predictor maintains two heat-response states: + +- an undelayed state, `x_hat_0`, driven by current applied duty; +- a delayed state, `x_hat_d`, driven by the same duty history shifted by `theta`. + +The controller input is: + +```text +T_smith = T_measured + x_hat_0 - x_hat_d +``` + +This is the measured-output Smith formulation. The measured term preserves feedback for plant/model mismatch and disturbances, while the state difference removes identified delay from the feedback signal. The unknown constant offset cancels, so it is estimated for identification but is not required in the persisted predictor model. + +Delayed command changes are applied at their exact due timestamps. A command due between two 15-second controller updates divides model integration into segments; delay is not rounded to the controller interval. + +## Architecture + +### `controller/smith_predictor.py` + +This new standard-library module contains two components. + +#### `AdaptiveFOPDTIdentifier` + +The identifier receives timestamped temperature observations and the applied-duty history. It owns one recursive least-squares estimator for each dead-time candidate from 0 through 120 seconds in 5-second increments. + +For candidate `theta_j`, each accepted observation updates: + +```text +(T_k - T_(k-1)) / dt = beta_0 + beta_T × T_(k-1) + beta_u × delayed_average_duty +``` + +The delayed input is the time-weighted average applied duty over: + +```text +[t_(k-1) - theta_j, t_k - theta_j] +``` + +Recovered physical parameters are: + +```text +tau = -1 / beta_T +K = -beta_u / beta_T +T_offset = -beta_0 / beta_T +``` + +RLS uses a forgetting factor of `0.9995`, fixed-size 3×3 covariance matrices initialized to `1e6`, and an exponentially weighted squared-residual factor of `0.02`. Temperature regressors are centered and scaled before each update, then transformed back to physical coefficients, avoiding poor conditioning from absolute grill temperatures. Work is incremental and bounded: 25 delay candidates and fixed-size matrices, with no growing regression window. + +The identifier exposes a diagnostic snapshot containing sample counts, excitation measures, best and runner-up residuals, covariance-derived uncertainty, candidate estimates, trusted estimates, and trust state. + +#### `SmithPredictor` + +The predictor owns: + +- trusted `K`, `tau`, and `theta`; +- the undelayed and delayed model states; +- the timestamped applied-duty queue needed by the maximum candidate delay and active Smith model; +- finite-state validation and safe reset logic. + +It returns measured temperature until trusted model parameters exist. On initial trust, restoration, or a safety reset, both model branches initialize to equal states so the Smith correction starts at zero. + +The module accepts native Fahrenheit or Celsius observations. Bounds and persisted gain use canonical Fahrenheit internally; the predicted temperature returned to PID-SP uses the controller's configured units. +The predictor and identifier accept an injected clock callable. PID-SP supplies its module-local `time.time`, which production leaves real and the simulator already replaces with its deterministic clock. + +### `controller/pid_sp.py` + +PID-SP will compose the identifier and predictor rather than implement model identification itself. + +- Remove the rate-of-change extrapolator and its `roc`-based predicted temperature. +- `update(current)` advances identification/model state and selects measured or Smith-predicted temperature. +- Proportional error, integral accumulation, and derivative all use that same selected temperature. +- Derivative compares consecutive selected controller-input temperatures; it never subtracts an actual sample from a predicted sample. +- Existing auto-center behavior, integral bounds, output construction, and startup reduction remain, with startup reduction applied to the newly calculated output. +- `set_output(applied_ratio, identification_allowed=True)` records the actual externally clamped command. The command always enters predictor history. Invalid/disturbed intervals pause identification only. +- `set_target(set_point)` resets target-dependent PID terms and timing while preserving learned physical parameters, RLS state, model states, and duty history. +- `get_model_snapshot()` returns a serializable trusted physical model with a monotonically increasing revision when model persistence is supported. +- `restore_model(snapshot)` validates and restores trusted physical parameters. + +PID-SP adds `get_status()`, returning only JSON-serializable diagnostics: prediction-active state, predicted temperature, model states, estimated gain/tau/delay, confidence, residual, and accepted sample count. `control.py` uses this method instead of exposing PID-SP's `__dict__`; internal estimator objects, matrices, and histories never enter the notification payload. Controllers without `get_status()` retain the existing diagnostics path. + +### Production `control.py` + +Immediately after creating a supported controller, production control seeds it with the initial applied `CycleRatio` so the first Hold interval is present in command history. After each normal Hold-mode calculation and all min/max/lid-open clamping, production control calls `set_output(CycleRatio, identification_allowed=...)`. PID-SP therefore models the command that will actually drive auger timing, not its unclamped raw output. + +Identification is disallowed for explicit disturbance intervals, including lid-open handling, manual auger override, and low-output fan-PID modulation where auger duty alone no longer represents heat input. A manual override records duty `1.0` or `0.0` at override start and restores the current clamped `CycleRatio` at override end, both with identification disabled. These commands still advance Smith history. Re-entry starts a fresh observation interval so RLS never computes a temperature slope across excluded time. + +A target-only Hold-to-Hold control update calls `set_target()` on the live controller, clears the update, writes the revised control state, and continues the current work cycle. Controller state is still recreated for mode changes, unit changes, controller selection/configuration changes, and process startup. + +### Durable trusted-model state + +`control.py` owns persistence so controller modules and the simulator remain filesystem/Redis independent. + +The runtime file `adaptive_controller_state.json`, beside `settings.json`, stores a versioned PID-SP record containing: + +- gain in °F per unit duty; +- tau and theta in seconds; +- confidence/residual summary; +- observation count; +- trusted-model revision; +- save timestamp. + +The physical grill model is an explicit invariant: a process restart does not reduce trust in identified `K`, `tau`, or `theta`. Trusted parameters therefore have no age expiration and restore as trusted immediately. + +Persistence behavior: + +- reject malformed, non-finite, out-of-bound, or schema-incompatible records; +- write atomically through a same-directory temporary file and `os.replace`; +- write only after a material trusted-model revision, no more than once per 30 minutes during ordinary operation; +- flush a pending trusted revision when the work cycle exits. + +RLS matrices, dynamic model states, and delayed command history are not persisted. They describe estimator confidence and past time evolution, not fixed grill physics. After restoration, model branches initialize equally for zero correction while a fresh RLS bank passively verifies and refines the restored model. + +## Confidence and Safety + +The predictor remains inactive before a restored or newly trusted model exists. New identification uses the following profile-independent gates: + +| Gate | Threshold | +| --- | ---: | +| Accepted observation time | at least 3600 seconds | +| Accepted observations at a 15-second cycle | at least 240 | +| Applied-duty standard deviation | at least 0.05 | +| Sustained duty transition | change of at least 0.05 held for 60 seconds | +| Observed temperature span | at least 15°F, or Celsius equivalent | +| Process gain | 50–2000°F per unit duty | +| Time constant | 300–20,000 seconds | +| Gain relative standard error | at most 20% | +| Tau relative standard error | at most 25% | +| Winning delay residual | at least 10% below the runner-up | +| Confirmation window | 20 accepted observations | +| Confirmation stability | gain within 5%, tau within 7.5%, unchanged delay candidate | + +A delay candidate is not promoted merely because it has the lowest residual. If candidates are statistically indistinguishable, PID-SP remains measured-temperature PID. + +After initial trust, a candidate is a material revision only if gain or tau changes by at least 5% or delay changes by at least 5 seconds. A passing revision is blended into gain and tau with factor `0.1`; delay changes only after the full confirmation window. Abrupt estimates outside the confirmation limits are rejected. The trusted model remains in use while fresh RLS confidence rebuilds after restart because the physical-model invariant makes restart age irrelevant. + +Prediction disables immediately if model state or predicted temperature becomes non-finite, leaves the broad physical range of -100°F to 1200°F (converted for Celsius), or the one-step temperature prediction residual exceeds 100°F for four consecutive accepted observations. Both model branches then reinitialize equally and control falls back to measured temperature. The last valid physical parameters remain observable; use resumes only after safe model-state initialization. + +All controller outputs continue through existing `[u_min, u_max]` production clamps. The Smith predictor cannot bypass actuator bounds. + +## Simulator Changes + +### Plant profiles + +`pid_simulator.py` adds `--plant {small,medium,large}` and keeps CLI parameter overrides. The profiles are: + +| Profile | Thermal mass | Heat input/s | Loss coefficient | Delay | Exact K | Exact tau | Max equilibrium at 70°F | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| Small | 250 | 48 | 0.075 | 20s | 640°F/duty | 3333.33s | 710°F | +| Medium | 400 | 55 | 0.085 | 35s | 647.06°F/duty | 4705.88s | 717.06°F | +| Large | 650 | 70 | 0.100 | 50s | 700°F/duty | 6500s | 770°F | + +At a 600°F target and 70°F ambient, required steady duties are approximately 0.828, 0.819, and 0.757. Each is reachable below the simulator's existing 0.90 maximum duty with control margin. + +The existing default model becomes the `medium` profile. The existing 250°F, 350°F, and 450°F scenarios remain unchanged for before/after continuity. A deterministic 600°F Hold scenario is added for plant-capability verification. + +### Applied-duty feedback and diagnostics + +The simulator seeds the initial applied duty and calls `set_output()` after the same min/max clamping used in production. PID-SP passes its module-local `time.time` callable into the identifier and predictor, so the simulator's existing controller-module clock replacement controls every timestamp deterministically. + +`SimulationResult`, terminal output, and optional CSV diagnostics add: + +- identifier activation time; +- final estimated gain, tau, and delay; +- final model confidence/residual; +- prediction-active flag and predicted temperature. + +Normal time-series CSV output retains one row per simulated second. Controller/model estimates are sampled without mutating controller state. + +### Identification runs + +Closed-loop recovery runs execute on all three profiles. Duration is profile-aware and long enough to observe several time constants rather than forcing the large grill into the medium grill's four-hour window. The same identifier settings and confidence gates apply to every profile. + +For each profile, the estimator must activate from passive controller behavior and recover: + +- gain within ±10%; +- tau within ±15%; +- delay within ±5 seconds. + +No profile-specific estimator tuning is permitted. + +## Verification + +### Predictor tests + +Deterministic tests will prove: + +- exact undelayed and delayed first-order trajectories; +- segmented integration at delay boundaries between controller samples; +- the measured-output Smith equation; +- equal-state initialization and zero initial correction; +- safe fallback for invalid model state. + +### Identifier tests + +Synthetic FOPDT tests with variable sample intervals will prove: + +- recovery of `K`, `tau`, and `theta` within acceptance tolerances; +- no promotion with constant duty or insufficient temperature span; +- no promotion when delay residuals are ambiguous; +- rejection of non-finite, negative-gain, unstable, and out-of-bound estimates; +- paused intervals do not create a cross-gap slope observation; +- fixed memory and bounded duty-history retention. + +### PID-SP and control integration tests + +Tests will prove: + +- measured temperature is used before model trust; +- one Smith temperature drives P, I, and D after trust; +- startup reduction scales the newly calculated output; +- integral accumulation remains bounded; +- applied clamped duty, not raw output, reaches the predictor; +- target updates retain identification and model state; +- mode/unit/controller/config changes still recreate controller state; +- explicit disturbances update exact command history while pausing identification; +- model persistence flushes atomically on material updates and work-cycle exit. + +### Persistence tests + +Tests will prove: + +- trusted physical parameters round-trip without age expiry; +- corrupted, non-finite, out-of-bound, and incompatible snapshots are ignored; +- restored dynamics begin with zero Smith correction; + +### Simulation acceptance + +The full existing regression suite must pass. Every controller/profile/mode combination must produce finite metrics and bounded commands. + +The medium-profile 250/350/450°F PID-SP report will be captured before implementation and rerun after implementation. Per segment, comparison reports: + +- integrated absolute error; +- percentage within ±5°F; +- directional overshoot; +- settling time; +- mean applied duty; +- identifier activation time and final `K`, `tau`, and `theta`. + +Results will be reported as measured; the implementation will not tune the test to manufacture a performance win. The existing 250°F acceptance remains: no more than 5°F overshoot and entry into the ±5°F band within 20 minutes. Each plant profile must also sustain the 600°F scenario below maximum configured duty. + +## Failure Handling + +- Missing or invalid persisted state logs a warning and starts measured-temperature PID with fresh identification. +- Identifier numerical failure resets only the affected candidate; repeated bank failure resets adaptive state without interrupting PID control. +- Predictor numerical failure disables prediction and reinitializes model dynamics; it does not stop the work cycle. +- Persistence failure logs an error and retains the in-memory trusted model. +- Unsupported controllers ignore the optional applied-output/model-state hooks and retain current behavior. + +## Constraints + +- Runtime estimator/predictor and simulator remain Python-standard-library only. +- Incremental controller work and memory are bounded for Raspberry Pi operation. +- No deliberate excitation is injected into production auger commands. +- No raw controller output may enter the Smith command model after external clamping is available. +- No compatibility shim retains the old rate-of-change predictor. +- Other PID controller equations remain unchanged. diff --git a/docs/superpowers/specs/2026-07-24-pid-controller-simulator-design.md b/docs/superpowers/specs/2026-07-24-pid-controller-simulator-design.md new file mode 100644 index 000000000..8cb4db2d5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-pid-controller-simulator-design.md @@ -0,0 +1,106 @@ +# PID Controller Simulator Design + +## Goal + +Provide a deterministic, offline Python CLI that runs PiFire's production PID controllers against a representative pellet-grill thermal plant and prints comparable cooking metrics. Use it to measure the effect of correcting confirmed PID-SP defects without adding dashboard, GPIO, Redis, or third-party dependencies. + +## Scope + +The CLI will be runnable from the repository root as: + +```console +python3 pid_simulator.py +``` + +It will compare `pid`, `pid_clamping`, `pid_clamping_percent_pb`, `pid_parallel`, `pid_ac`, and `pid_sp` under two setpoint-transition modes: `production-reset`, which reproduces current PiFire behavior, and `continuous`, which calls the live controller's existing `set_target()` method. It will run three four-hour Hold-mode cooking profiles: 250°F, 350°F, and 450°F. Each profile begins from a realistic post-startup pit temperature 50°F below its initial target, then contains two deterministic setpoint changes around its nominal cook temperature. Ambient temperature affects heat loss throughout the simulation but is not the controller-entry temperature. + +The terminal report will include one row per controller, profile, and setpoint-transition mode, plus PID-SP before/after results recorded by implementation verification: + +- integrated absolute error (°F·min) +- percentage of simulated cook time within ±5°F of the active setpoint +- maximum overshoot (°F) +- settling time after each setpoint change (minutes) +- mean auger duty ratio +- per-setpoint-segment values for the same tracking measures + +The simulator is an engineering comparison, not a physical calibration tool. It will use documented default plant parameters and CLI flags to override them. It will not model fuel geometry, weather transients, fan control, combustion chemistry, probe noise, startup/reignite modes, or the web UI. + +## Architecture + +### CLI and scenarios + +`pid_simulator.py` will use only the standard library (`argparse`, `dataclasses`, `statistics`, and `textwrap`). It will expose: + +- `--scenario {250,350,450,all}`; default `all` +- `--controller NAME`; repeatable filter, default all supported PID controllers +- `--setpoint-mode {production-reset,continuous}`; repeatable filter, default both modes +- `--ambient-f`; default 70°F +- `--duration-hours`; default 4, and must be greater than 3 so the final segment contains samples +- `--cycle-seconds`; default 15 +- `--delay-seconds`; default 35 +- `--csv PATH`; optional time-series output for independent plotting + +Built-in scenarios will be stable and explicit: + +| Scenario | Setpoint profile | +| --- | --- | +| 250 | 250°F → 275°F at 90 min → 250°F at 180 min | +| 350 | 350°F → 325°F at 90 min → 350°F at 180 min | +| 450 | 450°F → 425°F at 90 min → 450°F at 180 min | + +The final hour verifies steady-state recovery after the second change. + +### Controller adapter + +The simulator will instantiate the production controller modules through the same `Controller(config, units, cycle_data)` interface used by `control.py`. A local deterministic clock object will temporarily replace the module-local `time` reference for the duration of each simulated controller call. Controller imports that normally initialize PiFire's Redis-backed logger will receive a temporary standard-library no-op `create_logger` shim, keeping the offline CLI free of Redis and third-party dependencies without changing control equations. + +At each controller boundary, the adapter passes the current simulated pit temperature to `Controller.update()`, clamps the returned raw ratio exactly as Hold mode does (`u_min`/`u_max`), and holds the result until the next update. One-second plant samples preserve fractional auger on-time by applying the exact overlap between each sample interval and the floating-point on-window; they do not round duty up to a whole second. + +The model begins where PiFire's PID controllers actually begin: after Startup hands control to Hold. The first PID update occurs after one complete Hold cycle, with the initial minimum auger ratio applied during that interval. In `production-reset` mode, a target change reproduces current PiFire behavior: create a fresh controller, restore minimum duty, anchor a new auger cycle, and wait one full interval. In `continuous` mode, call `set_target()` on the existing controller, retain the current duty and auger phase until that cycle completes, and defer the next update until one full interval after the target change. This second mode exercises each controller's own intended state-reset policy without inventing shared PID-state rules. + +### Thermal plant + +The plant advances in one-second steps. A FIFO delay line turns commanded auger-on time into delayed heat at the firebox. The pit then follows a discrete first-order energy balance: + +```text +dT/dt = (delayed_heat_input - heat_loss_coefficient × (pit_temperature - ambient_temperature)) / thermal_mass +``` + +Default parameters (`thermal_mass=400`, `heat_input_per_second=55`, and `heat_loss_coefficient=0.085`) will produce plausible pellet-grill warm-up, steady-state duty, cooling, and delayed response. With PID-SP controlling the 250°F profile from 200°F, the pit must enter and remain in the ±5°F band within 20 minutes without exceeding 5°F overshoot. At maximum configured duty, the default plant's equilibrium temperature must exceed the highest 450°F target so the comparison does not make that scenario physically unreachable. Parameters will be isolated in one immutable `PlantConfig` dataclass so users can tune a grill model without changing controller code. + +### Metrics + +The simulator will record a sample each second. Every result and CSV row identifies its setpoint-transition mode. Metrics begin after each segment's setpoint transition and use the active target for that segment. A segment is settled when the temperature remains inside ±5°F for a continuous 10-minute window. Unsettled segments report `not settled`. Overshoot is directional: upward steps measure temperature above target, downward steps measure temperature below target, and the pre-transition temperature is not mislabeled as overshoot. Overshoot is floored at zero when the response never crosses the target; mean duty uses actual fractional auger actuation. The report will sort results by integrated absolute error within each scenario and mode and print all controllers rather than silently choosing a winner. + +## PID-SP Corrections + +`controller/pid_sp.py` will receive narrowly scoped changes. + +1. Replace the hard-coded initial `last = 150` sentinel with an uninitialized prior-sample state. On the first `update(current)`, seed prior temperature and the initial setpoint-change temperature from `current`, use zero rate of change and zero derivative, and then save the real sample. Later cycles retain the existing rate-of-change behavior. +2. Compute `p + i + d` before applying the documented 35% startup reduction. Apply that reduction to the newly computed output only while the actual error is within the proportional band and the setpoint-change window is active. +3. Require a positive finite `tau` and a non-negative finite `theta` when constructing/configuring PID-SP. Set corresponding UI metadata minimums to 1 second and 0 seconds. Invalid direct construction will raise `ValueError`; the settings UI prevents ordinary invalid submissions. +4. Turn the existing `inter_max` calculation into a real bound on `self.inter`, recalculated whenever gains or the center value changes. This prevents hidden integral windup while preserving the existing clamp on `self.i`. + +The fixes will not change controller dispatch, auger timing, or any other PID implementation. + +## Testing and Verification + +New deterministic `unittest` coverage will: + +- prove first PID-SP update uses zero rate/derivative from a real first sample rather than a synthetic 150°F sample; +- prove an initial target safely retains its real first-sample baseline through subsequent pre-setpoint updates; +- prove the startup reduction changes the newly calculated output; +- reject zero, negative, NaN, and infinite predictor parameters; +- prove the integral accumulator remains bounded; +- execute all built-in simulation scenarios with every controller, asserting finite metrics and complete output; +- exercise CLI filtering and optional CSV output. + +Before production changes, the simulator will produce a saved baseline for each default scenario. After the fixes, it will run the same scenarios and the implementation report will state PID-SP metric deltas. The checked-in CLI reports the current production behavior; it will not preserve a duplicate buggy PID-SP implementation. + +## Constraints + +- Python standard library only for the simulator and tests. +- The CLI must run from the repository root on supported PiFire Python versions. +- No hardware, Redis, Flask, browser, real-time sleeps, or network access. +- No behavior changes outside PID-SP and its parameter metadata. +- Simulation defaults must be deterministic and documented in source. diff --git a/docs/superpowers/specs/2026-07-25-high-temperature-transition-tuning-design.md b/docs/superpowers/specs/2026-07-25-high-temperature-transition-tuning-design.md new file mode 100644 index 000000000..4bf4cd7a8 --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-high-temperature-transition-tuning-design.md @@ -0,0 +1,123 @@ +# PID-SP High-Temperature Transition Tuning Design + +## Goal + +Recover the original 450°F first-segment overshoot and settling envelope without giving up the adaptive Smith implementation's aggregate accuracy gains, 600°F capability, plant-independent identification, or production safety behavior. + +The tuning must remain setpoint- and plant-independent. It must not add a 450°F threshold, grill-profile constants, an untrusted process model, or a second rate-extrapolation predictor. + +## Observed Cause + +The deterministic medium-plant 450°F simulation peaks at 455.38°F at second 1693. No Smith model is active during that first segment; identification first activates after the segment. The regression therefore is not caused by the Smith correction or identified model. + +During the fresh-model rise, PID-SP leaves transition damping at second 600 and accumulates integral output while the pit still has substantial upward momentum. Integral output peaks at `0.2499`; the medium plant's modeled 450°F steady-state correction is about `0.047`. The excessive stored integral produces the 5.4°F overshoot and longer settling tail. + +## Selected Approach + +Use a rate-gated integral-release state for output-limited upward setpoint transitions. + +This extends PID-SP's existing `new_target` transition bookkeeping. It does not change the Smith predictor, identifier, controller output clamps, persistence schema, or production integration. + +## Controller State + +PID-SP adds two bounded scalar fields: + +- `output_limited_approach`: whether the current upward transition reached its halfway point while the complete candidate output remained at or above `u_max`. +- `slow_approach_samples`: consecutive controller updates whose raw selected-temperature rate toward the target is at or below `r_release`. + +Both fields reset in the constructor, `set_target()`, and controller reinitialization through the existing constructor path. They never persist and never enter the trusted physical-model snapshot. + +## Native-Unit Scaling + +The transition capture band is the existing canonical 3°F band: + +- Fahrenheit: `3.0°F` +- Celsius: `3.0 × 5/9 = 1.666666…°C` + +The slow-approach threshold is half the capture-band rate over the configured integration time: + +\[ +r_{release} = \frac{capture\_band}{2 T_i} +\] + +For the default `Ti=180s`, this is `0.008333…°F/s` or `0.004629…°C/s`, equivalent to 0.5°F/minute. A deterministic parameter sweep found that the unscaled `capture_band / Ti` rule released damping at 442.3°F and produced 3.2601°F overshoot; the half-rate threshold produced 2.1043°F overshoot while preserving every nominal, 600°F, and identification gate. No new user-facing tuning option is introduced. + +If `Ti <= 0`, integral action is disabled already. An output-limited halfway transition completes without rate gating, and no division is performed. + +## Update Flow + +For each PID update: + +1. PID-SP obtains the one selected Smith-or-measured controller temperature exactly as it does now. P, I, D, transition error, and a raw local approach rate all use that same selected temperature. The raw rate is calculated before any derivative-term suppression. +2. Entering the canonical capture band completes transition state immediately and clears both new fields. An upward transition also completes if a discrete sample crosses to or above the target without landing inside the capture band. +3. The existing halfway test remains: at least three Hold cycles have elapsed and the selected error is no more than half the initial selected-temperature error. +4. The existing integral reset remains active outside the stable window and while a not-yet-complete target is at or beyond halfway. This retains only the current update's integration contribution instead of accumulated windup. +5. PID-SP computes P, I, D, startup scaling, and the complete candidate output. +6. If an upward halfway transition's complete candidate is at or above `u_max`, PID-SP sets `output_limited_approach=True` but does not clear `new_target`. +7. While `output_limited_approach` is active, `Ti > 0`, and the upward target remains below and outside the capture band: + - A raw selected-temperature rate faster than `r_release` resets `slow_approach_samples` to zero. + - A raw rate at or below `r_release`, including a stall or reversal, increments `slow_approach_samples`. + - Three consecutive slow samples complete `new_target` and clear the approach state. Normal integral accumulation then resumes on subsequent updates and removes a high-temperature P-only residual. +8. Output continues through the existing production `u_min`/`u_max` clamps and applied-duty feedback path. + +A single noisy slow sample cannot release the integrator. A target update restarts the state machine. Lowering transitions and ordinary non-output-limited steps retain their existing behavior. + +## Error and Safety Behavior + +- Non-finite controller inputs continue through the existing predictor/controller safety paths; this tuning introduces no fallback output. +- `Ti <= 0` performs no division, completes the output-limited halfway state immediately, and cannot enable an integral that is configured off. +- Manual auger changes, lid-open intervals, fan-PID modulation, controller reinitialization, and mode changes retain their existing identification and applied-duty handling. +- The new state is runtime-only, bounded, and JSON-independent. +- No controller other than PID-SP changes. + +## Verification + +### Focused controller tests + +Deterministic clock tests must prove: + +1. A fast, output-limited upward approach remains transition-damped and does not accumulate historical integral. +2. Three consecutive slow/stalled samples release integral action below the capture band. +3. One or two slow samples followed by a faster approach do not release it. +4. Entering the capture band releases immediately. +5. A target change clears all approach state. +6. Non-output-limited 250°F-style transitions retain existing behavior. +7. Celsius and Fahrenheit thresholds represent the same physical rate. +8. `Ti=0` is finite and leaves integral disabled. + +### Simulator acceptance + +For the medium plant in both `production-reset` and `continuous` modes: + +- 450°F first segment maximum directional overshoot is at most `2.2°F`. +- 450°F first segment settling time is at most `25.6 minutes`. +- Aggregate 450°F IAE remains no worse than the pre-Smith baseline: `1078.7` production-reset and `1075.6` continuous. +- Aggregate 450°F within-5°F remains no worse than the pre-Smith baseline: `79.4%` production-reset and `79.5%` continuous. +- Aggregate mean duty in each 450°F mode stays within `0.005` of the current adaptive value `0.595`. + +Regression gates retain the pre-Smith aggregate envelope for the other nominal scenarios: + +| Scenario/mode | Maximum IAE | Minimum within-5°F | +| --- | ---: | ---: | +| 250°F production-reset | 831.7 | 83.1% | +| 250°F continuous | 834.8 | 83.0% | +| 350°F production-reset | 877.6 | 82.5% | +| 350°F continuous | 878.7 | 82.5% | + +Existing requirements also remain mandatory: + +- Small, medium, and large plants sustain 600°F within ±5°F over the final 600 seconds below `u_max`. +- Closed-loop identification on every plant remains within gain ±10%, tau ±15%, and delay ±5 seconds. +- Every output and metric remains finite and bounded. +- The complete before/current/tuned comparison reports every scenario and mode; it may not select favorable rows. + +## Files in Scope + +Expected implementation scope: + +- `controller/pid_sp.py` +- `tests/test_pid_sp.py` +- `tests/test_pid_simulator.py` +- Runtime comparison artifacts under `/tmp` + +`pid_simulator.py`, Smith model/identifier modules, persistence, production `control.py`, and controller metadata must remain unchanged. A failure that requires modifying one of them is a design blocker to report rather than silent scope expansion.