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 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub daily_cycle_limit: Option<f64>,
117}
118
119#[derive(Deserialize)]
124struct StorageParamsWire {
125 #[serde(default)]
126 charge_efficiency: Option<f64>,
127 #[serde(default)]
128 discharge_efficiency: Option<f64>,
129 #[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#[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 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 pub fn round_trip_efficiency(&self) -> f64 {
271 self.charge_efficiency * self.discharge_efficiency
272 }
273
274 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
413pub enum GenType {
414 Synchronous,
416 Asynchronous,
418 #[serde(alias = "Wind", alias = "Solar", alias = "InverterOther")]
420 InverterBased,
421 Hybrid,
423 #[default]
425 Unknown,
426}
427
428#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
464pub enum CommitmentStatus {
465 #[default]
467 Market,
468 SelfCommitted,
470 MustRun,
472 Unavailable,
474 EmergencyOnly,
476}
477
478#[derive(Debug, Clone, Serialize, Deserialize)]
480pub struct FuelSupply {
481 pub fuel: String,
483 pub price_per_mmbtu: f64,
485 pub heat_rate_curve: Vec<(f64, f64)>,
487 pub daily_limit_mmbtu: Option<f64>,
489 pub min_take_mmbtu: Option<f64>,
491}
492
493#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct CommitmentParams {
498 #[serde(default)]
500 pub status: CommitmentStatus,
501 #[serde(default, skip_serializing_if = "Option::is_none")]
503 pub p_ecomin: Option<f64>,
504 #[serde(default, skip_serializing_if = "Option::is_none")]
506 pub p_ecomax: Option<f64>,
507 #[serde(default, skip_serializing_if = "Option::is_none")]
509 pub p_emergency_min: Option<f64>,
510 #[serde(default, skip_serializing_if = "Option::is_none")]
512 pub p_emergency_max: Option<f64>,
513 #[serde(default, skip_serializing_if = "Option::is_none")]
515 pub p_reg_min: Option<f64>,
516 #[serde(default, skip_serializing_if = "Option::is_none")]
518 pub p_reg_max: Option<f64>,
519 #[serde(default, skip_serializing_if = "Option::is_none")]
521 pub min_up_time_hr: Option<f64>,
522 #[serde(default, skip_serializing_if = "Option::is_none")]
524 pub min_down_time_hr: Option<f64>,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub max_up_time_hr: Option<f64>,
528 #[serde(default, skip_serializing_if = "Option::is_none")]
530 pub min_run_at_pmin_hr: Option<f64>,
531 #[serde(default, skip_serializing_if = "Option::is_none")]
533 pub max_starts_per_day: Option<u32>,
534 #[serde(default, skip_serializing_if = "Option::is_none")]
536 pub max_starts_per_week: Option<u32>,
537 #[serde(default, skip_serializing_if = "Option::is_none")]
540 pub max_energy_mwh_per_day: Option<f64>,
541 #[serde(default, skip_serializing_if = "Option::is_none")]
544 pub shutdown_ramp_mw_per_min: Option<f64>,
545 #[serde(default, skip_serializing_if = "Option::is_none")]
549 pub startup_ramp_mw_per_min: Option<f64>,
550 #[serde(default, skip_serializing_if = "Vec::is_empty")]
552 pub forbidden_zones: Vec<(f64, f64)>,
553 #[serde(default)]
555 pub hours_online: f64,
556 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
589pub struct RampingParams {
590 #[serde(default, skip_serializing_if = "Vec::is_empty")]
592 pub ramp_up_curve: Vec<(f64, f64)>,
593 #[serde(default, skip_serializing_if = "Vec::is_empty")]
595 pub ramp_down_curve: Vec<(f64, f64)>,
596 #[serde(default, skip_serializing_if = "Vec::is_empty")]
598 pub emergency_ramp_up_curve: Vec<(f64, f64)>,
599 #[serde(default, skip_serializing_if = "Vec::is_empty")]
601 pub emergency_ramp_down_curve: Vec<(f64, f64)>,
602 #[serde(default, skip_serializing_if = "Vec::is_empty")]
604 pub reg_ramp_up_curve: Vec<(f64, f64)>,
605 #[serde(default, skip_serializing_if = "Vec::is_empty")]
607 pub reg_ramp_down_curve: Vec<(f64, f64)>,
608}
609
610impl RampingParams {
611 #[inline]
614 pub fn ramp_up_mw_per_min(&self) -> Option<f64> {
615 self.ramp_up_curve.first().map(|&(_, rate)| rate)
616 }
617
618 #[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 #[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 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 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
695pub struct InverterParams {
696 #[serde(default, skip_serializing_if = "Option::is_none")]
698 pub s_rated_mva: Option<f64>,
699 #[serde(default, skip_serializing_if = "Option::is_none")]
701 pub p_available_mw: Option<f64>,
702 #[serde(default)]
704 pub curtailable: bool,
705 #[serde(default)]
707 pub grid_forming: bool,
708 #[serde(default)]
710 pub inverter_loss_a_mw: f64,
711 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
731pub struct GenFaultData {
732 #[serde(default, skip_serializing_if = "Option::is_none")]
734 pub xs: Option<f64>,
735 #[serde(default, skip_serializing_if = "Option::is_none")]
737 pub x2_pu: Option<f64>,
738 #[serde(default, skip_serializing_if = "Option::is_none")]
740 pub r2_pu: Option<f64>,
741 #[serde(default, skip_serializing_if = "Option::is_none")]
743 pub x0_pu: Option<f64>,
744 #[serde(default, skip_serializing_if = "Option::is_none")]
746 pub r0_pu: Option<f64>,
747 #[serde(default, skip_serializing_if = "Option::is_none")]
749 pub zn: Option<Complex64>,
750}
751
752#[derive(Debug, Clone, Default, Serialize, Deserialize)]
754pub struct ReactiveCapability {
755 #[serde(default, skip_serializing_if = "Vec::is_empty")]
757 pub pq_curve: Vec<(f64, f64, f64)>,
758 #[serde(default, skip_serializing_if = "Option::is_none")]
760 pub pc1: Option<f64>,
761 #[serde(default, skip_serializing_if = "Option::is_none")]
763 pub pc2: Option<f64>,
764 #[serde(default, skip_serializing_if = "Option::is_none")]
766 pub qc1min: Option<f64>,
767 #[serde(default, skip_serializing_if = "Option::is_none")]
769 pub qc1max: Option<f64>,
770 #[serde(default, skip_serializing_if = "Option::is_none")]
772 pub qc2min: Option<f64>,
773 #[serde(default, skip_serializing_if = "Option::is_none")]
775 pub qc2max: Option<f64>,
776 #[serde(default, skip_serializing_if = "Option::is_none")]
784 pub pq_linear_equality: Option<PqLinearLink>,
785 #[serde(default, skip_serializing_if = "Option::is_none")]
790 pub pq_linear_upper: Option<PqLinearLink>,
791 #[serde(default, skip_serializing_if = "Option::is_none")]
798 pub pq_linear_lower: Option<PqLinearLink>,
799}
800
801#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
811pub struct PqLinearLink {
812 pub q_at_p_zero_pu: f64,
814 pub beta: f64,
816}
817
818#[derive(Debug, Clone, Default, Serialize, Deserialize)]
820pub struct FuelParams {
821 #[serde(default, skip_serializing_if = "Option::is_none")]
823 pub fuel_type: Option<String>,
824 #[serde(default, skip_serializing_if = "Option::is_none")]
826 pub heat_rate_btu_mwh: Option<f64>,
827 #[serde(default, skip_serializing_if = "Option::is_none")]
829 pub primary_fuel: Option<FuelSupply>,
830 #[serde(default, skip_serializing_if = "Option::is_none")]
832 pub backup_fuel: Option<FuelSupply>,
833 #[serde(default)]
835 pub on_backup_fuel: bool,
836 #[serde(default, skip_serializing_if = "Option::is_none")]
838 pub fuel_switch_time_min: Option<f64>,
839 #[serde(default)]
841 pub emission_rates: EmissionRates,
842}
843
844#[derive(Debug, Clone, Default, Serialize, Deserialize)]
846pub struct MarketParams {
847 #[serde(default, skip_serializing_if = "Option::is_none")]
849 pub energy_offer: Option<EnergyOffer>,
850 #[serde(default, skip_serializing_if = "Vec::is_empty")]
852 pub reserve_offers: Vec<crate::market::reserve::ReserveOffer>,
853 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
855 pub qualifications: crate::market::reserve::QualificationMap,
856}
857
858#[derive(Debug, Clone, Serialize, Deserialize)]
869pub struct Generator {
870 #[serde(default = "default_empty_string")]
879 pub id: String,
880 pub bus: u32,
882 #[serde(default, skip_serializing_if = "Option::is_none")]
884 pub machine_id: Option<String>,
885 #[serde(alias = "pg")]
887 pub p: f64,
888 #[serde(alias = "qg")]
890 pub q: f64,
891 pub qmax: f64,
893 pub qmin: f64,
895 pub voltage_setpoint_pu: f64,
897 #[serde(default = "default_true")]
901 pub voltage_regulated: bool,
902 #[serde(default, skip_serializing_if = "Option::is_none")]
904 pub reg_bus: Option<u32>,
905 pub machine_base_mva: f64,
907 pub pmax: f64,
909 pub pmin: f64,
911 pub in_service: bool,
913 #[serde(default, skip_serializing_if = "Option::is_none")]
915 pub cost: Option<CostCurve>,
916 #[serde(default)]
918 pub gen_type: GenType,
919 #[serde(default, skip_serializing_if = "Option::is_none")]
921 pub technology: Option<GeneratorTechnology>,
922 #[serde(default, skip_serializing_if = "Option::is_none")]
924 pub source_technology_code: Option<String>,
925 #[serde(default, skip_serializing_if = "Option::is_none", alias = "apf")]
927 pub agc_participation_factor: Option<f64>,
928 #[serde(default, skip_serializing_if = "Option::is_none")]
930 pub h_inertia_s: Option<f64>,
931 #[serde(default = "default_true")]
933 pub pfr_eligible: bool,
934 #[serde(default)]
936 pub quick_start: bool,
937 #[serde(default, skip_serializing_if = "Option::is_none")]
939 pub forced_outage_rate: Option<f64>,
940 #[serde(default, skip_serializing_if = "Option::is_none")]
944 pub storage: Option<StorageParams>,
945 #[serde(default, skip_serializing_if = "Vec::is_empty")]
947 pub owners: Vec<super::owner::OwnershipEntry>,
948
949 #[serde(default, skip_serializing_if = "Option::is_none")]
952 pub commitment: Option<CommitmentParams>,
953 #[serde(default, skip_serializing_if = "Option::is_none")]
955 pub ramping: Option<RampingParams>,
956 #[serde(default, skip_serializing_if = "Option::is_none")]
958 pub inverter: Option<InverterParams>,
959 #[serde(default, skip_serializing_if = "Option::is_none")]
961 pub fault_data: Option<GenFaultData>,
962 #[serde(default, skip_serializing_if = "Option::is_none")]
964 pub reactive_capability: Option<ReactiveCapability>,
965 #[serde(default, skip_serializing_if = "Option::is_none")]
967 pub fuel: Option<FuelParams>,
968 #[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 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 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 #[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 #[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 #[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 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 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 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 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 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 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 #[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 #[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 #[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 #[inline]
1143 pub fn is_storage(&self) -> bool {
1144 self.storage.is_some()
1145 }
1146
1147 #[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 #[inline]
1159 pub fn discharge_mw_max(&self) -> f64 {
1160 self.pmax
1161 }
1162
1163 #[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 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 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 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 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 #[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 #[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
1242fn 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 for i in (0..curve.len()).rev() {
1259 if p_mw >= curve[i].0 {
1260 return Some(curve[i].1);
1261 }
1262 }
1263 Some(curve[0].1)
1265}
1266
1267fn 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 return ramp_rate_at_mw(curve, lo_mw);
1277 }
1278 if curve.len() == 1 {
1279 return Some(curve[0].1);
1280 }
1281
1282 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 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
1304pub 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 #[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 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)); 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)); }
1365
1366 #[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 let curve = vec![(200.0, 8.0), (350.0, 12.0)];
1383 let avg = ramp_rate_avg(&curve, 200.0, 500.0).unwrap();
1384 assert!((avg - 10.0).abs() < 1e-10);
1386 }
1387
1388 #[test]
1389 fn test_ramp_avg_partial_overlap() {
1390 let curve = vec![(100.0, 5.0), (300.0, 15.0)];
1393 let avg = ramp_rate_avg(&curve, 200.0, 400.0).unwrap();
1394 assert!((avg - 10.0).abs() < 1e-10);
1397 }
1398
1399 #[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 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 #[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 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 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 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 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 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 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 assert!((g.startup_ramp_mw_per_period(0.5) - 90.0).abs() < 1e-10);
1589 }
1590}