Skip to main content

powerio/format/powerworld/
pwd.rs

1//! Read substation coordinates from PowerWorld `.pwd` display files
2//! (read only).
3//!
4//! A `.pwd` is the diagram sibling of a case: drawing records for buses,
5//! branches, substations, and field labels. This reader decodes the one
6//! subset with a differential oracle, the substation symbols, and leaves
7//! every other drawing object undecoded. The evidence (seven files across
8//! the 2016 through 2022 writer eras, each matched 1-1 against the
9//! latitude/longitude its same vintage aux carries per substation, except
10//! the v19 resave, which matches 1248/1250 against the published case
11//! across a vintage skew) is in `docs/powerworld.md`.
12//!
13//! Two structures carry the data, both present in every probed save:
14//!
15//! - The substation identity table, behind the only `ff ff ff ff 3d 0f`
16//!   byte sequence in the file (sentinel plus table tag 0x0f3d): records of
17//!   `u32 number, u32 number (exact duplicate), u32 length, name, 0x02`,
18//!   terminated exactly by the next `ff ff ff ff`. The order is display
19//!   order, not case order.
20//! - The DisplaySubstation drawing records: each repeats the file's header
21//!   stamp (the u32 at offset 22) at +18, stores the position as f64 x/y at
22//!   +22/+30 with an f32 echo of both at +2/+6, and links its substation
23//!   number behind a marker byte (0x03 or 0x07 by writer era) in the style
24//!   tail. The record's type tag (the u16 at +0) varies per save, so the
25//!   reader keys on this structure instead: stamp echo, dual encoded
26//!   coordinates, and a link to every identity row in table order. Decoy
27//!   groups exist (field label records with the same count and plausible
28//!   coordinates) and fail the link gauntlet; if more than one group ever
29//!   passes, the reader rejects rather than guesses.
30//!
31//! The coordinates are diagram positions (y north positive), not
32//! geography: no probed file stores latitude or longitude directly. The
33//! auto generated TAMU and Hawaii layouts equal a Mercator projection
34//! (`x = k * longitude`, `y = k * merc(latitude)`, k = 535.81608... on the
35//! never edited Hawaii40 file, bit exact), but hand moved symbols and the
36//! June 2016 era deviate, so the values are exposed as stored and any
37//! projection is the consumer's choice. Consumers wanting geography should
38//! read the aux.
39
40use std::collections::HashSet;
41use std::path::Path;
42
43use crate::{Error, Result};
44
45const FMT: &str = "PowerWorld .pwd";
46
47/// The identity table tag behind the `ff ff ff ff` sentinel.
48const IDENTITY_TAG: [u8; 6] = [0xff, 0xff, 0xff, 0xff, 0x3d, 0x0f];
49
50/// One substation symbol from a display file: the identity row joined with
51/// its drawing record, in identity table (display) order. `x` and `y` are
52/// diagram coordinates as stored, y north positive (see the module docs).
53#[derive(Debug, Clone, PartialEq)]
54pub struct PwdSubstation {
55    pub number: u32,
56    pub name: String,
57    pub x: f64,
58    pub y: f64,
59}
60
61/// Decoded PowerWorld display file content.
62///
63/// A `.pwd` is not a case file and does not carry a [`Network`](crate::Network).
64/// This structure exposes the display metadata the reader validates plus the
65/// supported drawing object subset.
66#[derive(Debug, Clone, PartialEq)]
67pub struct PwdDisplay {
68    pub canvas_width: u16,
69    pub canvas_height: u16,
70    pub stamp: u32,
71    pub substations: Vec<PwdSubstation>,
72}
73
74/// Read and parse a `.pwd` display file.
75///
76/// # Errors
77/// [`Error::Io`] when the file cannot be read, or [`Error::FormatRead`] when
78/// the display bytes are not a supported PowerWorld `.pwd` shape.
79pub fn parse_pwd_file(path: impl AsRef<Path>) -> Result<PwdDisplay> {
80    let bytes = std::fs::read(path)?;
81    parse_pwd_display(&bytes)
82}
83
84/// Parse a `.pwd` display file, returning metadata and decoded substations.
85///
86/// # Errors
87/// [`Error::FormatRead`] when the header is not the known display shape,
88/// the file has no substation identity table (bus only diagrams), or no
89/// unique drawing record group links to the identity rows.
90pub fn parse_pwd_display(bytes: &[u8]) -> Result<PwdDisplay> {
91    parse_pwd_inner(bytes)
92}
93
94/// Parse the substation coordinates out of `.pwd` bytes.
95///
96/// # Errors
97/// [`Error::FormatRead`] when the header is not the known display shape,
98/// the file has no substation identity table (bus only diagrams), or no
99/// unique drawing record group links to the identity rows.
100pub fn parse_pwd(bytes: &[u8]) -> Result<Vec<PwdSubstation>> {
101    parse_pwd_display(bytes).map(|display| display.substations)
102}
103
104fn pwd_err(message: impl Into<String>) -> Error {
105    Error::FormatRead {
106        format: FMT,
107        message: message.into(),
108    }
109}
110
111fn parse_pwd_header(bytes: &[u8]) -> Result<(u16, u16, u32)> {
112    let (Some(header), Some(canvas_width), Some(canvas_height)) =
113        (u32_at(bytes, 0), u16_at(bytes, 4), u16_at(bytes, 6))
114    else {
115        let header = u32_at(bytes, 0).unwrap_or(0);
116        return Err(pwd_err(format!(
117            "not a recognized PowerWorld display file (header word {header}; the probed saves all \
118             carry 50)",
119        )));
120    };
121    if bytes.len() < 0x40 || header != 50 {
122        return Err(pwd_err(format!(
123            "not a recognized PowerWorld display file (header word {header}; the probed saves all \
124             carry 50)",
125        )));
126    }
127    if canvas_width == 0 || canvas_height == 0 {
128        return Err(pwd_err("display header canvas dimensions are zero"));
129    }
130    let stamp = u32_at(bytes, 22).unwrap_or(0);
131    if stamp == 0 {
132        return Err(pwd_err(
133            "display header stamp is zero; every validated save carries a nonzero stamp the \
134             drawing records repeat",
135        ));
136    }
137    Ok((canvas_width, canvas_height, stamp))
138}
139
140fn parse_pwd_inner(bytes: &[u8]) -> Result<PwdDisplay> {
141    let (canvas_width, canvas_height, stamp) = parse_pwd_header(bytes)?;
142
143    let identity = find_identity_table(bytes)?;
144
145    // Every drawing object record repeats the header stamp at +18 and dual
146    // encodes its position (f64 at +22/+30, f32 echo at +2/+6); the scan
147    // collects every offset with that shape and groups by the u16 type tag.
148    let mut groups: Vec<(u16, Vec<DrawRecord>)> = Vec::new();
149    for i in 0..bytes.len().saturating_sub(38) {
150        if u32_at(bytes, i + 18) != Some(stamp) {
151            continue;
152        }
153        let (Some(x), Some(y)) = (f64_at(bytes, i + 22), f64_at(bytes, i + 30)) else {
154            continue;
155        };
156        if !x.is_finite() || !y.is_finite() {
157            continue;
158        }
159        #[allow(clippy::cast_possible_truncation)] // the echo is the f32 rounding by design
160        let (rx, ry) = (x as f32, y as f32);
161        // Bit equality: the magnitude gate below excludes zero, so the only
162        // value the echo can hold is the rounded f64 itself.
163        if f32_at(bytes, i + 2).map(f32::to_bits) != Some(rx.to_bits())
164            || f32_at(bytes, i + 6).map(f32::to_bits) != Some(ry.to_bits())
165        {
166            continue;
167        }
168        let magnitude = x.abs().max(y.abs());
169        if !(1.0..1.0e7).contains(&magnitude) {
170            continue;
171        }
172        let Some(tag) = u16_at(bytes, i) else {
173            continue;
174        };
175        let rec = DrawRecord { at: i, x, y };
176        match groups.iter_mut().find(|(t, _)| *t == tag) {
177            Some((_, v)) => v.push(rec),
178            None => groups.push((tag, vec![rec])),
179        }
180    }
181
182    // The substation group is the one whose records, in stream order, link
183    // every identity row in table order: a marker byte (0x03 or 0x07 by
184    // era) followed by the row's u32 number, somewhere in the style tail.
185    // Field label decoys carry other markers (0x05 observed) or another
186    // order and fail; ambiguity is a loud error, never a pick.
187    let matches: Vec<&(u16, Vec<DrawRecord>)> = groups
188        .iter()
189        .filter(|(_, records)| {
190            records.len() == identity.len()
191                && records
192                    .iter()
193                    .zip(&identity)
194                    .all(|(rec, (number, _))| links_number(bytes, rec.at, *number))
195        })
196        .collect();
197    let (_, records) = match matches.as_slice() {
198        [one] => *one,
199        [] => {
200            return Err(pwd_err(format!(
201                "no drawing record group links the {} substation identity rows; the \
202                 DisplaySubstation layout of this save is not the validated one",
203                identity.len()
204            )));
205        }
206        several => {
207            return Err(pwd_err(format!(
208                "{} drawing record groups link the substation identity rows; refusing to guess \
209                 between them",
210                several.len()
211            )));
212        }
213    };
214
215    let substations = records
216        .iter()
217        .zip(identity)
218        .map(|(rec, (number, name))| PwdSubstation {
219            number,
220            name,
221            x: rec.x,
222            y: rec.y,
223        })
224        .collect();
225    Ok(PwdDisplay {
226        canvas_width,
227        canvas_height,
228        stamp,
229        substations,
230    })
231}
232
233/// A drawing record that passed the shape gate: its stream offset (for the
234/// identity link check) and the decoded coordinates, kept so the final mapping
235/// never re-reads the bytes.
236struct DrawRecord {
237    at: usize,
238    x: f64,
239    y: f64,
240}
241
242/// The substation identity table: exactly one valid walk behind a
243/// `ff ff ff ff 3d 0f` anchor. Zero (bus only diagrams, pre 2016 shapes)
244/// and several are loud errors.
245fn find_identity_table(b: &[u8]) -> Result<Vec<(u32, String)>> {
246    let mut tables = Vec::new();
247    for at in memmem(b, &IDENTITY_TAG) {
248        if let Some(rows) = identity_walk(b, at + IDENTITY_TAG.len()) {
249            tables.push(rows);
250        }
251    }
252    match tables.len() {
253        1 => Ok(tables.pop().unwrap()),
254        0 => Err(Error::FormatRead {
255            format: FMT,
256            message: "no substation identity table (tag 0x0f3d walks clean); bus only diagrams \
257                      and unprobed save eras are not decoded (see docs/powerworld.md)"
258                .into(),
259        }),
260        n => Err(Error::FormatRead {
261            format: FMT,
262            message: format!(
263                "{n} byte ranges walk as a substation identity table; refusing to guess \
264                 between them"
265            ),
266        }),
267    }
268}
269
270/// Walk identity records (`u32 number, u32 duplicate, u32 length, name,
271/// 0x02`) from `at` until the next `ff ff ff ff` sentinel, which must
272/// arrive exactly at a record boundary. At least one record, numbers
273/// unique and plausible, names printable.
274fn identity_walk(b: &[u8], mut at: usize) -> Option<Vec<(u32, String)>> {
275    let mut rows = Vec::new();
276    let mut seen = HashSet::new();
277    loop {
278        if b.get(at..at + 4) == Some([0xff; 4].as_slice()) {
279            return (!rows.is_empty()).then_some(rows);
280        }
281        let number = u32_at(b, at)?;
282        if number == 0 || number > 99_999_999 || u32_at(b, at + 4) != Some(number) {
283            return None;
284        }
285        let len = u32_at(b, at + 8)? as usize;
286        if len == 0 || len >= 64 {
287            return None;
288        }
289        let name = b.get(at + 12..at + 12 + len)?;
290        if !name.iter().all(|&c| (0x20..0x7f).contains(&c)) || b.get(at + 12 + len) != Some(&0x02) {
291            return None;
292        }
293        if !seen.insert(number) {
294            return None;
295        }
296        rows.push((number, String::from_utf8_lossy(name).into_owned()));
297        at += 12 + len + 1;
298    }
299}
300
301/// Whether the drawing record at `i` links `number`: a marker byte 0x03 or
302/// 0x07 (the substation symbol markers of the two observed eras) directly
303/// followed by the number, inside the style tail window. The window is
304/// variable because a digit string of 1 to 4 characters precedes the link
305/// in some saves.
306fn links_number(b: &[u8], i: usize, number: u32) -> bool {
307    (40..140)
308        .any(|d| matches!(b.get(i + d), Some(0x03 | 0x07)) && u32_at(b, i + d + 1) == Some(number))
309}
310
311/// Every start of `needle` in `haystack`.
312fn memmem<'a>(haystack: &'a [u8], needle: &'a [u8]) -> impl Iterator<Item = usize> + 'a {
313    haystack
314        .windows(needle.len())
315        .enumerate()
316        .filter_map(move |(i, w)| (w == needle).then_some(i))
317}
318
319// Total little endian reads: `None` past the end of the buffer, no index
320// arithmetic that can panic or wrap. Every offset in this reader derives
321// from untrusted file bytes, so the accessors carry the bounds check.
322
323fn u16_at(b: &[u8], i: usize) -> Option<u16> {
324    Some(u16::from_le_bytes(*b.get(i..)?.first_chunk()?))
325}
326
327fn u32_at(b: &[u8], i: usize) -> Option<u32> {
328    Some(u32::from_le_bytes(*b.get(i..)?.first_chunk()?))
329}
330
331fn f32_at(b: &[u8], i: usize) -> Option<f32> {
332    Some(f32::from_le_bytes(*b.get(i..)?.first_chunk()?))
333}
334
335fn f64_at(b: &[u8], i: usize) -> Option<f64> {
336    Some(f64::from_le_bytes(*b.get(i..)?.first_chunk()?))
337}