Skip to main content

powerio/
gen_cost.rs

1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4
5use crate::network::{BusId, GenCost, Network};
6use crate::{Error, Result};
7
8/// Policy for generators whose source format has no active-power cost row.
9#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
10#[serde(tag = "mode", rename_all = "snake_case")]
11pub enum MissingGenCostPolicy {
12    /// Leave missing costs absent.
13    #[default]
14    Preserve,
15    /// Error when an in-service generator has no cost row.
16    Require,
17    /// Fill missing costs with a MATPOWER polynomial row.
18    Fill {
19        c2: f64,
20        c1: f64,
21        c0: f64,
22        startup: f64,
23        shutdown: f64,
24    },
25}
26
27impl MissingGenCostPolicy {
28    #[must_use]
29    pub fn zero() -> Self {
30        Self::Fill {
31            c2: 0.0,
32            c1: 0.0,
33            c0: 0.0,
34            startup: 0.0,
35            shutdown: 0.0,
36        }
37    }
38
39    #[must_use]
40    pub fn quadratic(c2: f64, c1: f64, c0: f64) -> Self {
41        Self::Fill {
42            c2,
43            c1,
44            c0,
45            startup: 0.0,
46            shutdown: 0.0,
47        }
48    }
49
50    #[must_use]
51    pub fn is_preserve(self) -> bool {
52        matches!(self, Self::Preserve)
53    }
54
55    #[must_use]
56    pub fn label(self) -> &'static str {
57        match self {
58            Self::Preserve => "preserve",
59            Self::Require => "require",
60            Self::Fill { .. } => "fill",
61        }
62    }
63
64    fn fill_cost(c2: f64, c1: f64, c0: f64, startup: f64, shutdown: f64) -> Result<GenCost> {
65        for (field, value) in [
66            ("c2", c2),
67            ("c1", c1),
68            ("c0", c0),
69            ("startup", startup),
70            ("shutdown", shutdown),
71        ] {
72            if !value.is_finite() {
73                return Err(Error::NonFiniteGenCost { field, value });
74            }
75        }
76        Ok(GenCost {
77            model: 2,
78            startup,
79            shutdown,
80            ncost: 3,
81            coeffs: vec![c2, c1, c0],
82        })
83    }
84}
85
86/// One explicit generator cost patch from a user supplied table.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct GenCostPatch {
89    /// Zero based index into [`Network::generators`].
90    pub gen_index: usize,
91    /// Bus id expected on that generator, used to catch stale patch tables.
92    pub bus: BusId,
93    pub cost: GenCost,
94}
95
96/// Counts produced by applying user cost patches and a missing-cost policy.
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
98pub struct GenCostPolicyReport {
99    pub missing_before: usize,
100    pub missing_in_service_before: usize,
101    pub patched: usize,
102    pub synthesized: usize,
103}
104
105impl Network {
106    /// Apply explicit cost patches, then a missing-cost policy.
107    ///
108    /// Patches replace the existing cost for the named generator. The missing-cost
109    /// fill policy only touches generators still missing a cost after patching.
110    pub fn apply_gen_cost_policy(
111        &mut self,
112        patches: &[GenCostPatch],
113        policy: MissingGenCostPolicy,
114    ) -> Result<GenCostPolicyReport> {
115        let patched = self.apply_gen_cost_patches(patches)?;
116        let missing_before = self.generators.iter().filter(|g| g.cost.is_none()).count();
117        let missing_in_service_before = self
118            .generators
119            .iter()
120            .filter(|g| g.in_service && g.cost.is_none())
121            .count();
122
123        let mut synthesized = 0usize;
124        match policy {
125            MissingGenCostPolicy::Preserve => {}
126            MissingGenCostPolicy::Require => {
127                if let Some((idx, _)) = self
128                    .generators
129                    .iter()
130                    .enumerate()
131                    .find(|(_, g)| g.in_service && g.cost.is_none())
132                {
133                    return Err(Error::MissingGenCost { gen_index: idx });
134                }
135            }
136            MissingGenCostPolicy::Fill {
137                c2,
138                c1,
139                c0,
140                startup,
141                shutdown,
142            } => {
143                let cost = MissingGenCostPolicy::fill_cost(c2, c1, c0, startup, shutdown)?;
144                for generator in &mut self.generators {
145                    if generator.cost.is_none() {
146                        generator.cost = Some(cost.clone());
147                        synthesized += 1;
148                    }
149                }
150            }
151        }
152
153        Ok(GenCostPolicyReport {
154            missing_before,
155            missing_in_service_before,
156            patched,
157            synthesized,
158        })
159    }
160
161    fn apply_gen_cost_patches(&mut self, patches: &[GenCostPatch]) -> Result<usize> {
162        let mut seen = BTreeSet::new();
163        for (row, patch) in patches.iter().enumerate() {
164            let row = row + 1;
165            if !seen.insert(patch.gen_index) {
166                return Err(Error::InvalidGenCostPatch {
167                    row,
168                    reason: format!("duplicate gen_index {}", patch.gen_index),
169                });
170            }
171            let Some(generator) = self.generators.get_mut(patch.gen_index) else {
172                return Err(Error::InvalidGenCostPatch {
173                    row,
174                    reason: format!(
175                        "gen_index {} out of range for {} generator(s)",
176                        patch.gen_index,
177                        self.generators.len()
178                    ),
179                });
180            };
181            if generator.bus != patch.bus {
182                return Err(Error::InvalidGenCostPatch {
183                    row,
184                    reason: format!(
185                        "bus mismatch for gen_index {}: table has {}, network has {}",
186                        patch.gen_index, patch.bus, generator.bus
187                    ),
188                });
189            }
190            validate_cost(&patch.cost, row)?;
191            generator.cost = Some(patch.cost.clone());
192        }
193        Ok(patches.len())
194    }
195}
196
197/// Parse a simple generator cost CSV with required columns
198/// `gen_index,bus,c2,c1,c0` and optional `startup,shutdown`.
199///
200/// The parser accepts plain comma separated fields with a header row. Quoted CSV
201/// dialect features are intentionally not implemented; this table is numeric.
202pub fn parse_gen_cost_csv(content: &str) -> Result<Vec<GenCostPatch>> {
203    let mut lines = content
204        .lines()
205        .enumerate()
206        .filter(|(_, line)| !line.trim().is_empty());
207    let Some((_, header)) = lines.next() else {
208        return Err(Error::InvalidGenCostPatch {
209            row: 0,
210            reason: "empty generator cost CSV".into(),
211        });
212    };
213    let header = split_csv_line(header);
214    let col = |name: &'static str| {
215        header
216            .iter()
217            .position(|h| h == name)
218            .ok_or_else(|| Error::InvalidGenCostPatch {
219                row: 0,
220                reason: format!("missing required column `{name}`"),
221            })
222    };
223    let gen_index_col = col("gen_index")?;
224    let bus_col = col("bus")?;
225    let c2_col = col("c2")?;
226    let c1_col = col("c1")?;
227    let c0_col = col("c0")?;
228    let startup_col = header.iter().position(|h| h == "startup");
229    let shutdown_col = header.iter().position(|h| h == "shutdown");
230
231    let mut out = Vec::new();
232    for (line_no, line) in lines {
233        let row = line_no + 1;
234        let fields = split_csv_line(line);
235        let get = |idx: usize, name: &'static str| {
236            fields
237                .get(idx)
238                .filter(|s| !s.is_empty())
239                .ok_or_else(|| Error::InvalidGenCostPatch {
240                    row,
241                    reason: format!("missing value for `{name}`"),
242                })
243        };
244        let gen_index = parse_usize(get(gen_index_col, "gen_index")?, row, "gen_index")?;
245        let bus = BusId(parse_usize(get(bus_col, "bus")?, row, "bus")?);
246        let c2 = parse_f64(get(c2_col, "c2")?, row, "c2")?;
247        let c1 = parse_f64(get(c1_col, "c1")?, row, "c1")?;
248        let c0 = parse_f64(get(c0_col, "c0")?, row, "c0")?;
249        let startup = match startup_col {
250            Some(idx) => fields
251                .get(idx)
252                .filter(|s| !s.is_empty())
253                .map_or(Ok(0.0), |s| parse_f64(s, row, "startup"))?,
254            None => 0.0,
255        };
256        let shutdown = match shutdown_col {
257            Some(idx) => fields
258                .get(idx)
259                .filter(|s| !s.is_empty())
260                .map_or(Ok(0.0), |s| parse_f64(s, row, "shutdown"))?,
261            None => 0.0,
262        };
263        out.push(GenCostPatch {
264            gen_index,
265            bus,
266            cost: GenCost {
267                model: 2,
268                startup,
269                shutdown,
270                ncost: 3,
271                coeffs: vec![c2, c1, c0],
272            },
273        });
274    }
275    Ok(out)
276}
277
278fn split_csv_line(line: &str) -> Vec<String> {
279    line.split(',')
280        .map(|s| s.trim().trim_matches('"').to_string())
281        .collect()
282}
283
284fn parse_usize(value: &str, row: usize, field: &'static str) -> Result<usize> {
285    value
286        .parse::<usize>()
287        .map_err(|_| Error::InvalidGenCostPatch {
288            row,
289            reason: format!("`{field}` is not a non-negative integer: {value}"),
290        })
291}
292
293fn parse_f64(value: &str, row: usize, field: &'static str) -> Result<f64> {
294    let parsed = value
295        .parse::<f64>()
296        .map_err(|_| Error::InvalidGenCostPatch {
297            row,
298            reason: format!("`{field}` is not a number: {value}"),
299        })?;
300    if parsed.is_finite() {
301        Ok(parsed)
302    } else {
303        Err(Error::InvalidGenCostPatch {
304            row,
305            reason: format!("`{field}` is not finite: {parsed}"),
306        })
307    }
308}
309
310fn validate_cost(cost: &GenCost, row: usize) -> Result<()> {
311    for (field, value) in [("startup", cost.startup), ("shutdown", cost.shutdown)] {
312        if !value.is_finite() {
313            return Err(Error::InvalidGenCostPatch {
314                row,
315                reason: format!("`{field}` is not finite: {value}"),
316            });
317        }
318    }
319    for (idx, value) in cost.coeffs.iter().enumerate() {
320        if !value.is_finite() {
321            return Err(Error::InvalidGenCostPatch {
322                row,
323                reason: format!("cost coefficient {idx} is not finite: {value}"),
324            });
325        }
326    }
327    Ok(())
328}