///|
/// Estimate annualized cost for an early design point.
pub fn estimate_annual_cost(
  point : DesignPoint,
  assumptions : CostAssumptions,
) -> CostEstimate {
  let capital = point.volume.max(0.0) *
    assumptions.vessel_cost_per_volume.max(0.0)
  let pumping = point.volume.max(0.0) * assumptions.pump_cost_per_flow.max(0.0)
  let thermal = point.heat_removed.abs() *
    assumptions.annual_hours.max(0.0) *
    assumptions.electricity_price.max(0.0) /
    1000.0
  let utilities = pumping + thermal
  let maintenance = capital * assumptions.maintenance_fraction.max(0.0)
  { capital, utilities, maintenance, total: capital + utilities + maintenance }
}

///|
/// Compare the annualized cost of two design points.
pub fn cost_savings(
  reference : CostEstimate,
  candidate : CostEstimate,
) -> Double {
  reference.total - candidate.total
}

///|
/// Return the cost per unit of converted feed, with a safe zero boundary.
pub fn cost_per_conversion(cost : CostEstimate, point : DesignPoint) -> Double {
  if point.conversion <= 1.0e-12 {
    cost.total / 1.0e-12
  } else {
    cost.total / point.conversion
  }
}

///|
/// Evaluate a volume grid against conversion and temperature limits.
pub fn design_envelope(
  reaction : Reaction,
  feed : Feed,
  minimum_volume : Double,
  maximum_volume : Double,
  steps : Int,
  minimum_conversion : Double,
  maximum_temperature : Double,
) -> Array[EnvelopePoint] {
  let count = steps.max(0)
  let result : Array[EnvelopePoint] = []
  for i in 0..<=count {
    let fraction = if count == 0 {
      0.0
    } else {
      Double::from_int(i) / Double::from_int(count)
    }
    let volume = minimum_volume + (maximum_volume - minimum_volume) * fraction
    let point = design_pfr(reaction, feed, volume)
    let conversion_ok = point.conversion >= minimum_conversion
    let temperature_ok = point.outlet_temperature <= maximum_temperature
    let reason = if conversion_ok && temperature_ok {
      "feasible"
    } else if !conversion_ok && !temperature_ok {
      "conversion and temperature limits"
    } else if !conversion_ok {
      "conversion limit"
    } else {
      "temperature limit"
    }
    result.push({
      volume,
      conversion: point.conversion,
      temperature: point.outlet_temperature,
      feasible: conversion_ok && temperature_ok,
      reason,
    })
  }
  result
}

///|
/// Select the feasible point with the lowest volume.
pub fn minimum_feasible_volume(
  points : ArrayView[EnvelopePoint],
) -> EnvelopePoint? {
  let mut best : EnvelopePoint? = None
  for point in points {
    if point.feasible {
      match best {
        None => best = Some(point)
        Some(current) => if point.volume < current.volume { best = Some(point) }
      }
    }
  }
  best
}

///|
/// Score a candidate using conversion benefit and volume penalty.
pub fn score_design(
  point : DesignPoint,
  conversion_weight : Double,
  volume_weight : Double,
  temperature_limit : Double,
) -> ObjectiveScore {
  let feasible = point.outlet_temperature <= temperature_limit
  let penalty = if feasible { 0.0 } else { 1.0e6 }
  let score = conversion_weight.max(0.0) * point.conversion -
    volume_weight.max(0.0) * point.volume -
    penalty
  {
    volume: point.volume,
    conversion: point.conversion,
    temperature: point.outlet_temperature,
    score,
    feasible,
  }
}

///|
/// Enumerate a coarse, deterministic design objective surface.
pub fn objective_surface(
  reaction : Reaction,
  feed : Feed,
  volumes : ArrayView[Double],
  conversion_weight : Double,
  volume_weight : Double,
  temperature_limit : Double,
) -> Array[ObjectiveScore] {
  let result : Array[ObjectiveScore] = []
  for volume in volumes {
    result.push(
      score_design(
        design_pfr(reaction, feed, volume),
        conversion_weight,
        volume_weight,
        temperature_limit,
      ),
    )
  }
  result
}

///|
/// Find the highest-scoring candidate, preserving the first tie.
pub fn best_objective(scores : ArrayView[ObjectiveScore]) -> ObjectiveScore? {
  let mut best : ObjectiveScore? = None
  for score in scores {
    match best {
      None => best = Some(score)
      Some(current) => if score.score > current.score { best = Some(score) }
    }
  }
  best
}