Skip to main content

powerio/format/matpower/
mod.rs

1//! MATPOWER `.m` case file parser. Standard MATPOWER 7.x format.
2
3mod locate;
4mod matlab;
5mod rows;
6mod tokens;
7mod writer;
8
9#[cfg(test)]
10mod tests;
11
12use std::path::Path;
13use std::sync::Arc;
14
15pub use writer::write_matpower;
16pub(crate) use writer::write_matpower_conversion;
17
18use crate::network::{Generator, Network, SourceFormat};
19use crate::{Error, Result};
20
21/// Parse the MATPOWER case in `content` into a [`Network`].
22pub fn parse_matpower(content: &str) -> Result<Network> {
23    // The caller owns `content` as a borrow, so retention needs one copy.
24    parse_matpower_source(Arc::new(content.to_owned()), None)
25}
26
27/// Parse the MATPOWER case at `path`, using the file stem as the network name.
28pub fn parse_matpower_file(path: impl AsRef<Path>) -> Result<Network> {
29    let path = path.as_ref();
30    let content = std::fs::read_to_string(path)?;
31    let name = path
32        .file_stem()
33        .and_then(|s| s.to_str())
34        .unwrap_or("case")
35        .to_string();
36    // We own the file buffer; move it straight into the retained source — no
37    // second copy of the whole file.
38    parse_matpower_named(Arc::new(content), &name)
39}
40
41/// Owned-source entry used by the format hub: move the buffer straight into the
42/// retained source (no copy) and take `name_hint` (e.g. the file stem) as the
43/// network name.
44pub(crate) fn parse_matpower_source(
45    source: Arc<String>,
46    name_hint: Option<&str>,
47) -> Result<Network> {
48    let name = name_hint
49        .map(str::to_owned)
50        .or_else(|| matpower_function_name(&source).map(str::to_owned))
51        .unwrap_or_else(|| "case".to_string());
52    parse_matpower_named(source, &name)
53}
54
55fn matpower_function_name(source: &str) -> Option<&str> {
56    for line in source.lines() {
57        let line = line.trim_start();
58        if !line.starts_with("function") {
59            continue;
60        }
61        let Some((_, rhs)) = line.split_once('=') else {
62            continue;
63        };
64        let rhs = rhs.trim_start();
65        let end = rhs
66            .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
67            .unwrap_or(rhs.len());
68        let starts_ident = rhs
69            .as_bytes()
70            .first()
71            .is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_');
72        if end > 0 && starts_ident {
73            return Some(&rhs[..end]);
74        }
75    }
76    None
77}
78
79fn parse_matpower_named(source: Arc<String>, name: &str) -> Result<Network> {
80    // Locate each assignment's text directly in `source` and build the network
81    // from those borrowed slices in one pass; the typed model owns its data, so
82    // the borrows end with `located` and the source Arc moves into the network.
83    let mut net = {
84        let located = locate::locate_assignments(&source);
85        build_case(name, |field| {
86            located
87                .iter()
88                .find(|(f, _)| *f == field)
89                .map(|(_, full)| *full)
90        })?
91    };
92    net.source = Some(source);
93    // The other format readers validate references; the MATPOWER path must too,
94    // or a duplicate or dangling bus id reaches `IndexedNetwork` as silently
95    // collapsed aggregates (the dense bus-id map only debug-asserts uniqueness).
96    net.check_references("MATPOWER")?;
97    Ok(net)
98}
99
100/// Build a [`Network`] from a per-field assignment-text accessor `get`, which
101/// returns the raw `mpc.<field> = …;` text for a field name. MATPOWER folds
102/// demand and shunts onto the bus row; [`rows::bus_row`] splits them back out
103/// into the hub's first-class [`Load`](crate::network::Load) /
104/// [`Shunt`](crate::network::Shunt). The caller attaches the source afterward.
105fn build_case<'a>(name: &str, get: impl Fn(&str) -> Option<&'a str>) -> Result<Network> {
106    let base_mva = get("baseMVA")
107        .and_then(|raw| matlab::scalar_from_assignment(raw, "baseMVA").transpose())
108        .transpose()?
109        .ok_or(Error::MissingField("baseMVA"))?;
110
111    let bus_raw = get("bus").ok_or(Error::MissingField("bus"))?;
112    let n_bus = estimate_rows(bus_raw);
113    let mut buses = Vec::with_capacity(n_bus);
114    let mut loads = Vec::with_capacity(n_bus);
115    let mut shunts = Vec::with_capacity(n_bus);
116    matlab::for_each_matrix_row(bus_raw, "bus", |row, i| {
117        let (bus, load, shunt) = rows::bus_row(row, i)?;
118        buses.push(bus);
119        if let Some(l) = load {
120            loads.push(l);
121        }
122        if let Some(s) = shunt {
123            shunts.push(s);
124        }
125        Ok(())
126    })?;
127
128    let branches = parse_rows(
129        get("branch").ok_or(Error::MissingField("branch"))?,
130        "branch",
131        rows::branch_row,
132    )?;
133
134    let generators = parse_gens(&get)?;
135    let storage = parse_optional(&get, "storage", rows::storage_row)?;
136    let hvdc = parse_optional(&get, "dcline", rows::hvdc_row)?;
137
138    // Bus names live in a `{...}` cell array; pull them (quotes kept) and attach
139    // by position when the count matches.
140    if let Some(raw) = get("bus_name") {
141        let names = locate::parse_string_cell(raw);
142        if names.len() == buses.len() {
143            for (bus, label) in buses.iter_mut().zip(names) {
144                bus.name = Some(label);
145            }
146        }
147    }
148
149    Ok(Network {
150        name: name.to_string(),
151        base_mva,
152        base_frequency: crate::network::DEFAULT_BASE_FREQUENCY,
153        buses,
154        loads,
155        shunts,
156        branches,
157        switches: Vec::new(),
158        generators,
159        storage,
160        hvdc,
161        transformers_3w: Vec::new(),
162        areas: Vec::new(),
163        solver: None,
164        source_format: SourceFormat::Matpower,
165        source: None,
166    })
167}
168
169/// A cheap upper-bound row count for an assignment (one `;` per row), used to
170/// pre-size the typed vectors so parsing doesn't reallocate as it streams.
171/// Capped: each `;` byte would otherwise pre-allocate a full element (~100
172/// bytes), letting a small crafted file demand ~100x its size in memory up
173/// front. Real cases sit far below the cap (largest vendored case: 13659
174/// buses); beyond it the vectors just grow as rows actually parse.
175fn estimate_rows(assignment: &str) -> usize {
176    const MAX_ROW_HINT: usize = 1 << 20;
177    assignment
178        .bytes()
179        .filter(|&b| b == b';')
180        .count()
181        .min(MAX_ROW_HINT)
182}
183
184/// Stream the rows of one assignment, building a typed `T` per row via `ctor`.
185fn parse_rows<T>(
186    assignment: &str,
187    field: &str,
188    ctor: impl Fn(&[f64], usize) -> Result<T>,
189) -> Result<Vec<T>> {
190    let mut out = Vec::with_capacity(estimate_rows(assignment));
191    matlab::for_each_matrix_row(assignment, field, |row, i| {
192        out.push(ctor(row, i)?);
193        Ok(())
194    })?;
195    Ok(out)
196}
197
198/// Like [`parse_rows`] but for an optional `mpc.<field>` block (empty if absent).
199fn parse_optional<'a, T>(
200    get: &impl Fn(&str) -> Option<&'a str>,
201    field: &str,
202    ctor: impl Fn(&[f64], usize) -> Result<T>,
203) -> Result<Vec<T>> {
204    match get(field) {
205        Some(raw) => parse_rows(raw, field, ctor),
206        None => Ok(Vec::new()),
207    }
208}
209
210/// Parse `mpc.gen` and fold in the active-power block of `mpc.gencost`.
211/// Both are optional: a case with only power flow data has neither and gets no gens.
212fn parse_gens<'a>(get: &impl Fn(&str) -> Option<&'a str>) -> Result<Vec<Generator>> {
213    let Some(raw) = get("gen") else {
214        return Ok(Vec::new());
215    };
216    let mut gens = parse_rows(raw, "gen", rows::gen_row)?;
217
218    // MATPOWER lays the active-power costs first, one row per generator and in
219    // the same order; reactive-power costs (if any) follow in a second block.
220    if let Some(craw) = get("gencost") {
221        let costs = parse_rows(craw, "gencost", rows::gencost_row)?;
222        // Reject a count that is neither `n_gen` (active only) nor `2·n_gen`
223        // (active + reactive). A per-row defect surfaces as `ShortRow` first.
224        let n = gens.len();
225        if costs.len() != n && costs.len() != 2 * n {
226            return Err(Error::GenCostCountMismatch {
227                gens: n,
228                gencost: costs.len(),
229            });
230        }
231        // The first `n` rows are the active-power costs in gen order; any
232        // reactive-power second block is accepted but not retained.
233        for (generator, cost) in gens.iter_mut().zip(costs) {
234            generator.cost = Some(cost);
235        }
236    }
237
238    Ok(gens)
239}