Skip to main content

surge_network/network/
generator.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! Generator representation.
3
4use num_complex::Complex64;
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7
8use crate::market::{CostCurve, EmissionRates, EnergyOffer};
9
10// ── StorageDispatchMode ───────────────────────────────────────────────────
11
12/// How the optimizer dispatches a storage generator.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
14pub enum StorageDispatchMode {
15    /// Optimizer minimizes total system cost — storage charges/discharges optimally.
16    /// Efficiency losses plus `variable_cost_per_mwh` and `degradation_cost_per_mwh`
17    /// are baked into the LP/NLP objective alongside generator costs.  The optimizer
18    /// places storage where it is most valuable given its costs; no external price
19    /// signal is needed (marginal prices emerge endogenously from the dispatch LP).
20    #[default]
21    CostMinimization,
22    /// BESS submits offer curves like a generator.  `discharge_offer` defines the
23    /// cumulative `(MW, $/hr)` PWL breakpoints for selling power; `charge_bid`
24    /// defines the cumulative `(MW, $/hr)` breakpoints for buying power. Curves
25    /// must start with an explicit `(0.0, 0.0)` origin and at least one
26    /// additional breakpoint. The optimizer clears the BESS against these curves
27    /// at the endogenous bus LMP.
28    OfferCurve,
29    /// Operator pre-commits a fixed net injection for every period.  `self_schedule_mw`
30    /// (positive = discharge, negative = charge) is injected as-is (clamped by SoC
31    /// limits).  The schedule is **not** optimized — the BESS is dispatched exactly
32    /// at the committed level regardless of prices.
33    SelfSchedule,
34}
35
36// ── StorageParams ─────────────────────────────────────────────────────────
37
38/// Storage-specific parameters for generators with energy storage capability.
39///
40/// Present on generators that are batteries (BESS), pumped hydro acting as
41/// storage, or any other energy-limited bidirectional resource.
42/// When `Some`, `Generator.pmin` is negative (= -charge_mw_max) and
43/// `Generator.pmax` is the discharge power limit.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(from = "StorageParamsWire")]
46pub struct StorageParams {
47    /// Charge-side efficiency (0 < eta <= 1): fraction of metered charge MW
48    /// that reaches the SoC reservoir. Typical lithium-ion batteries lose
49    /// most of their round-trip on this leg (~90%).
50    pub charge_efficiency: f64,
51    /// Discharge-side efficiency (0 < eta <= 1): fraction of SoC draw that
52    /// reaches the grid as metered discharge MW. Typically higher than the
53    /// charge-side (~98% for modern inverters).
54    pub discharge_efficiency: f64,
55    /// Usable energy capacity (MWh).
56    pub energy_capacity_mwh: f64,
57    /// Initial state of charge (MWh). Must be in [soc_min_mwh, soc_max_mwh].
58    pub soc_initial_mwh: f64,
59    /// Minimum allowable SoC (MWh).
60    pub soc_min_mwh: f64,
61    /// Maximum allowable SoC (MWh). Typically equal to energy_capacity_mwh.
62    pub soc_max_mwh: f64,
63    /// Variable cost per MWh discharged ($/MWh). Used in CostMinimization mode.
64    #[serde(default)]
65    pub variable_cost_per_mwh: f64,
66    /// Degradation cost per MWh throughput ($/MWh), applied to both charge
67    /// and discharge.
68    #[serde(default)]
69    pub degradation_cost_per_mwh: f64,
70    /// How the optimizer dispatches this unit.
71    #[serde(default)]
72    pub dispatch_mode: StorageDispatchMode,
73    /// Pre-committed net dispatch (MW). SelfSchedule mode only.
74    #[serde(default)]
75    pub self_schedule_mw: f64,
76    /// Discharge offer curve: cumulative `(MW, $/hr)` breakpoints with an
77    /// explicit `(0.0, 0.0)` origin. OfferCurve mode only.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub discharge_offer: Option<Vec<(f64, f64)>>,
80    /// Charge bid curve: cumulative `(MW, $/hr)` breakpoints with an explicit
81    /// `(0.0, 0.0)` origin. OfferCurve mode only.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub charge_bid: Option<Vec<(f64, f64)>>,
84    /// Max charge C-rate (e.g. 0.25 for 4-hr battery). None = inverter-limited.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub max_c_rate_charge: Option<f64>,
87    /// Max discharge C-rate. None = inverter-limited.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub max_c_rate_discharge: Option<f64>,
90    /// Battery chemistry (informational): "LFP", "NMC", "flow", "sodium".
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub chemistry: Option<String>,
93    /// Discharge-side foldback threshold (MWh of SoC). Above this, the
94    /// battery can reach its full discharge MW cap; below, the cap
95    /// derates linearly to 0 MW at ``soc_min_mwh``. ``None`` disables
96    /// the foldback cut entirely. Typical lithium-ion value: a few
97    /// percent of energy capacity above ``soc_min_mwh``.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub discharge_foldback_soc_mwh: Option<f64>,
100    /// Charge-side foldback threshold (MWh of SoC). Below this, the
101    /// battery can reach its full charge MW cap; above, the cap
102    /// derates linearly to 0 MW at ``soc_max_mwh``. ``None`` disables
103    /// the foldback cut. Typical lithium-ion value: a few percent of
104    /// energy capacity below ``soc_max_mwh``.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub charge_foldback_soc_mwh: Option<f64>,
107}
108
109/// Wire representation of [`StorageParams`] that accepts either the new
110/// split (`charge_efficiency` / `discharge_efficiency`) or the legacy
111/// single round-trip `efficiency` field. Used via `#[serde(from = ...)]`
112/// so deserialization of older JSON cases keeps working.
113#[derive(Deserialize)]
114struct StorageParamsWire {
115    #[serde(default)]
116    charge_efficiency: Option<f64>,
117    #[serde(default)]
118    discharge_efficiency: Option<f64>,
119    /// Legacy single-field round-trip efficiency — split sqrt-per-leg
120    /// when the two new fields are absent.
121    #[serde(default)]
122    efficiency: Option<f64>,
123    energy_capacity_mwh: f64,
124    soc_initial_mwh: f64,
125    soc_min_mwh: f64,
126    soc_max_mwh: f64,
127    #[serde(default)]
128    variable_cost_per_mwh: f64,
129    #[serde(default)]
130    degradation_cost_per_mwh: f64,
131    #[serde(default)]
132    dispatch_mode: StorageDispatchMode,
133    #[serde(default)]
134    self_schedule_mw: f64,
135    #[serde(default)]
136    discharge_offer: Option<Vec<(f64, f64)>>,
137    #[serde(default)]
138    charge_bid: Option<Vec<(f64, f64)>>,
139    #[serde(default)]
140    max_c_rate_charge: Option<f64>,
141    #[serde(default)]
142    max_c_rate_discharge: Option<f64>,
143    #[serde(default)]
144    chemistry: Option<String>,
145    #[serde(default)]
146    discharge_foldback_soc_mwh: Option<f64>,
147    #[serde(default)]
148    charge_foldback_soc_mwh: Option<f64>,
149}
150
151impl From<StorageParamsWire> for StorageParams {
152    fn from(w: StorageParamsWire) -> Self {
153        let (charge_efficiency, discharge_efficiency) =
154            match (w.charge_efficiency, w.discharge_efficiency, w.efficiency) {
155                (Some(c), Some(d), _) => (c, d),
156                (Some(c), None, _) => (c, 0.98),
157                (None, Some(d), _) => (0.90, d),
158                (None, None, Some(rt)) => {
159                    let leg = rt.max(0.0).sqrt();
160                    (leg, leg)
161                }
162                (None, None, None) => (0.90, 0.98),
163            };
164        Self {
165            charge_efficiency,
166            discharge_efficiency,
167            energy_capacity_mwh: w.energy_capacity_mwh,
168            soc_initial_mwh: w.soc_initial_mwh,
169            soc_min_mwh: w.soc_min_mwh,
170            soc_max_mwh: w.soc_max_mwh,
171            variable_cost_per_mwh: w.variable_cost_per_mwh,
172            degradation_cost_per_mwh: w.degradation_cost_per_mwh,
173            dispatch_mode: w.dispatch_mode,
174            self_schedule_mw: w.self_schedule_mw,
175            discharge_offer: w.discharge_offer,
176            charge_bid: w.charge_bid,
177            max_c_rate_charge: w.max_c_rate_charge,
178            max_c_rate_discharge: w.max_c_rate_discharge,
179            chemistry: w.chemistry,
180            discharge_foldback_soc_mwh: w.discharge_foldback_soc_mwh,
181            charge_foldback_soc_mwh: w.charge_foldback_soc_mwh,
182        }
183    }
184}
185
186/// Validation error for [`StorageParams`].
187#[derive(Debug, Clone, Error, PartialEq)]
188pub enum StorageValidationError {
189    #[error("charge_efficiency must be in (0, 1], got {0}")]
190    InvalidChargeEfficiency(f64),
191    #[error("discharge_efficiency must be in (0, 1], got {0}")]
192    InvalidDischargeEfficiency(f64),
193    #[error("energy_capacity_mwh must be > 0, got {0}")]
194    InvalidEnergyCapacity(f64),
195    #[error("soc_min_mwh ({soc_min_mwh}) exceeds soc_max_mwh ({soc_max_mwh})")]
196    InvalidSocRange { soc_min_mwh: f64, soc_max_mwh: f64 },
197    #[error(
198        "soc_initial_mwh ({soc_initial_mwh}) must lie within [soc_min_mwh ({soc_min_mwh}), soc_max_mwh ({soc_max_mwh})]"
199    )]
200    InvalidInitialSoc {
201        soc_initial_mwh: f64,
202        soc_min_mwh: f64,
203        soc_max_mwh: f64,
204    },
205    #[error(
206        "discharge_foldback_soc_mwh ({threshold}) must lie in (soc_min_mwh ({soc_min}), soc_max_mwh ({soc_max})]; outside this range the foldback cut is either empty or covers the whole range"
207    )]
208    InvalidDischargeFoldback {
209        threshold: f64,
210        soc_min: f64,
211        soc_max: f64,
212    },
213    #[error(
214        "charge_foldback_soc_mwh ({threshold}) must lie in [soc_min_mwh ({soc_min}), soc_max_mwh ({soc_max})); outside this range the foldback cut is either empty or covers the whole range"
215    )]
216    InvalidChargeFoldback {
217        threshold: f64,
218        soc_min: f64,
219        soc_max: f64,
220    },
221}
222
223impl StorageParams {
224    /// Construct with sensible defaults for a storage resource with the given
225    /// energy capacity.
226    ///
227    /// Power limits live on the parent [`Generator`]:
228    /// `Generator::pmin = -charge_mw_max` and
229    /// `Generator::pmax = discharge_mw_max`.
230    pub fn with_energy_capacity_mwh(energy_capacity_mwh: f64) -> Self {
231        Self {
232            charge_efficiency: 0.90,
233            discharge_efficiency: 0.98,
234            energy_capacity_mwh,
235            soc_initial_mwh: 0.5 * energy_capacity_mwh,
236            soc_min_mwh: 0.0,
237            soc_max_mwh: energy_capacity_mwh,
238            variable_cost_per_mwh: 0.0,
239            degradation_cost_per_mwh: 0.0,
240            dispatch_mode: StorageDispatchMode::default(),
241            self_schedule_mw: 0.0,
242            discharge_offer: None,
243            charge_bid: None,
244            max_c_rate_charge: None,
245            max_c_rate_discharge: None,
246            chemistry: None,
247            discharge_foldback_soc_mwh: None,
248            charge_foldback_soc_mwh: None,
249        }
250    }
251
252    /// Round-trip efficiency implied by the charge/discharge pair. Useful for
253    /// reporting and tests where a single figure is expected.
254    pub fn round_trip_efficiency(&self) -> f64 {
255        self.charge_efficiency * self.discharge_efficiency
256    }
257
258    /// Construct a symmetric split from a single round-trip figure. Convenience
259    /// for callers that only have a nameplate round-trip number and want the
260    /// legacy sqrt-per-leg behaviour.
261    pub fn from_round_trip(energy_capacity_mwh: f64, round_trip: f64) -> Self {
262        let leg = round_trip.max(0.0).sqrt();
263        Self {
264            charge_efficiency: leg,
265            discharge_efficiency: leg,
266            ..Self::with_energy_capacity_mwh(energy_capacity_mwh)
267        }
268    }
269
270    /// Validate storage parameters.
271    pub fn validate(&self) -> Result<(), StorageValidationError> {
272        if self.charge_efficiency <= 0.0 || self.charge_efficiency > 1.0 {
273            return Err(StorageValidationError::InvalidChargeEfficiency(
274                self.charge_efficiency,
275            ));
276        }
277        if self.discharge_efficiency <= 0.0 || self.discharge_efficiency > 1.0 {
278            return Err(StorageValidationError::InvalidDischargeEfficiency(
279                self.discharge_efficiency,
280            ));
281        }
282        if self.energy_capacity_mwh <= 0.0 {
283            return Err(StorageValidationError::InvalidEnergyCapacity(
284                self.energy_capacity_mwh,
285            ));
286        }
287        if self.soc_min_mwh > self.soc_max_mwh {
288            return Err(StorageValidationError::InvalidSocRange {
289                soc_min_mwh: self.soc_min_mwh,
290                soc_max_mwh: self.soc_max_mwh,
291            });
292        }
293        if self.soc_initial_mwh < self.soc_min_mwh || self.soc_initial_mwh > self.soc_max_mwh {
294            return Err(StorageValidationError::InvalidInitialSoc {
295                soc_initial_mwh: self.soc_initial_mwh,
296                soc_min_mwh: self.soc_min_mwh,
297                soc_max_mwh: self.soc_max_mwh,
298            });
299        }
300        if let Some(t) = self.discharge_foldback_soc_mwh {
301            if t <= self.soc_min_mwh || t > self.soc_max_mwh {
302                return Err(StorageValidationError::InvalidDischargeFoldback {
303                    threshold: t,
304                    soc_min: self.soc_min_mwh,
305                    soc_max: self.soc_max_mwh,
306                });
307            }
308        }
309        if let Some(t) = self.charge_foldback_soc_mwh {
310            if t < self.soc_min_mwh || t >= self.soc_max_mwh {
311                return Err(StorageValidationError::InvalidChargeFoldback {
312                    threshold: t,
313                    soc_min: self.soc_min_mwh,
314                    soc_max: self.soc_max_mwh,
315                });
316            }
317        }
318        Ok(())
319    }
320
321    /// Validate a market bid/offer curve used by storage dispatch.
322    ///
323    /// Curves must include an explicit `(0.0, 0.0)` origin followed by at
324    /// least one additional strictly increasing MW breakpoint.
325    pub fn validate_market_curve_points(
326        points: &[(f64, f64)],
327        curve_label: &str,
328    ) -> Result<(), String> {
329        if points.len() < 2 {
330            return Err(format!(
331                "{curve_label} must include an explicit origin (0.0, 0.0) and at least one additional breakpoint"
332            ));
333        }
334
335        let (mw0, cost0) = points[0];
336        if !mw0.is_finite() || !cost0.is_finite() {
337            return Err(format!(
338                "{curve_label} origin breakpoint must be finite, got ({mw0}, {cost0})"
339            ));
340        }
341        if mw0.abs() > 1e-9 || cost0.abs() > 1e-9 {
342            return Err(format!(
343                "{curve_label} must start at an explicit origin breakpoint (0.0, 0.0), got ({mw0}, {cost0})"
344            ));
345        }
346
347        let mut prev_mw = mw0;
348        for (point_idx, &(mw, cost)) in points.iter().enumerate().skip(1) {
349            if !mw.is_finite() || !cost.is_finite() {
350                return Err(format!(
351                    "{curve_label} breakpoint {point_idx} must be finite, got ({mw}, {cost})"
352                ));
353            }
354            if mw <= prev_mw + 1e-9 {
355                return Err(format!(
356                    "{curve_label} MW breakpoints must be strictly increasing after the origin; breakpoint {point_idx} has MW {mw} after {prev_mw}"
357                ));
358            }
359            prev_mw = mw;
360        }
361
362        Ok(())
363    }
364
365    /// Evaluate a storage market bid/offer curve at a given MW level.
366    pub fn market_curve_value(points: &[(f64, f64)], mw: f64) -> f64 {
367        CostCurve::PiecewiseLinear {
368            startup: 0.0,
369            shutdown: 0.0,
370            points: points.to_vec(),
371        }
372        .evaluate(mw.max(0.0))
373    }
374
375    /// Evaluate the marginal value of a storage market bid/offer curve.
376    pub fn market_curve_marginal_value(points: &[(f64, f64)], mw: f64) -> f64 {
377        CostCurve::PiecewiseLinear {
378            startup: 0.0,
379            shutdown: 0.0,
380            points: points.to_vec(),
381        }
382        .marginal_cost(mw.max(0.0))
383    }
384}
385
386/// Generator electrical class.
387///
388/// This captures the machine's electrical interface to the network rather than
389/// the plant/resource technology or fuel. Keep technology and fuel in their
390/// dedicated fields.
391#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
392pub enum GenType {
393    /// Conventional synchronous machine with electromechanical swing dynamics.
394    Synchronous,
395    /// Asynchronous / induction machine directly coupled to the grid.
396    Asynchronous,
397    /// Power-electronics-interfaced inverter-based resource.
398    #[serde(alias = "Wind", alias = "Solar", alias = "InverterOther")]
399    InverterBased,
400    /// Hybrid resource combining multiple electrical interfaces.
401    Hybrid,
402    /// Electrical class is not known from the source data.
403    #[default]
404    Unknown,
405}
406
407/// Generator plant/resource technology classification.
408///
409/// This complements [`GenType`] by describing what the unit is, while
410/// `gen_type` describes how it connects electrically.
411#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
412pub enum GeneratorTechnology {
413    Thermal,
414    SteamTurbine,
415    CombustionTurbine,
416    CombinedCycle,
417    InternalCombustion,
418    Hydro,
419    PumpedStorage,
420    Hydrokinetic,
421    Nuclear,
422    Geothermal,
423    Wind,
424    Solar,
425    SolarPv,
426    SolarThermal,
427    Wave,
428    Storage,
429    BatteryStorage,
430    CompressedAirStorage,
431    FlywheelStorage,
432    FuelCell,
433    SynchronousCondenser,
434    StaticVarCompensator,
435    Motor,
436    DispatchableLoad,
437    DcTie,
438    Other,
439}
440
441/// Unit commitment status.
442#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
443pub enum CommitmentStatus {
444    /// ISO decides on/off based on economics.
445    #[default]
446    Market,
447    /// Operator chose to be online, ISO dispatches energy.
448    SelfCommitted,
449    /// Required online for reliability.
450    MustRun,
451    /// On outage, not offered.
452    Unavailable,
453    /// Available only if ISO declares emergency.
454    EmergencyOnly,
455}
456
457/// Fuel supply characteristics.
458#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct FuelSupply {
460    /// Fuel name: "natural_gas", "oil", "diesel", "coal", "uranium".
461    pub fuel: String,
462    /// Fuel price ($/MMBtu).
463    pub price_per_mmbtu: f64,
464    /// Heat rate curve: (MW, BTU/MWh) segments. Empty = use flat heat_rate.
465    pub heat_rate_curve: Vec<(f64, f64)>,
466    /// Daily fuel limit (MMBtu/day). None = unlimited.
467    pub daily_limit_mmbtu: Option<f64>,
468    /// Minimum fuel take (MMBtu/day). Take-or-pay. None = 0.
469    pub min_take_mmbtu: Option<f64>,
470}
471
472// ── Sub-structs ───────────────────────────────────────────────────────────
473
474/// Unit commitment parameters.
475#[derive(Debug, Clone, Serialize, Deserialize)]
476pub struct CommitmentParams {
477    /// Unit commitment status.
478    #[serde(default)]
479    pub status: CommitmentStatus,
480    /// Economic minimum (MW). May be > pmin.
481    #[serde(default, skip_serializing_if = "Option::is_none")]
482    pub p_ecomin: Option<f64>,
483    /// Economic maximum (MW). May be < pmax.
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub p_ecomax: Option<f64>,
486    /// Emergency minimum (MW). Below economic min.
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    pub p_emergency_min: Option<f64>,
489    /// Emergency maximum (MW). Above economic max.
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub p_emergency_max: Option<f64>,
492    /// Regulation-mode minimum (MW). Active when unit is providing regulation.
493    #[serde(default, skip_serializing_if = "Option::is_none")]
494    pub p_reg_min: Option<f64>,
495    /// Regulation-mode maximum (MW). Active when unit is providing regulation.
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub p_reg_max: Option<f64>,
498    /// Minimum up time in hours.
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub min_up_time_hr: Option<f64>,
501    /// Minimum down time in hours.
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    pub min_down_time_hr: Option<f64>,
504    /// Max continuous run time (hours). None = unlimited.
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub max_up_time_hr: Option<f64>,
507    /// Min soak time at pmin after sync (hours).
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    pub min_run_at_pmin_hr: Option<f64>,
510    /// Maximum startup events in any rolling 24-hour window. None = unlimited.
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    pub max_starts_per_day: Option<u32>,
513    /// Maximum startup events in any rolling 7-day (168h) window. None = unlimited.
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    pub max_starts_per_week: Option<u32>,
516    /// Maximum energy output in any rolling 24-hour window (MWh). None = unlimited.
517    /// For hydro, demand response, and energy-limited resources.
518    #[serde(default, skip_serializing_if = "Option::is_none")]
519    pub max_energy_mwh_per_day: Option<f64>,
520    /// Shutdown ramp rate (MW/min). Used for de-loading constraints when
521    /// `enforce_shutdown_deloading` is enabled. Falls back to `ramp_down_mw_per_min()`.
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub shutdown_ramp_mw_per_min: Option<f64>,
524    /// Startup ramp rate (MW/min). Maximum rate at which the unit can increase
525    /// output from zero during startup. Used for de-loading constraints when
526    /// `enforce_shutdown_deloading` is enabled. Falls back to `ramp_up_mw_per_min()`.
527    #[serde(default, skip_serializing_if = "Option::is_none")]
528    pub startup_ramp_mw_per_min: Option<f64>,
529    /// Forbidden operating zones: (low_mw, high_mw) ranges.
530    #[serde(default, skip_serializing_if = "Vec::is_empty")]
531    pub forbidden_zones: Vec<(f64, f64)>,
532    /// Hours online so far (SCUC warm-start).
533    #[serde(default)]
534    pub hours_online: f64,
535    /// Hours offline so far (startup tier selection).
536    #[serde(default)]
537    pub hours_offline: f64,
538}
539
540impl Default for CommitmentParams {
541    fn default() -> Self {
542        Self {
543            status: CommitmentStatus::Market,
544            p_ecomin: None,
545            p_ecomax: None,
546            p_emergency_min: None,
547            p_emergency_max: None,
548            p_reg_min: None,
549            p_reg_max: None,
550            min_up_time_hr: None,
551            min_down_time_hr: None,
552            max_up_time_hr: None,
553            min_run_at_pmin_hr: None,
554            max_starts_per_day: None,
555            max_starts_per_week: None,
556            max_energy_mwh_per_day: None,
557            shutdown_ramp_mw_per_min: None,
558            startup_ramp_mw_per_min: None,
559            forbidden_zones: Vec::new(),
560            hours_online: 0.0,
561            hours_offline: 0.0,
562        }
563    }
564}
565
566/// Piecewise ramp curve parameters.
567#[derive(Debug, Clone, Default, Serialize, Deserialize)]
568pub struct RampingParams {
569    /// Normal ramp-up: (MW operating point, MW/min). Empty = unlimited.
570    #[serde(default, skip_serializing_if = "Vec::is_empty")]
571    pub ramp_up_curve: Vec<(f64, f64)>,
572    /// Normal ramp-down. Empty = unlimited.
573    #[serde(default, skip_serializing_if = "Vec::is_empty")]
574    pub ramp_down_curve: Vec<(f64, f64)>,
575    /// Emergency ramp-up. Empty = use ramp_up_curve.
576    #[serde(default, skip_serializing_if = "Vec::is_empty")]
577    pub emergency_ramp_up_curve: Vec<(f64, f64)>,
578    /// Emergency ramp-down. Empty = use ramp_down_curve.
579    #[serde(default, skip_serializing_if = "Vec::is_empty")]
580    pub emergency_ramp_down_curve: Vec<(f64, f64)>,
581    /// Regulation ramp-up (AGC following). Empty = use ramp_up_curve.
582    #[serde(default, skip_serializing_if = "Vec::is_empty")]
583    pub reg_ramp_up_curve: Vec<(f64, f64)>,
584    /// Regulation ramp-down. Empty = use ramp_down_curve.
585    #[serde(default, skip_serializing_if = "Vec::is_empty")]
586    pub reg_ramp_down_curve: Vec<(f64, f64)>,
587}
588
589impl RampingParams {
590    /// Scalar ramp-up rate (MW/min) — first segment of ramp_up_curve.
591    /// Returns None if curve is empty.
592    #[inline]
593    pub fn ramp_up_mw_per_min(&self) -> Option<f64> {
594        self.ramp_up_curve.first().map(|&(_, rate)| rate)
595    }
596
597    /// Scalar ramp-down rate (MW/min) — first segment, falls back to ramp_up.
598    #[inline]
599    pub fn ramp_down_mw_per_min(&self) -> Option<f64> {
600        self.ramp_down_curve
601            .first()
602            .or(self.ramp_up_curve.first())
603            .map(|&(_, rate)| rate)
604    }
605
606    /// AGC/regulation ramp rate (MW/min) — first segment, falls back to ramp_up.
607    #[inline]
608    pub fn ramp_agc_mw_per_min(&self) -> Option<f64> {
609        self.reg_ramp_up_curve
610            .first()
611            .or(self.ramp_up_curve.first())
612            .map(|&(_, rate)| rate)
613    }
614
615    /// Interpolate ramp-up rate at a given MW operating point.
616    pub fn ramp_up_at_mw(&self, p_mw: f64) -> Option<f64> {
617        ramp_rate_at_mw(&self.ramp_up_curve, p_mw)
618    }
619
620    /// Interpolate ramp-down rate at a given MW operating point.
621    /// Falls back to ramp_up_curve if ramp_down_curve is empty.
622    pub fn ramp_down_at_mw(&self, p_mw: f64) -> Option<f64> {
623        let curve = if self.ramp_down_curve.is_empty() {
624            &self.ramp_up_curve
625        } else {
626            &self.ramp_down_curve
627        };
628        ramp_rate_at_mw(curve, p_mw)
629    }
630
631    /// Interpolate regulation ramp-up rate at a given MW operating point.
632    /// Falls back to ramp_up_curve if reg curve is empty.
633    pub fn reg_ramp_up_at_mw(&self, p_mw: f64) -> Option<f64> {
634        let curve = if self.reg_ramp_up_curve.is_empty() {
635            &self.ramp_up_curve
636        } else {
637            &self.reg_ramp_up_curve
638        };
639        ramp_rate_at_mw(curve, p_mw)
640    }
641
642    /// Interpolate regulation ramp-down rate at a given MW operating point.
643    /// Falls back to ramp_down_curve, then ramp_up_curve.
644    pub fn reg_ramp_down_at_mw(&self, p_mw: f64) -> Option<f64> {
645        let curve = if !self.reg_ramp_down_curve.is_empty() {
646            &self.reg_ramp_down_curve
647        } else if !self.ramp_down_curve.is_empty() {
648            &self.ramp_down_curve
649        } else {
650            &self.ramp_up_curve
651        };
652        ramp_rate_at_mw(curve, p_mw)
653    }
654
655    /// Weighted-average ramp-up rate over \[lo_mw, hi_mw\].
656    pub fn ramp_up_avg(&self, lo_mw: f64, hi_mw: f64) -> Option<f64> {
657        ramp_rate_avg(&self.ramp_up_curve, lo_mw, hi_mw)
658    }
659
660    /// Weighted-average ramp-down rate over \[lo_mw, hi_mw\].
661    /// Falls back to ramp_up_curve if ramp_down_curve is empty.
662    pub fn ramp_down_avg(&self, lo_mw: f64, hi_mw: f64) -> Option<f64> {
663        let curve = if self.ramp_down_curve.is_empty() {
664            &self.ramp_up_curve
665        } else {
666            &self.ramp_down_curve
667        };
668        ramp_rate_avg(curve, lo_mw, hi_mw)
669    }
670}
671
672/// Inverter-specific parameters (ignored for synchronous machines).
673#[derive(Debug, Clone, Serialize, Deserialize)]
674pub struct InverterParams {
675    /// Inverter apparent power rating (MVA). Defines P-Q capability circle.
676    #[serde(default, skip_serializing_if = "Option::is_none")]
677    pub s_rated_mva: Option<f64>,
678    /// Current weather-limited available power (MW). None = use pmax.
679    #[serde(default, skip_serializing_if = "Option::is_none")]
680    pub p_available_mw: Option<f64>,
681    /// Can be curtailed below p_available. Default false.
682    #[serde(default)]
683    pub curtailable: bool,
684    /// Can form voltage/frequency reference. Default false.
685    #[serde(default)]
686    pub grid_forming: bool,
687    /// Inverter no-load loss (MW). Default 0.
688    #[serde(default)]
689    pub inverter_loss_a_mw: f64,
690    /// Inverter proportional loss coefficient. Default 0.
691    #[serde(default, alias = "inverter_loss_b")]
692    pub inverter_loss_b_pu: f64,
693}
694
695impl Default for InverterParams {
696    fn default() -> Self {
697        Self {
698            s_rated_mva: None,
699            p_available_mw: None,
700            curtailable: false,
701            grid_forming: false,
702            inverter_loss_a_mw: 0.0,
703            inverter_loss_b_pu: 0.0,
704        }
705    }
706}
707
708/// Generator fault/sequence data.
709#[derive(Debug, Clone, Default, Serialize, Deserialize)]
710pub struct GenFaultData {
711    /// Machine leakage reactance (pu, machine base). Used as Xd' for GENCLS.
712    #[serde(default, skip_serializing_if = "Option::is_none")]
713    pub xs: Option<f64>,
714    /// Negative-sequence subtransient reactance X2 in per-unit (machine base).
715    #[serde(default, skip_serializing_if = "Option::is_none")]
716    pub x2_pu: Option<f64>,
717    /// Negative-sequence resistance R2 in per-unit (machine base).
718    #[serde(default, skip_serializing_if = "Option::is_none")]
719    pub r2_pu: Option<f64>,
720    /// Zero-sequence reactance X0 in per-unit (machine base).
721    #[serde(default, skip_serializing_if = "Option::is_none")]
722    pub x0_pu: Option<f64>,
723    /// Zero-sequence resistance R0 in per-unit (machine base).
724    #[serde(default, skip_serializing_if = "Option::is_none")]
725    pub r0_pu: Option<f64>,
726    /// Neutral grounding impedance Zn in per-unit (system base).
727    #[serde(default, skip_serializing_if = "Option::is_none")]
728    pub zn: Option<Complex64>,
729}
730
731/// Reactive capability curve data (MATPOWER cols 10-15 + D-curve).
732#[derive(Debug, Clone, Default, Serialize, Deserialize)]
733pub struct ReactiveCapability {
734    /// Sorted list of (p_pu, qmax_pu, qmin_pu) operating points.
735    #[serde(default, skip_serializing_if = "Vec::is_empty")]
736    pub pq_curve: Vec<(f64, f64, f64)>,
737    /// Lower real power output point for Q capability curve (MW).
738    #[serde(default, skip_serializing_if = "Option::is_none")]
739    pub pc1: Option<f64>,
740    /// Upper real power output point for Q capability curve (MW).
741    #[serde(default, skip_serializing_if = "Option::is_none")]
742    pub pc2: Option<f64>,
743    /// Minimum reactive power at Pc1 (MVAr).
744    #[serde(default, skip_serializing_if = "Option::is_none")]
745    pub qc1min: Option<f64>,
746    /// Maximum reactive power at Pc1 (MVAr).
747    #[serde(default, skip_serializing_if = "Option::is_none")]
748    pub qc1max: Option<f64>,
749    /// Minimum reactive power at Pc2 (MVAr).
750    #[serde(default, skip_serializing_if = "Option::is_none")]
751    pub qc2min: Option<f64>,
752    /// Maximum reactive power at Pc2 (MVAr).
753    #[serde(default, skip_serializing_if = "Option::is_none")]
754    pub qc2max: Option<f64>,
755    /// GO Competition Challenge 3 §4.6 eq (116): linear EQUALITY linking
756    /// `q = q_at_p_zero_pu + beta * p`. Devices in `J^pqe`. The PDF
757    /// further constrains q-reserves to zero on these devices (eqs
758    /// 117-118), enforced by the reserves layer when present. None of the
759    /// public 73/617/2000-bus scenarios I inspected exercise this case
760    /// (`q_linear_cap = 1` in the GO JSON), but the larger 4000+ bus
761    /// problems may.
762    #[serde(default, skip_serializing_if = "Option::is_none")]
763    pub pq_linear_equality: Option<PqLinearLink>,
764    /// GO Competition Challenge 3 §4.6 eq (114): linear UPPER bound
765    /// `q + q^qru ≤ q_at_p_zero_pu + beta * p` on devices in `J^pqmax`.
766    /// In GO C3 inputs this is signaled by `q_bound_cap = 1` together
767    /// with `q_0_ub` and `beta_ub`.
768    #[serde(default, skip_serializing_if = "Option::is_none")]
769    pub pq_linear_upper: Option<PqLinearLink>,
770    /// GO Competition Challenge 3 §4.6 eq (115): linear LOWER bound
771    /// `q − q^qrd ≥ q_at_p_zero_pu + beta * p` on devices in `J^pqmin`.
772    /// In GO C3 inputs this is signaled by `q_bound_cap = 1` together
773    /// with `q_0_lb` and `beta_lb`. The GO data property eq (229)
774    /// guarantees `J^pqmax = J^pqmin`, so a device with `q_bound_cap = 1`
775    /// always carries both `pq_linear_upper` and `pq_linear_lower`.
776    #[serde(default, skip_serializing_if = "Option::is_none")]
777    pub pq_linear_lower: Option<PqLinearLink>,
778}
779
780/// Coefficients for a linear p-q linking constraint of the form
781/// `q ⨀ q_at_p_zero_pu + beta * p` where `⨀` is `=`, `≤`, or `≥` depending
782/// on the variant of `ReactiveCapability::pq_linear_*` that holds it.
783///
784/// All values are in per-unit on the device's machine base. The full GO
785/// formulation also references the `active_indicator` (`u^on + Σ u^su +
786/// Σ u^sd`) on the constant term, but in single-period AC reconcile the
787/// commitment is fixed and the indicator collapses to a constant `0` or
788/// `1` that the AC OPF can fold into the row's constant offset.
789#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
790pub struct PqLinearLink {
791    /// Reactive power intercept at `p = 0` in per-unit.
792    pub q_at_p_zero_pu: f64,
793    /// Slope `dq/dp` in per-unit (pu/pu).
794    pub beta: f64,
795}
796
797/// Fuel-related parameters.
798#[derive(Debug, Clone, Default, Serialize, Deserialize)]
799pub struct FuelParams {
800    /// Fuel type (e.g. "gas", "coal", "nuclear", "wind", "solar").
801    #[serde(default, skip_serializing_if = "Option::is_none")]
802    pub fuel_type: Option<String>,
803    /// Heat rate in BTU/MWh (flat fallback when no fuel heat_rate_curve).
804    #[serde(default, skip_serializing_if = "Option::is_none")]
805    pub heat_rate_btu_mwh: Option<f64>,
806    /// Primary fuel supply. None = use cost curve directly.
807    #[serde(default, skip_serializing_if = "Option::is_none")]
808    pub primary_fuel: Option<FuelSupply>,
809    /// Backup fuel (dual-fuel units). None = single fuel.
810    #[serde(default, skip_serializing_if = "Option::is_none")]
811    pub backup_fuel: Option<FuelSupply>,
812    /// Currently on backup fuel.
813    #[serde(default)]
814    pub on_backup_fuel: bool,
815    /// Fuel switching time (minutes).
816    #[serde(default, skip_serializing_if = "Option::is_none")]
817    pub fuel_switch_time_min: Option<f64>,
818    /// Multi-pollutant emission rates.
819    #[serde(default)]
820    pub emission_rates: EmissionRates,
821}
822
823/// Market offer/qualification parameters.
824#[derive(Debug, Clone, Default, Serialize, Deserialize)]
825pub struct MarketParams {
826    /// Energy offer for market clearing.
827    #[serde(default, skip_serializing_if = "Option::is_none")]
828    pub energy_offer: Option<EnergyOffer>,
829    /// Reserve offers keyed by product ID (generic reserve model).
830    #[serde(default, skip_serializing_if = "Vec::is_empty")]
831    pub reserve_offers: Vec<crate::market::reserve::ReserveOffer>,
832    /// Custom qualification flags for reserve products.
833    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
834    pub qualifications: crate::market::reserve::QualificationMap,
835}
836
837// ── Generator ─────────────────────────────────────────────────────────────
838
839/// A generation unit connected to a bus in the transmission network.
840///
841/// Represents thermal, hydro, renewable, and storage resources. For storage
842/// units (BESS, pumped hydro), set `storage = Some(StorageParams)` with
843/// `pmin = -charge_mw_max` (negative) and `pmax = discharge_mw_max`.
844///
845/// All power quantities are in MW/MVAr (system base = 100 MVA for per-unit
846/// conversion). Voltage setpoint is in per-unit.
847#[derive(Debug, Clone, Serialize, Deserialize)]
848pub struct Generator {
849    /// Canonical generator identifier.
850    ///
851    /// This is the stable, crate-native identity for the generator and is used
852    /// for replaying detached solutions, scenario application, and any other
853    /// workflow that must survive generator reordering.
854    ///
855    /// Auto-assigned by [`crate::network::Network::canonicalize_generator_ids`]
856    /// for generators that lack an explicit ID (format: `gen_{bus}_{ordinal}`).
857    #[serde(default = "default_empty_string")]
858    pub id: String,
859    /// Bus number where the generator is connected.
860    pub bus: u32,
861    /// PSS/E machine ID string (e.g. `"1"`, `"G1"`).
862    #[serde(default, skip_serializing_if = "Option::is_none")]
863    pub machine_id: Option<String>,
864    /// Real power output in MW.
865    #[serde(alias = "pg")]
866    pub p: f64,
867    /// Reactive power output in MVAr.
868    #[serde(alias = "qg")]
869    pub q: f64,
870    /// Maximum reactive power in MVAr.
871    pub qmax: f64,
872    /// Minimum reactive power in MVAr.
873    pub qmin: f64,
874    /// Voltage setpoint in per-unit.
875    pub voltage_setpoint_pu: f64,
876    /// Whether this generator participates in voltage regulation (PV bus).
877    /// When false, the generator injects P/Q but does not control bus voltage.
878    /// Default true — most generators regulate voltage.
879    #[serde(default = "default_true")]
880    pub voltage_regulated: bool,
881    /// Remote voltage regulated bus number (PSS/E IREG field).
882    #[serde(default, skip_serializing_if = "Option::is_none")]
883    pub reg_bus: Option<u32>,
884    /// Machine base MVA.
885    pub machine_base_mva: f64,
886    /// Maximum real power in MW.
887    pub pmax: f64,
888    /// Minimum real power in MW.
889    pub pmin: f64,
890    /// Generator status (true = in service).
891    pub in_service: bool,
892    /// Cost curve for OPF (physical cost for planning).
893    #[serde(default, skip_serializing_if = "Option::is_none")]
894    pub cost: Option<CostCurve>,
895    /// Generator electrical class.
896    #[serde(default)]
897    pub gen_type: GenType,
898    /// Generator technology / prime mover classification.
899    #[serde(default, skip_serializing_if = "Option::is_none")]
900    pub technology: Option<GeneratorTechnology>,
901    /// Source-native technology code when available (e.g. MATPOWER `gentype`).
902    #[serde(default, skip_serializing_if = "Option::is_none")]
903    pub source_technology_code: Option<String>,
904    /// AGC participation factor (dimensionless).
905    #[serde(default, skip_serializing_if = "Option::is_none", alias = "apf")]
906    pub agc_participation_factor: Option<f64>,
907    /// Inertia constant H in seconds (MVA·s / MVA).
908    #[serde(default, skip_serializing_if = "Option::is_none")]
909    pub h_inertia_s: Option<f64>,
910    /// Eligible to provide primary frequency response.
911    #[serde(default = "default_true")]
912    pub pfr_eligible: bool,
913    /// Quick-start flag: can reach full output in <=10 minutes.
914    #[serde(default)]
915    pub quick_start: bool,
916    /// Forced outage rate [0, 1].
917    #[serde(default, skip_serializing_if = "Option::is_none")]
918    pub forced_outage_rate: Option<f64>,
919    /// Storage parameters. Present iff this generator has energy storage
920    /// capability (BESS, pumped hydro in storage mode, etc.).
921    /// When Some: pmin = -charge_mw_max (negative), pmax = discharge_mw_max.
922    #[serde(default, skip_serializing_if = "Option::is_none")]
923    pub storage: Option<StorageParams>,
924    /// Ownership entries (PSS/E O1,F1..O4,F4). Up to 4 co-owners.
925    #[serde(default, skip_serializing_if = "Vec::is_empty")]
926    pub owners: Vec<super::owner::OwnershipEntry>,
927
928    // ── Optional sub-structs ──────────────────────────────────────────
929    /// Unit commitment parameters.
930    #[serde(default, skip_serializing_if = "Option::is_none")]
931    pub commitment: Option<CommitmentParams>,
932    /// Piecewise ramp curve parameters.
933    #[serde(default, skip_serializing_if = "Option::is_none")]
934    pub ramping: Option<RampingParams>,
935    /// Inverter-specific parameters.
936    #[serde(default, skip_serializing_if = "Option::is_none")]
937    pub inverter: Option<InverterParams>,
938    /// Generator fault/sequence data.
939    #[serde(default, skip_serializing_if = "Option::is_none")]
940    pub fault_data: Option<GenFaultData>,
941    /// Reactive capability curve data.
942    #[serde(default, skip_serializing_if = "Option::is_none")]
943    pub reactive_capability: Option<ReactiveCapability>,
944    /// Fuel-related parameters.
945    #[serde(default, skip_serializing_if = "Option::is_none")]
946    pub fuel: Option<FuelParams>,
947    /// Market offer/qualification parameters.
948    #[serde(default, skip_serializing_if = "Option::is_none")]
949    pub market: Option<MarketParams>,
950}
951
952use crate::network::serde_defaults::{default_empty_string, default_true};
953
954impl Default for Generator {
955    fn default() -> Self {
956        Self {
957            id: default_empty_string(),
958            bus: 0,
959            machine_id: None,
960            p: 0.0,
961            q: 0.0,
962            qmax: 9999.0,
963            qmin: -9999.0,
964            voltage_setpoint_pu: 1.0,
965            voltage_regulated: true,
966            reg_bus: None,
967            machine_base_mva: 100.0,
968            pmax: 9999.0,
969            pmin: 0.0,
970            in_service: true,
971            cost: None,
972            gen_type: GenType::Unknown,
973            technology: None,
974            source_technology_code: None,
975            agc_participation_factor: None,
976            h_inertia_s: None,
977            pfr_eligible: true,
978            quick_start: false,
979            forced_outage_rate: None,
980            storage: None,
981            owners: Vec::new(),
982            commitment: None,
983            ramping: None,
984            inverter: None,
985            fault_data: None,
986            reactive_capability: None,
987            fuel: None,
988            market: None,
989        }
990    }
991}
992
993impl Generator {
994    /// Create a generator with the given bus, real power output, and voltage setpoint.
995    pub fn new(bus: u32, p: f64, voltage_setpoint_pu: f64) -> Self {
996        Self {
997            bus,
998            p,
999            voltage_setpoint_pu,
1000            ..Default::default()
1001        }
1002    }
1003
1004    /// Create a generator with an explicit canonical ID.
1005    pub fn with_id(id: impl Into<String>, bus: u32, p: f64, voltage_setpoint_pu: f64) -> Self {
1006        let mut generator = Self::new(bus, p, voltage_setpoint_pu);
1007        generator.id = id.into();
1008        generator
1009    }
1010
1011    /// Returns true when the generator can move reactive output enough to support
1012    /// a voltage target.
1013    #[inline]
1014    pub fn has_reactive_power_range(&self, tolerance_mvar: f64) -> bool {
1015        if self.qmax.is_nan() || self.qmin.is_nan() {
1016            return false;
1017        }
1018        if !self.qmax.is_finite() || !self.qmin.is_finite() {
1019            return true;
1020        }
1021        self.qmax > self.qmin + tolerance_mvar
1022    }
1023
1024    /// Returns true when this generator is explicitly excluded from acting as
1025    /// a voltage-regulating reference resource.
1026    #[inline]
1027    pub fn is_excluded_from_voltage_regulation(&self) -> bool {
1028        self.market
1029            .as_ref()
1030            .and_then(|market| market.qualifications.get("ac_voltage_regulation_excluded"))
1031            .copied()
1032            .unwrap_or(false)
1033    }
1034
1035    /// Returns true when the generator should participate in AC voltage control.
1036    ///
1037    /// Q range is intentionally NOT required here: a generator with
1038    /// `qmin == qmax` (e.g. Q pinned to a reference value for
1039    /// diagnostic roundtrips or fixed-Q dispatch replays) is still a
1040    /// legitimate voltage reference — V is the free variable at its bus,
1041    /// Q just happens to be fixed rather than free. Requiring
1042    /// `has_reactive_power_range` here breaks `validate_for_solve`
1043    /// whenever the per-period dispatch profile collapses Q bounds to
1044    /// a point (the bus then has no regulator count, slack-placement
1045    /// check fails, AC-OPF preflight rejects the network).
1046    #[inline]
1047    pub fn can_voltage_regulate(&self) -> bool {
1048        self.in_service && !self.is_excluded_from_voltage_regulation() && self.voltage_regulated
1049    }
1050
1051    // ── Ramp forwarding methods ───────────────────────────────────────
1052
1053    /// Interpolate ramp-up rate at a given MW operating point.
1054    /// Below first breakpoint -> first segment's rate.
1055    /// Above last breakpoint -> last segment's rate.
1056    /// Returns None if curve is empty (unlimited ramp).
1057    pub fn ramp_up_at_mw(&self, p_mw: f64) -> Option<f64> {
1058        self.ramping.as_ref().and_then(|r| r.ramp_up_at_mw(p_mw))
1059    }
1060
1061    /// Interpolate ramp-down rate at a given MW operating point.
1062    /// Falls back to ramp_up_curve if ramp_down_curve is empty.
1063    pub fn ramp_down_at_mw(&self, p_mw: f64) -> Option<f64> {
1064        self.ramping.as_ref().and_then(|r| r.ramp_down_at_mw(p_mw))
1065    }
1066
1067    /// Interpolate regulation ramp-up rate at a given MW operating point.
1068    /// Falls back to ramp_up_curve if reg curve is empty.
1069    pub fn reg_ramp_up_at_mw(&self, p_mw: f64) -> Option<f64> {
1070        self.ramping
1071            .as_ref()
1072            .and_then(|r| r.reg_ramp_up_at_mw(p_mw))
1073    }
1074
1075    /// Interpolate regulation ramp-down rate at a given MW operating point.
1076    /// Falls back to ramp_down_curve, then ramp_up_curve.
1077    pub fn reg_ramp_down_at_mw(&self, p_mw: f64) -> Option<f64> {
1078        self.ramping
1079            .as_ref()
1080            .and_then(|r| r.reg_ramp_down_at_mw(p_mw))
1081    }
1082
1083    /// Weighted-average ramp-up rate over \[Pmin, Pmax\].
1084    /// Returns None if curve is empty (unlimited ramp).
1085    pub fn ramp_up_avg_mw_per_min(&self) -> Option<f64> {
1086        self.ramping
1087            .as_ref()
1088            .and_then(|r| r.ramp_up_avg(self.pmin, self.pmax))
1089    }
1090
1091    /// Weighted-average ramp-down rate over \[Pmin, Pmax\].
1092    /// Falls back to ramp_up_curve if ramp_down_curve is empty.
1093    pub fn ramp_down_avg_mw_per_min(&self) -> Option<f64> {
1094        self.ramping
1095            .as_ref()
1096            .and_then(|r| r.ramp_down_avg(self.pmin, self.pmax))
1097    }
1098
1099    /// Scalar ramp-up rate (MW/min) — first segment of ramp_up_curve.
1100    /// Equivalent to `ramp_up_at_mw(pmin)`. Returns None if curve is empty.
1101    #[inline]
1102    pub fn ramp_up_mw_per_min(&self) -> Option<f64> {
1103        self.ramping.as_ref().and_then(|r| r.ramp_up_mw_per_min())
1104    }
1105
1106    /// Scalar ramp-down rate (MW/min) — first segment, falls back to ramp_up.
1107    #[inline]
1108    pub fn ramp_down_mw_per_min(&self) -> Option<f64> {
1109        self.ramping.as_ref().and_then(|r| r.ramp_down_mw_per_min())
1110    }
1111
1112    /// AGC/regulation ramp rate (MW/min) — first segment, falls back to ramp_up.
1113    #[inline]
1114    pub fn ramp_agc_mw_per_min(&self) -> Option<f64> {
1115        self.ramping.as_ref().and_then(|r| r.ramp_agc_mw_per_min())
1116    }
1117
1118    // ── Cross-cutting methods ─────────────────────────────────────────
1119
1120    /// Returns true if this generator has energy storage capability.
1121    #[inline]
1122    pub fn is_storage(&self) -> bool {
1123        self.storage.is_some()
1124    }
1125
1126    /// Maximum charge power (MW). Returns 0 for non-storage generators.
1127    #[inline]
1128    pub fn charge_mw_max(&self) -> f64 {
1129        if self.storage.is_some() {
1130            (-self.pmin).max(0.0)
1131        } else {
1132            0.0
1133        }
1134    }
1135
1136    /// Maximum discharge power (MW). Alias for pmax for storage generators.
1137    #[inline]
1138    pub fn discharge_mw_max(&self) -> f64 {
1139        self.pmax
1140    }
1141
1142    /// True if this generator is must-run.
1143    #[inline]
1144    pub fn is_must_run(&self) -> bool {
1145        self.commitment
1146            .as_ref()
1147            .is_some_and(|c| c.status == CommitmentStatus::MustRun)
1148    }
1149
1150    /// Get the reserve offer for a specific product, if any.
1151    pub fn reserve_offer(&self, product_id: &str) -> Option<&crate::market::reserve::ReserveOffer> {
1152        self.market
1153            .as_ref()
1154            .and_then(|m| m.reserve_offers.iter().find(|o| o.product_id == product_id))
1155    }
1156
1157    /// Maximum MW deliverable within a product's deployment window, limited by ramp rate.
1158    ///
1159    /// Selects the appropriate ramp curve (reg/normal/emergency) based on product context.
1160    pub fn ramp_limited_mw(&self, product: &crate::market::reserve::ReserveProduct) -> f64 {
1161        use crate::market::reserve::ReserveDirection;
1162        let deploy_min = product.deploy_secs / 60.0;
1163        let rate = match product.direction {
1164            ReserveDirection::Up => {
1165                // Regulation products use reg ramp curve
1166                if product.id.starts_with("reg") {
1167                    self.ramp_agc_mw_per_min()
1168                } else {
1169                    self.ramp_up_mw_per_min()
1170                }
1171            }
1172            ReserveDirection::Down => {
1173                if product.id.starts_with("reg") {
1174                    // Reg down — use reg ramp down, fall back to ramp down
1175                    self.ramping.as_ref().and_then(|r| {
1176                        r.reg_ramp_down_curve
1177                            .first()
1178                            .or(r.ramp_down_curve.first())
1179                            .or(r.ramp_up_curve.first())
1180                            .map(|&(_, rate)| rate)
1181                    })
1182                } else {
1183                    self.ramp_down_mw_per_min()
1184                }
1185            }
1186        };
1187        rate.map(|r| r * deploy_min).unwrap_or(f64::INFINITY)
1188    }
1189
1190    /// Effective shutdown ramp capacity (MW) for one dispatch period of `dt_hours`.
1191    ///
1192    /// Uses `shutdown_ramp_mw_per_min` when present; otherwise falls back to the
1193    /// economic ramp-down rate. Returns `f64::MAX` when no ramp curve is defined.
1194    #[inline]
1195    pub fn shutdown_ramp_mw_per_period(&self, dt_hours: f64) -> f64 {
1196        let rate = self
1197            .commitment
1198            .as_ref()
1199            .and_then(|c| c.shutdown_ramp_mw_per_min)
1200            .or_else(|| self.ramp_down_mw_per_min())
1201            .unwrap_or(f64::MAX);
1202        rate * 60.0 * dt_hours
1203    }
1204
1205    /// Effective startup ramp capacity (MW) for one dispatch period of `dt_hours`.
1206    ///
1207    /// Uses `startup_ramp_mw_per_min` when present; otherwise falls back to the
1208    /// economic ramp-up rate. Returns `f64::MAX` when no ramp curve is defined.
1209    #[inline]
1210    pub fn startup_ramp_mw_per_period(&self, dt_hours: f64) -> f64 {
1211        let rate = self
1212            .commitment
1213            .as_ref()
1214            .and_then(|c| c.startup_ramp_mw_per_min)
1215            .or_else(|| self.ramp_up_mw_per_min())
1216            .unwrap_or(f64::MAX);
1217        rate * 60.0 * dt_hours
1218    }
1219}
1220
1221// ── Piecewise ramp curve helpers ──────────────────────────────────────
1222
1223/// Interpolate a ramp rate at a given MW operating point on a piecewise curve.
1224/// The curve is `[(MW_breakpoint, MW/min_rate), ...]`, sorted by MW ascending.
1225/// Returns the rate of the segment containing `p_mw`:
1226/// - Below first breakpoint -> first rate.
1227/// - Above last breakpoint -> last rate.
1228/// - Between breakpoints -> rate of the segment whose range contains `p_mw`.
1229fn ramp_rate_at_mw(curve: &[(f64, f64)], p_mw: f64) -> Option<f64> {
1230    if curve.is_empty() {
1231        return None;
1232    }
1233    if curve.len() == 1 {
1234        return Some(curve[0].1);
1235    }
1236    // Find the segment: last breakpoint whose MW <= p_mw
1237    for i in (0..curve.len()).rev() {
1238        if p_mw >= curve[i].0 {
1239            return Some(curve[i].1);
1240        }
1241    }
1242    // p_mw is below the first breakpoint — use first rate
1243    Some(curve[0].1)
1244}
1245
1246/// Weighted-average ramp rate over [lo_mw, hi_mw].
1247/// Each segment's rate is weighted by the MW overlap with [lo_mw, hi_mw].
1248fn ramp_rate_avg(curve: &[(f64, f64)], lo_mw: f64, hi_mw: f64) -> Option<f64> {
1249    if curve.is_empty() {
1250        return None;
1251    }
1252    let span = hi_mw - lo_mw;
1253    if span <= 0.0 {
1254        // Degenerate range — return rate at lo_mw
1255        return ramp_rate_at_mw(curve, lo_mw);
1256    }
1257    if curve.len() == 1 {
1258        return Some(curve[0].1);
1259    }
1260
1261    // Build segment ranges: segment i covers [curve[i].0, curve[i+1].0),
1262    // last segment extends to infinity.
1263    let mut weighted_sum = 0.0;
1264    for i in 0..curve.len() {
1265        let seg_lo = curve[i].0;
1266        let seg_hi = if i + 1 < curve.len() {
1267            curve[i + 1].0
1268        } else {
1269            f64::MAX
1270        };
1271        let rate = curve[i].1;
1272
1273        // Overlap of [seg_lo, seg_hi) with [lo_mw, hi_mw]
1274        let overlap_lo = seg_lo.max(lo_mw);
1275        let overlap_hi = seg_hi.min(hi_mw);
1276        if overlap_hi > overlap_lo {
1277            weighted_sum += rate * (overlap_hi - overlap_lo);
1278        }
1279    }
1280    Some(weighted_sum / span)
1281}
1282
1283/// Enforce monotonically non-decreasing rates on a ramp curve.
1284/// Segments with rate < previous are flattened to the previous rate.
1285pub fn enforce_monotonic_ramp(curve: &[(f64, f64)]) -> Vec<(f64, f64)> {
1286    let mut result: Vec<(f64, f64)> = curve.to_vec();
1287    for i in 1..result.len() {
1288        if result[i].1 < result[i - 1].1 {
1289            tracing::warn!(
1290                segment = i,
1291                rate = result[i].1,
1292                previous = result[i - 1].1,
1293                "Ramp curve segment: rate < previous, flattened"
1294            );
1295            result[i].1 = result[i - 1].1;
1296        }
1297    }
1298    result
1299}
1300
1301#[cfg(test)]
1302mod tests {
1303    use super::*;
1304
1305    fn gen_with_ramp(pmin: f64, pmax: f64, up: Vec<(f64, f64)>, dn: Vec<(f64, f64)>) -> Generator {
1306        Generator {
1307            pmin,
1308            pmax,
1309            ramping: Some(RampingParams {
1310                ramp_up_curve: up,
1311                ramp_down_curve: dn,
1312                ..Default::default()
1313            }),
1314            ..Default::default()
1315        }
1316    }
1317
1318    // ── ramp_rate_at_mw ──
1319
1320    #[test]
1321    fn test_ramp_at_mw_empty_curve() {
1322        assert_eq!(ramp_rate_at_mw(&[], 100.0), None);
1323    }
1324
1325    #[test]
1326    fn test_ramp_at_mw_single_segment() {
1327        let curve = vec![(0.0, 10.0)];
1328        assert_eq!(ramp_rate_at_mw(&curve, 0.0), Some(10.0));
1329        assert_eq!(ramp_rate_at_mw(&curve, 500.0), Some(10.0));
1330    }
1331
1332    #[test]
1333    fn test_ramp_at_mw_multi_segment() {
1334        // Rate changes at MW breakpoints: 8 below 350, 12 from 350+
1335        let curve = vec![(200.0, 8.0), (350.0, 12.0), (500.0, 10.0)];
1336        assert_eq!(ramp_rate_at_mw(&curve, 100.0), Some(8.0)); // below first bp
1337        assert_eq!(ramp_rate_at_mw(&curve, 200.0), Some(8.0)); // at first bp
1338        assert_eq!(ramp_rate_at_mw(&curve, 300.0), Some(8.0)); // in first segment
1339        assert_eq!(ramp_rate_at_mw(&curve, 350.0), Some(12.0)); // at second bp
1340        assert_eq!(ramp_rate_at_mw(&curve, 400.0), Some(12.0)); // in second segment
1341        assert_eq!(ramp_rate_at_mw(&curve, 500.0), Some(10.0)); // at third bp
1342        assert_eq!(ramp_rate_at_mw(&curve, 600.0), Some(10.0)); // above last bp
1343    }
1344
1345    // ── ramp_rate_avg ──
1346
1347    #[test]
1348    fn test_ramp_avg_empty_curve() {
1349        assert_eq!(ramp_rate_avg(&[], 100.0, 500.0), None);
1350    }
1351
1352    #[test]
1353    fn test_ramp_avg_single_segment() {
1354        let curve = vec![(0.0, 10.0)];
1355        assert_eq!(ramp_rate_avg(&curve, 100.0, 500.0), Some(10.0));
1356    }
1357
1358    #[test]
1359    fn test_ramp_avg_multi_segment() {
1360        // 200-350: rate=8 (150 MW), 350-500: rate=12 (150 MW)
1361        let curve = vec![(200.0, 8.0), (350.0, 12.0)];
1362        let avg = ramp_rate_avg(&curve, 200.0, 500.0).unwrap();
1363        // (8 * 150 + 12 * 150) / 300 = (1200 + 1800) / 300 = 10.0
1364        assert!((avg - 10.0).abs() < 1e-10);
1365    }
1366
1367    #[test]
1368    fn test_ramp_avg_partial_overlap() {
1369        // Curve starts at 100, but we integrate over [200, 400]
1370        // Segment 100-300: rate=5. Segment 300-500: rate=15.
1371        let curve = vec![(100.0, 5.0), (300.0, 15.0)];
1372        let avg = ramp_rate_avg(&curve, 200.0, 400.0).unwrap();
1373        // Overlap: [200,300] -> 100 MW at rate=5, [300,400] -> 100 MW at rate=15
1374        // avg = (5*100 + 15*100) / 200 = 10.0
1375        assert!((avg - 10.0).abs() < 1e-10);
1376    }
1377
1378    // ── Generator methods ──
1379
1380    #[test]
1381    fn test_gen_ramp_up_avg() {
1382        let g = gen_with_ramp(200.0, 500.0, vec![(200.0, 8.0), (350.0, 12.0)], vec![]);
1383        let avg = g.ramp_up_avg_mw_per_min().unwrap();
1384        assert!((avg - 10.0).abs() < 1e-10);
1385    }
1386
1387    #[test]
1388    fn test_gen_ramp_dn_fallback_to_up() {
1389        let g = gen_with_ramp(200.0, 500.0, vec![(0.0, 10.0)], vec![]);
1390        assert_eq!(g.ramp_down_at_mw(300.0), Some(10.0));
1391        assert_eq!(g.ramp_down_avg_mw_per_min(), Some(10.0));
1392    }
1393
1394    #[test]
1395    fn test_gen_ramp_up_at_mw() {
1396        let g = gen_with_ramp(200.0, 500.0, vec![(200.0, 8.0), (400.0, 12.0)], vec![]);
1397        assert_eq!(g.ramp_up_at_mw(300.0), Some(8.0));
1398        assert_eq!(g.ramp_up_at_mw(450.0), Some(12.0));
1399    }
1400
1401    #[test]
1402    fn test_gen_single_segment_consistent() {
1403        // Single-segment: all methods should agree
1404        let g = gen_with_ramp(0.0, 100.0, vec![(0.0, 5.0)], vec![]);
1405        assert_eq!(g.ramp_up_mw_per_min(), Some(5.0));
1406        assert_eq!(g.ramp_up_at_mw(50.0), Some(5.0));
1407        assert_eq!(g.ramp_up_avg_mw_per_min(), Some(5.0));
1408    }
1409
1410    #[test]
1411    #[ignore = "encoded spec behavior drifted from implementation; revisit voltage-regulation eligibility rules"]
1412    fn test_generator_can_voltage_regulate_requires_reactive_range() {
1413        let mut generator = Generator {
1414            qmin: 0.0,
1415            qmax: 0.0,
1416            ..Generator::default()
1417        };
1418        assert!(!generator.has_reactive_power_range(1e-9));
1419        assert!(!generator.can_voltage_regulate());
1420
1421        generator.qmax = 10.0;
1422        assert!(generator.has_reactive_power_range(1e-9));
1423        assert!(generator.can_voltage_regulate());
1424    }
1425
1426    #[test]
1427    fn test_generator_can_voltage_regulate_accepts_unbounded_reactive_range() {
1428        let generator = Generator {
1429            qmin: f64::NEG_INFINITY,
1430            qmax: f64::INFINITY,
1431            ..Generator::default()
1432        };
1433        assert!(generator.has_reactive_power_range(1e-9));
1434        assert!(generator.can_voltage_regulate());
1435    }
1436
1437    #[test]
1438    fn test_generator_can_voltage_regulate_respects_exclusion_qualification() {
1439        let mut generator = Generator {
1440            qmin: -10.0,
1441            qmax: 10.0,
1442            market: Some(MarketParams::default()),
1443            ..Generator::default()
1444        };
1445        generator
1446            .market
1447            .as_mut()
1448            .expect("market params")
1449            .qualifications
1450            .insert("ac_voltage_regulation_excluded".to_string(), true);
1451        assert!(generator.has_reactive_power_range(1e-9));
1452        assert!(!generator.can_voltage_regulate());
1453        assert!(generator.is_excluded_from_voltage_regulation());
1454    }
1455
1456    // ── enforce_monotonic_ramp ──
1457
1458    #[test]
1459    fn test_enforce_monotonic_already_monotonic() {
1460        let curve = vec![(200.0, 8.0), (400.0, 12.0), (500.0, 15.0)];
1461        let result = enforce_monotonic_ramp(&curve);
1462        assert_eq!(result, curve);
1463    }
1464
1465    #[test]
1466    fn test_enforce_monotonic_flattens_decrease() {
1467        let curve = vec![(200.0, 8.0), (400.0, 12.0), (500.0, 6.0)];
1468        let result = enforce_monotonic_ramp(&curve);
1469        assert_eq!(result, vec![(200.0, 8.0), (400.0, 12.0), (500.0, 12.0)]);
1470    }
1471
1472    #[test]
1473    fn test_enforce_monotonic_empty() {
1474        let result = enforce_monotonic_ramp(&[]);
1475        assert!(result.is_empty());
1476    }
1477
1478    #[test]
1479    fn test_enforce_monotonic_cascading() {
1480        // All decrease: should flatten everything to first rate
1481        let curve = vec![(0.0, 10.0), (100.0, 8.0), (200.0, 5.0)];
1482        let result = enforce_monotonic_ramp(&curve);
1483        assert_eq!(result, vec![(0.0, 10.0), (100.0, 10.0), (200.0, 10.0)]);
1484    }
1485
1486    #[test]
1487    fn test_shutdown_ramp_mw_per_period_explicit() {
1488        let g = Generator {
1489            pmax: 300.0,
1490            commitment: Some(CommitmentParams {
1491                shutdown_ramp_mw_per_min: Some(2.0),
1492                ..Default::default()
1493            }),
1494            ramping: Some(RampingParams {
1495                ramp_down_curve: vec![(0.0, 5.0)],
1496                ..Default::default()
1497            }),
1498            ..Default::default()
1499        };
1500        // Uses explicit shutdown ramp: 2.0 * 60 * 1.0 = 120
1501        assert!((g.shutdown_ramp_mw_per_period(1.0) - 120.0).abs() < 1e-10);
1502    }
1503
1504    #[test]
1505    fn test_shutdown_ramp_mw_per_period_fallback() {
1506        let g = Generator {
1507            pmax: 300.0,
1508            ramping: Some(RampingParams {
1509                ramp_down_curve: vec![(0.0, 5.0)],
1510                ..Default::default()
1511            }),
1512            ..Default::default()
1513        };
1514        // Falls back to ramp_down: 5.0 * 60 * 1.0 = 300
1515        assert!((g.shutdown_ramp_mw_per_period(1.0) - 300.0).abs() < 1e-10);
1516    }
1517
1518    #[test]
1519    fn test_shutdown_ramp_mw_per_period_no_curve() {
1520        let g = Generator::default();
1521        // No ramp curve -> effectively unlimited (>= f64::MAX)
1522        assert!(g.shutdown_ramp_mw_per_period(1.0) >= f64::MAX);
1523    }
1524
1525    #[test]
1526    fn test_startup_ramp_mw_per_period_explicit() {
1527        let g = Generator {
1528            pmax: 300.0,
1529            commitment: Some(CommitmentParams {
1530                startup_ramp_mw_per_min: Some(1.5),
1531                ..Default::default()
1532            }),
1533            ramping: Some(RampingParams {
1534                ramp_up_curve: vec![(0.0, 5.0)],
1535                ..Default::default()
1536            }),
1537            ..Default::default()
1538        };
1539        // Uses explicit startup ramp: 1.5 * 60 * 1.0 = 90
1540        assert!((g.startup_ramp_mw_per_period(1.0) - 90.0).abs() < 1e-10);
1541    }
1542
1543    #[test]
1544    fn test_startup_ramp_mw_per_period_fallback() {
1545        let g = Generator {
1546            pmax: 300.0,
1547            ramping: Some(RampingParams {
1548                ramp_up_curve: vec![(0.0, 5.0)],
1549                ..Default::default()
1550            }),
1551            ..Default::default()
1552        };
1553        // Falls back to ramp_up: 5.0 * 60 * 1.0 = 300
1554        assert!((g.startup_ramp_mw_per_period(1.0) - 300.0).abs() < 1e-10);
1555    }
1556
1557    #[test]
1558    fn test_startup_ramp_mw_per_period_half_hour() {
1559        let g = Generator {
1560            commitment: Some(CommitmentParams {
1561                startup_ramp_mw_per_min: Some(3.0),
1562                ..Default::default()
1563            }),
1564            ..Default::default()
1565        };
1566        // 3.0 * 60 * 0.5 = 90
1567        assert!((g.startup_ramp_mw_per_period(0.5) - 90.0).abs() < 1e-10);
1568    }
1569}