///|
/// Reset a controller after a mode change or safety trip.
pub fn controller_reset(_state : ControllerState) -> ControllerState {
  ControllerState::default()
}

///|
/// Execute one discrete PID step with anti-windup by clamping the integral.
pub fn controller_step(
  config : ControllerConfig,
  state : ControllerState,
  setpoint : Double,
  measurement : Double,
  dt : Double,
) -> ControllerStep {
  let error = setpoint - measurement
  let safe_dt = dt.max(1.0e-9)
  let candidate_integral = state.integral + error * safe_dt
  let raw = config.proportional * error +
    config.integral * candidate_integral +
    config.derivative * (error - state.previous_error) / safe_dt
  let output = raw.clamp(min=config.minimum, max=config.maximum)
  let saturated = output != raw
  let integral = if saturated { state.integral } else { candidate_integral }
  { output, state: { integral, previous_error: error, output }, saturated }
}

///|
/// Simulate a controller against a first-order process model.
pub fn simulate_controller(
  config : ControllerConfig,
  setpoint : Double,
  initial_measurement : Double,
  process_gain : Double,
  process_time_constant : Double,
  dt : Double,
  steps : Int,
) -> Array[ControllerStep] {
  let result : Array[ControllerStep] = []
  let mut state = ControllerState::default()
  let mut measurement = initial_measurement
  let time_constant = process_time_constant.max(1.0e-9)
  for _ in 0.. Double {
  trace.fold(init=0.0, fn(acc, step) { acc.max((setpoint - step.output).abs()) })
}

///|
/// A smooth actuator map that avoids negative heat-removal commands.
pub fn bounded_actuator(
  value : Double,
  minimum : Double,
  maximum : Double,
) -> Double {
  value.clamp(min=minimum.min(maximum), max=maximum.max(minimum))
}

///|
/// Convert a temperature error into a bounded jacket duty cycle.
pub fn jacket_duty(
  target_temperature : Double,
  reactor_temperature : Double,
  coolant_temperature : Double,
  gain : Double,
) -> Double {
  let available = (reactor_temperature - coolant_temperature).max(1.0e-9)
  bounded_actuator(
    gain.max(0.0) * (reactor_temperature - target_temperature) / available,
    0.0,
    1.0,
  )
}

///|
/// Estimate heat removal from a bounded jacket duty cycle.
pub fn jacket_heat_removal(
  exchange : HeatExchange,
  reactor_temperature : Double,
  target_temperature : Double,
  gain : Double,
) -> Double {
  let duty = jacket_duty(
    target_temperature,
    reactor_temperature,
    exchange.coolant_temperature,
    gain,
  )
  exchange.ua.max(0.0) *
  duty *
  (reactor_temperature - exchange.coolant_temperature).max(0.0)
}

///|
/// Check the standard monotonicity and boundedness invariants of a trace.
pub fn controller_trace_is_bounded(
  trace : ArrayView[ControllerStep],
  config : ControllerConfig,
) -> Bool {
  for step in trace {
    if step.output < config.minimum || step.output > config.maximum {
      return false
    }
  }
  true
}