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}