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