Skip to main content

powerio_tx/
gen_cost.rs

1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4
5use crate::network::{BalancedNetwork, BusId, GenCost};
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 [`BalancedNetwork::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 BalancedNetwork {
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
117            .generators()
118            .iter()
119            .filter(|g| g.cost.is_none())
120            .count();
121        let missing_in_service_before = self
122            .generators()
123            .iter()
124            .filter(|g| g.in_service && g.cost.is_none())
125            .count();
126
127        let mut synthesized = 0usize;
128        match policy {
129            MissingGenCostPolicy::Preserve => {}
130            MissingGenCostPolicy::Require => {
131                if let Some((idx, _)) = self
132                    .generators()
133                    .iter()
134                    .enumerate()
135                    .find(|(_, g)| g.in_service && g.cost.is_none())
136                {
137                    return Err(Error::MissingGenCost { gen_index: idx });
138                }
139            }
140            MissingGenCostPolicy::Fill {
141                c2,
142                c1,
143                c0,
144                startup,
145                shutdown,
146            } => {
147                let cost = MissingGenCostPolicy::fill_cost(c2, c1, c0, startup, shutdown)?;
148                for generator in self.generators_mut() {
149                    if generator.cost.is_none() {
150                        generator.cost = Some(cost.clone());
151                        synthesized += 1;
152                    }
153                }
154            }
155        }
156
157        Ok(GenCostPolicyReport {
158            missing_before,
159            missing_in_service_before,
160            patched,
161            synthesized,
162        })
163    }
164
165    fn apply_gen_cost_patches(&mut self, patches: &[GenCostPatch]) -> Result<usize> {
166        let mut seen = BTreeSet::new();
167        for (row, patch) in patches.iter().enumerate() {
168            let row = row + 1;
169            if !seen.insert(patch.gen_index) {
170                return Err(Error::InvalidGenCostPatch {
171                    row,
172                    reason: format!("duplicate gen_index {}", patch.gen_index),
173                });
174            }
175            let Some(generator) = self.generators_mut().get_mut(patch.gen_index) else {
176                return Err(Error::InvalidGenCostPatch {
177                    row,
178                    reason: format!(
179                        "gen_index {} out of range for {} generator(s)",
180                        patch.gen_index,
181                        self.generators().len()
182                    ),
183                });
184            };
185            if generator.bus != patch.bus {
186                return Err(Error::InvalidGenCostPatch {
187                    row,
188                    reason: format!(
189                        "bus mismatch for gen_index {}: table has {}, network has {}",
190                        patch.gen_index, patch.bus, generator.bus
191                    ),
192                });
193            }
194            validate_cost(&patch.cost, row)?;
195            generator.cost = Some(patch.cost.clone());
196        }
197        Ok(patches.len())
198    }
199}
200
201/// Parse a simple generator cost CSV with required columns
202/// `gen_index,bus,c2,c1,c0` and optional `startup,shutdown`.
203///
204/// The parser accepts plain comma separated fields with a header row. Quoted CSV
205/// dialect features are intentionally not implemented; this table is numeric.
206pub fn parse_gen_cost_csv(content: &str) -> Result<Vec<GenCostPatch>> {
207    let mut lines = content
208        .lines()
209        .enumerate()
210        .filter(|(_, line)| !line.trim().is_empty());
211    let Some((_, header)) = lines.next() else {
212        return Err(Error::InvalidGenCostPatch {
213            row: 0,
214            reason: "empty generator cost CSV".into(),
215        });
216    };
217    let header = split_csv_line(header);
218    let col = |name: &'static str| {
219        header
220            .iter()
221            .position(|h| h == name)
222            .ok_or_else(|| Error::InvalidGenCostPatch {
223                row: 0,
224                reason: format!("missing required column `{name}`"),
225            })
226    };
227    let gen_index_col = col("gen_index")?;
228    let bus_col = col("bus")?;
229    let c2_col = col("c2")?;
230    let c1_col = col("c1")?;
231    let c0_col = col("c0")?;
232    let startup_col = header.iter().position(|h| h == "startup");
233    let shutdown_col = header.iter().position(|h| h == "shutdown");
234
235    let mut out = Vec::new();
236    for (line_no, line) in lines {
237        let row = line_no + 1;
238        let fields = split_csv_line(line);
239        let get = |idx: usize, name: &'static str| {
240            fields
241                .get(idx)
242                .filter(|s| !s.is_empty())
243                .ok_or_else(|| Error::InvalidGenCostPatch {
244                    row,
245                    reason: format!("missing value for `{name}`"),
246                })
247        };
248        let gen_index = parse_usize(get(gen_index_col, "gen_index")?, row, "gen_index")?;
249        let bus = BusId(parse_usize(get(bus_col, "bus")?, row, "bus")?);
250        let c2 = parse_f64(get(c2_col, "c2")?, row, "c2")?;
251        let c1 = parse_f64(get(c1_col, "c1")?, row, "c1")?;
252        let c0 = parse_f64(get(c0_col, "c0")?, row, "c0")?;
253        let startup = match startup_col {
254            Some(idx) => fields
255                .get(idx)
256                .filter(|s| !s.is_empty())
257                .map_or(Ok(0.0), |s| parse_f64(s, row, "startup"))?,
258            None => 0.0,
259        };
260        let shutdown = match shutdown_col {
261            Some(idx) => fields
262                .get(idx)
263                .filter(|s| !s.is_empty())
264                .map_or(Ok(0.0), |s| parse_f64(s, row, "shutdown"))?,
265            None => 0.0,
266        };
267        out.push(GenCostPatch {
268            gen_index,
269            bus,
270            cost: GenCost {
271                model: 2,
272                startup,
273                shutdown,
274                ncost: 3,
275                coeffs: vec![c2, c1, c0],
276            },
277        });
278    }
279    Ok(out)
280}
281
282fn split_csv_line(line: &str) -> Vec<String> {
283    line.split(',')
284        .map(|s| s.trim().trim_matches('"').to_string())
285        .collect()
286}
287
288fn parse_usize(value: &str, row: usize, field: &'static str) -> Result<usize> {
289    value
290        .parse::<usize>()
291        .map_err(|_| Error::InvalidGenCostPatch {
292            row,
293            reason: format!("`{field}` is not a non-negative integer: {value}"),
294        })
295}
296
297fn parse_f64(value: &str, row: usize, field: &'static str) -> Result<f64> {
298    let parsed = value
299        .parse::<f64>()
300        .map_err(|_| Error::InvalidGenCostPatch {
301            row,
302            reason: format!("`{field}` is not a number: {value}"),
303        })?;
304    if parsed.is_finite() {
305        Ok(parsed)
306    } else {
307        Err(Error::InvalidGenCostPatch {
308            row,
309            reason: format!("`{field}` is not finite: {parsed}"),
310        })
311    }
312}
313
314fn validate_cost(cost: &GenCost, row: usize) -> Result<()> {
315    for (field, value) in [("startup", cost.startup), ("shutdown", cost.shutdown)] {
316        if !value.is_finite() {
317            return Err(Error::InvalidGenCostPatch {
318                row,
319                reason: format!("`{field}` is not finite: {value}"),
320            });
321        }
322    }
323    for (idx, value) in cost.coeffs.iter().enumerate() {
324        if !value.is_finite() {
325            return Err(Error::InvalidGenCostPatch {
326                row,
327                reason: format!("cost coefficient {idx} is not finite: {value}"),
328            });
329        }
330    }
331    Ok(())
332}