1use num_complex::Complex64;
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7
8use crate::market::{CostCurve, EmissionRates, EnergyOffer};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
14pub enum StorageDispatchMode {
15 #[default]
21 CostMinimization,
22 OfferCurve,
29 SelfSchedule,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(from = "StorageParamsWire")]
46pub struct StorageParams {
47 pub charge_efficiency: f64,
51 pub discharge_efficiency: f64,
55 pub energy_capacity_mwh: f64,
57 pub soc_initial_mwh: f64,
59 pub soc_min_mwh: f64,
61 pub soc_max_mwh: f64,
63 #[serde(default)]
65 pub variable_cost_per_mwh: f64,
66 #[serde(default)]
69 pub degradation_cost_per_mwh: f64,
70 #[serde(default)]
72 pub dispatch_mode: StorageDispatchMode,
73 #[serde(default)]
75 pub self_schedule_mw: f64,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub discharge_offer: Option<Vec<(f64, f64)>>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub charge_bid: Option<Vec<(f64, f64)>>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub max_c_rate_charge: Option<f64>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub max_c_rate_discharge: Option<f64>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub chemistry: Option<String>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub discharge_foldback_soc_mwh: Option<f64>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub charge_foldback_soc_mwh: Option<f64>,
107}
108
109#[derive(Deserialize)]
114struct StorageParamsWire {
115 #[serde(default)]
116 charge_efficiency: Option<f64>,
117 #[serde(default)]
118 discharge_efficiency: Option<f64>,
119 #[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#[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 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 pub fn round_trip_efficiency(&self) -> f64 {
255 self.charge_efficiency * self.discharge_efficiency
256 }
257
258 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
392pub enum GenType {
393 Synchronous,
395 Asynchronous,
397 #[serde(alias = "Wind", alias = "Solar", alias = "InverterOther")]
399 InverterBased,
400 Hybrid,
402 #[default]
404 Unknown,
405}
406
407#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
443pub enum CommitmentStatus {
444 #[default]
446 Market,
447 SelfCommitted,
449 MustRun,
451 Unavailable,
453 EmergencyOnly,
455}
456
457#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct FuelSupply {
460 pub fuel: String,
462 pub price_per_mmbtu: f64,
464 pub heat_rate_curve: Vec<(f64, f64)>,
466 pub daily_limit_mmbtu: Option<f64>,
468 pub min_take_mmbtu: Option<f64>,
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize)]
476pub struct CommitmentParams {
477 #[serde(default)]
479 pub status: CommitmentStatus,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub p_ecomin: Option<f64>,
483 #[serde(default, skip_serializing_if = "Option::is_none")]
485 pub p_ecomax: Option<f64>,
486 #[serde(default, skip_serializing_if = "Option::is_none")]
488 pub p_emergency_min: Option<f64>,
489 #[serde(default, skip_serializing_if = "Option::is_none")]
491 pub p_emergency_max: Option<f64>,
492 #[serde(default, skip_serializing_if = "Option::is_none")]
494 pub p_reg_min: Option<f64>,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub p_reg_max: Option<f64>,
498 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub min_up_time_hr: Option<f64>,
501 #[serde(default, skip_serializing_if = "Option::is_none")]
503 pub min_down_time_hr: Option<f64>,
504 #[serde(default, skip_serializing_if = "Option::is_none")]
506 pub max_up_time_hr: Option<f64>,
507 #[serde(default, skip_serializing_if = "Option::is_none")]
509 pub min_run_at_pmin_hr: Option<f64>,
510 #[serde(default, skip_serializing_if = "Option::is_none")]
512 pub max_starts_per_day: Option<u32>,
513 #[serde(default, skip_serializing_if = "Option::is_none")]
515 pub max_starts_per_week: Option<u32>,
516 #[serde(default, skip_serializing_if = "Option::is_none")]
519 pub max_energy_mwh_per_day: Option<f64>,
520 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub shutdown_ramp_mw_per_min: Option<f64>,
524 #[serde(default, skip_serializing_if = "Option::is_none")]
528 pub startup_ramp_mw_per_min: Option<f64>,
529 #[serde(default, skip_serializing_if = "Vec::is_empty")]
531 pub forbidden_zones: Vec<(f64, f64)>,
532 #[serde(default)]
534 pub hours_online: f64,
535 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
568pub struct RampingParams {
569 #[serde(default, skip_serializing_if = "Vec::is_empty")]
571 pub ramp_up_curve: Vec<(f64, f64)>,
572 #[serde(default, skip_serializing_if = "Vec::is_empty")]
574 pub ramp_down_curve: Vec<(f64, f64)>,
575 #[serde(default, skip_serializing_if = "Vec::is_empty")]
577 pub emergency_ramp_up_curve: Vec<(f64, f64)>,
578 #[serde(default, skip_serializing_if = "Vec::is_empty")]
580 pub emergency_ramp_down_curve: Vec<(f64, f64)>,
581 #[serde(default, skip_serializing_if = "Vec::is_empty")]
583 pub reg_ramp_up_curve: Vec<(f64, f64)>,
584 #[serde(default, skip_serializing_if = "Vec::is_empty")]
586 pub reg_ramp_down_curve: Vec<(f64, f64)>,
587}
588
589impl RampingParams {
590 #[inline]
593 pub fn ramp_up_mw_per_min(&self) -> Option<f64> {
594 self.ramp_up_curve.first().map(|&(_, rate)| rate)
595 }
596
597 #[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 #[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 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 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
674pub struct InverterParams {
675 #[serde(default, skip_serializing_if = "Option::is_none")]
677 pub s_rated_mva: Option<f64>,
678 #[serde(default, skip_serializing_if = "Option::is_none")]
680 pub p_available_mw: Option<f64>,
681 #[serde(default)]
683 pub curtailable: bool,
684 #[serde(default)]
686 pub grid_forming: bool,
687 #[serde(default)]
689 pub inverter_loss_a_mw: f64,
690 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
710pub struct GenFaultData {
711 #[serde(default, skip_serializing_if = "Option::is_none")]
713 pub xs: Option<f64>,
714 #[serde(default, skip_serializing_if = "Option::is_none")]
716 pub x2_pu: Option<f64>,
717 #[serde(default, skip_serializing_if = "Option::is_none")]
719 pub r2_pu: Option<f64>,
720 #[serde(default, skip_serializing_if = "Option::is_none")]
722 pub x0_pu: Option<f64>,
723 #[serde(default, skip_serializing_if = "Option::is_none")]
725 pub r0_pu: Option<f64>,
726 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub zn: Option<Complex64>,
729}
730
731#[derive(Debug, Clone, Default, Serialize, Deserialize)]
733pub struct ReactiveCapability {
734 #[serde(default, skip_serializing_if = "Vec::is_empty")]
736 pub pq_curve: Vec<(f64, f64, f64)>,
737 #[serde(default, skip_serializing_if = "Option::is_none")]
739 pub pc1: Option<f64>,
740 #[serde(default, skip_serializing_if = "Option::is_none")]
742 pub pc2: Option<f64>,
743 #[serde(default, skip_serializing_if = "Option::is_none")]
745 pub qc1min: Option<f64>,
746 #[serde(default, skip_serializing_if = "Option::is_none")]
748 pub qc1max: Option<f64>,
749 #[serde(default, skip_serializing_if = "Option::is_none")]
751 pub qc2min: Option<f64>,
752 #[serde(default, skip_serializing_if = "Option::is_none")]
754 pub qc2max: Option<f64>,
755 #[serde(default, skip_serializing_if = "Option::is_none")]
763 pub pq_linear_equality: Option<PqLinearLink>,
764 #[serde(default, skip_serializing_if = "Option::is_none")]
769 pub pq_linear_upper: Option<PqLinearLink>,
770 #[serde(default, skip_serializing_if = "Option::is_none")]
777 pub pq_linear_lower: Option<PqLinearLink>,
778}
779
780#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
790pub struct PqLinearLink {
791 pub q_at_p_zero_pu: f64,
793 pub beta: f64,
795}
796
797#[derive(Debug, Clone, Default, Serialize, Deserialize)]
799pub struct FuelParams {
800 #[serde(default, skip_serializing_if = "Option::is_none")]
802 pub fuel_type: Option<String>,
803 #[serde(default, skip_serializing_if = "Option::is_none")]
805 pub heat_rate_btu_mwh: Option<f64>,
806 #[serde(default, skip_serializing_if = "Option::is_none")]
808 pub primary_fuel: Option<FuelSupply>,
809 #[serde(default, skip_serializing_if = "Option::is_none")]
811 pub backup_fuel: Option<FuelSupply>,
812 #[serde(default)]
814 pub on_backup_fuel: bool,
815 #[serde(default, skip_serializing_if = "Option::is_none")]
817 pub fuel_switch_time_min: Option<f64>,
818 #[serde(default)]
820 pub emission_rates: EmissionRates,
821}
822
823#[derive(Debug, Clone, Default, Serialize, Deserialize)]
825pub struct MarketParams {
826 #[serde(default, skip_serializing_if = "Option::is_none")]
828 pub energy_offer: Option<EnergyOffer>,
829 #[serde(default, skip_serializing_if = "Vec::is_empty")]
831 pub reserve_offers: Vec<crate::market::reserve::ReserveOffer>,
832 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
834 pub qualifications: crate::market::reserve::QualificationMap,
835}
836
837#[derive(Debug, Clone, Serialize, Deserialize)]
848pub struct Generator {
849 #[serde(default = "default_empty_string")]
858 pub id: String,
859 pub bus: u32,
861 #[serde(default, skip_serializing_if = "Option::is_none")]
863 pub machine_id: Option<String>,
864 #[serde(alias = "pg")]
866 pub p: f64,
867 #[serde(alias = "qg")]
869 pub q: f64,
870 pub qmax: f64,
872 pub qmin: f64,
874 pub voltage_setpoint_pu: f64,
876 #[serde(default = "default_true")]
880 pub voltage_regulated: bool,
881 #[serde(default, skip_serializing_if = "Option::is_none")]
883 pub reg_bus: Option<u32>,
884 pub machine_base_mva: f64,
886 pub pmax: f64,
888 pub pmin: f64,
890 pub in_service: bool,
892 #[serde(default, skip_serializing_if = "Option::is_none")]
894 pub cost: Option<CostCurve>,
895 #[serde(default)]
897 pub gen_type: GenType,
898 #[serde(default, skip_serializing_if = "Option::is_none")]
900 pub technology: Option<GeneratorTechnology>,
901 #[serde(default, skip_serializing_if = "Option::is_none")]
903 pub source_technology_code: Option<String>,
904 #[serde(default, skip_serializing_if = "Option::is_none", alias = "apf")]
906 pub agc_participation_factor: Option<f64>,
907 #[serde(default, skip_serializing_if = "Option::is_none")]
909 pub h_inertia_s: Option<f64>,
910 #[serde(default = "default_true")]
912 pub pfr_eligible: bool,
913 #[serde(default)]
915 pub quick_start: bool,
916 #[serde(default, skip_serializing_if = "Option::is_none")]
918 pub forced_outage_rate: Option<f64>,
919 #[serde(default, skip_serializing_if = "Option::is_none")]
923 pub storage: Option<StorageParams>,
924 #[serde(default, skip_serializing_if = "Vec::is_empty")]
926 pub owners: Vec<super::owner::OwnershipEntry>,
927
928 #[serde(default, skip_serializing_if = "Option::is_none")]
931 pub commitment: Option<CommitmentParams>,
932 #[serde(default, skip_serializing_if = "Option::is_none")]
934 pub ramping: Option<RampingParams>,
935 #[serde(default, skip_serializing_if = "Option::is_none")]
937 pub inverter: Option<InverterParams>,
938 #[serde(default, skip_serializing_if = "Option::is_none")]
940 pub fault_data: Option<GenFaultData>,
941 #[serde(default, skip_serializing_if = "Option::is_none")]
943 pub reactive_capability: Option<ReactiveCapability>,
944 #[serde(default, skip_serializing_if = "Option::is_none")]
946 pub fuel: Option<FuelParams>,
947 #[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 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 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 #[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 #[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 #[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 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 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 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 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 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 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 #[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 #[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 #[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 #[inline]
1122 pub fn is_storage(&self) -> bool {
1123 self.storage.is_some()
1124 }
1125
1126 #[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 #[inline]
1138 pub fn discharge_mw_max(&self) -> f64 {
1139 self.pmax
1140 }
1141
1142 #[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 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 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 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 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 #[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 #[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
1221fn 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 for i in (0..curve.len()).rev() {
1238 if p_mw >= curve[i].0 {
1239 return Some(curve[i].1);
1240 }
1241 }
1242 Some(curve[0].1)
1244}
1245
1246fn 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 return ramp_rate_at_mw(curve, lo_mw);
1256 }
1257 if curve.len() == 1 {
1258 return Some(curve[0].1);
1259 }
1260
1261 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 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
1283pub 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 #[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 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)); assert_eq!(ramp_rate_at_mw(&curve, 200.0), Some(8.0)); assert_eq!(ramp_rate_at_mw(&curve, 300.0), Some(8.0)); assert_eq!(ramp_rate_at_mw(&curve, 350.0), Some(12.0)); assert_eq!(ramp_rate_at_mw(&curve, 400.0), Some(12.0)); assert_eq!(ramp_rate_at_mw(&curve, 500.0), Some(10.0)); assert_eq!(ramp_rate_at_mw(&curve, 600.0), Some(10.0)); }
1344
1345 #[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 let curve = vec![(200.0, 8.0), (350.0, 12.0)];
1362 let avg = ramp_rate_avg(&curve, 200.0, 500.0).unwrap();
1363 assert!((avg - 10.0).abs() < 1e-10);
1365 }
1366
1367 #[test]
1368 fn test_ramp_avg_partial_overlap() {
1369 let curve = vec![(100.0, 5.0), (300.0, 15.0)];
1372 let avg = ramp_rate_avg(&curve, 200.0, 400.0).unwrap();
1373 assert!((avg - 10.0).abs() < 1e-10);
1376 }
1377
1378 #[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 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 #[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 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 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 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 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 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 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 assert!((g.startup_ramp_mw_per_period(0.5) - 90.0).abs() < 1e-10);
1568 }
1569}