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