Skip to main content

powerio_wasm/
lib.rs

1//! In-memory browser conversion using the native PowerIO parser and writers.
2
3use std::collections::BTreeMap;
4use std::sync::Arc;
5
6use powerio::{ArtifactPath, Destination, Diagnostic, EmittedOutput, PioModule, PioValue, Source};
7use serde_json::{Value, json};
8use wasm_bindgen::prelude::*;
9
10#[wasm_bindgen]
11pub fn capabilities() -> String {
12    let formats: Vec<_> = powerio::grid_formats()
13        .map(|entry| {
14            json!({
15                "token": entry.format.token,
16                "label": entry.label,
17                "family": entry.family,
18                "canRead": entry.can_read,
19                // The converter offers no GridLAB-D target: an ordinary OpenDSS
20                // feeder states no explicit conductor and ground interpretation
21                // for it, so the bulk targets stay as they were. The catalog
22                // still lists both profiles for reading and family detection.
23                "canEmit": entry.format.can_emit
24                    && !matches!(
25                        entry.format.token,
26                        "bmopf-json" | "gridlabd" | "gridlabd-arras"
27                    ),
28                "isDirectory": entry.format.is_directory,
29                "extension": entry.format.extension,
30                "requiresValueType": (entry.format.token == "goc3-json")
31                    .then_some("powerio.AcScucSolution"),
32            })
33        })
34        .collect();
35    json!({ "version": powerio::VERSION, "formats": formats }).to_string()
36}
37
38fn diagnostics(entries: &[Diagnostic]) -> Vec<Value> {
39    entries
40        .iter()
41        .map(|entry| {
42            json!({
43                "code": entry.code(),
44                "severity": entry.severity().as_str(),
45                "message": entry.message(),
46                "target": entry.target(),
47                "suggestedAction": entry.suggested_action(),
48                "spans": entry.spans().iter().map(|span| json!({
49                    "source": span.source().as_str(),
50                    "start": span.byte_start(),
51                    "end": span.byte_end(),
52                })).collect::<Vec<_>>(),
53            })
54        })
55        .collect()
56}
57
58fn failure(error: &powerio::Error) -> String {
59    json!({"ok": false, "diagnostics": diagnostics(error.diagnostics())}).to_string()
60}
61
62/// One case and its parsed module, retained until the worker releases it.
63#[wasm_bindgen]
64pub struct Conversion {
65    name: String,
66    files: BTreeMap<String, Arc<[u8]>>,
67    module: Option<PioModule<PioValue>>,
68    artifacts: Vec<powerio::MemoryArtifact>,
69}
70
71#[wasm_bindgen]
72impl Conversion {
73    #[wasm_bindgen(constructor)]
74    pub fn new(name: String) -> Self {
75        Self {
76            name,
77            files: BTreeMap::new(),
78            module: None,
79            artifacts: Vec::new(),
80        }
81    }
82
83    /// Return a structured error for invalid or duplicate project paths.
84    pub fn add_file(&mut self, name: String, bytes: &[u8]) -> String {
85        if let Err(error) = ArtifactPath::new(&name) {
86            return failure(&error);
87        }
88        if self.files.contains_key(&name) {
89            return json!({"ok": false, "diagnostics": [{
90                "code": "WEB.INPUT.DUPLICATE_PATH", "severity": "error",
91                "message": format!("More than one file has the project path `{name}`. Choose their containing folder to preserve relative paths.")
92            }]}).to_string();
93        }
94        if self.files.len() >= 4096
95            || self.files.values().map(|file| file.len()).sum::<usize>() + bytes.len()
96                > 64 * 1024 * 1024
97        {
98            return json!({"ok": false, "diagnostics": [{
99                "code": "WEB.INPUT.LIMIT", "severity": "error",
100                "message": "This project exceeds 4096 files or 64 MiB. Choose a smaller project or use PowerIO in the terminal."
101            }]}).to_string();
102        }
103        self.files.insert(name, Arc::from(bytes));
104        json!({"ok": true}).to_string()
105    }
106
107    #[allow(clippy::needless_pass_by_value)] // wasm-bindgen receives optional JavaScript strings as owned strings.
108    pub fn inspect(&mut self, primary: Option<String>, format: Option<String>) -> String {
109        self.module = None;
110        self.artifacts.clear();
111        let source = self.source(primary.as_deref(), format.as_deref());
112        let module = match source.and_then(powerio::parse) {
113            Ok(module) => module,
114            Err(error) => return failure(&error),
115        };
116        let detected =
117            module
118                .source()
119                .and_then(Source::format)
120                .map(|format| match format.as_str() {
121                    "powerworld-pwb" => "pwb".to_owned(),
122                    token => powerio::resolve_format(token)
123                        .map_or(token, |resolved| resolved.token)
124                        .to_owned(),
125                });
126        let family = detected.as_ref().and_then(|token| {
127            powerio::grid_formats()
128                .find(|entry| entry.format.token == token)
129                .map(|entry| entry.family)
130        });
131        let response = json!({
132            "ok": true, "format": detected, "family": family,
133            "valueType": module.value().type_name(), "diagnostics": diagnostics(module.diagnostics()),
134        }).to_string();
135        self.module = Some(module);
136        response
137    }
138
139    /// Emit one target and retain its artifacts for transfer to JavaScript.
140    pub fn emit(&mut self, format: &str, name: String) -> String {
141        self.artifacts.clear();
142        let Some(module) = self.module.as_ref() else {
143            return json!({"ok": false, "diagnostics": [{
144                "code": "WEB.CONVERT.NOT_PARSED", "severity": "error", "message": "Parse the case before converting it."
145            }]}).to_string();
146        };
147        let result = Destination::memory(name).and_then(|out| powerio::emit(module, format, out));
148        match result {
149            Ok(result) => {
150                let response = json!({
151                    "ok": true,
152                    "layout": match result.layout() {
153                        powerio::OutputLayout::File => "file",
154                        powerio::OutputLayout::Directory => "directory",
155                    },
156                    "fidelity": match result.fidelity() {
157                        powerio::Fidelity::ExactSameFormat => "exact",
158                        powerio::Fidelity::Canonical => "canonical",
159                    },
160                    "diagnostics": diagnostics(result.diagnostics()),
161                })
162                .to_string();
163                if let EmittedOutput::Memory { artifacts } = result.into_output() {
164                    self.artifacts = artifacts;
165                }
166                response
167            }
168            Err(error) => failure(&error),
169        }
170    }
171
172    pub fn artifact_count(&self) -> usize {
173        self.artifacts.len()
174    }
175
176    pub fn artifact_name(&self, index: usize) -> Option<String> {
177        self.artifacts
178            .get(index)
179            .map(|artifact| artifact.name().as_str().to_owned())
180    }
181
182    pub fn take_artifact(&mut self, index: usize) -> Vec<u8> {
183        self.artifacts
184            .get_mut(index)
185            .map_or_else(Vec::new, |artifact| {
186                let empty = powerio::MemoryArtifact::new(artifact.name().clone(), Vec::new());
187                std::mem::replace(artifact, empty).into_bytes()
188            })
189    }
190}
191
192impl Conversion {
193    fn source(
194        &self,
195        primary: Option<&str>,
196        format: Option<&str>,
197    ) -> Result<Source, powerio::Error> {
198        let source = if self.files.len() == 1
199            && self
200                .files
201                .first_key_value()
202                .is_some_and(|(name, _)| primary == Some(name.as_str()))
203        {
204            let (name, bytes) = self.files.first_key_value().expect("one file");
205            Source::from_memory(name, Arc::clone(bytes))?
206        } else {
207            let files = self
208                .files
209                .iter()
210                .map(|(name, bytes)| ArtifactPath::new(name).map(|path| (path, Arc::clone(bytes))))
211                .collect::<Result<Vec<_>, _>>()?;
212            Source::from_memory_tree(
213                &self.name,
214                files,
215                primary.map(ArtifactPath::new).transpose()?,
216            )?
217        };
218        if let Some(format) = format.filter(|value| !value.is_empty()) {
219            Ok(source.with_format(powerio::FormatId::new(format)?))
220        } else {
221            Ok(source)
222        }
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn explicit_reader_profiles_report_their_family_without_enabling_sincal_writing() {
232        let connection = rusqlite::Connection::open_in_memory().unwrap();
233        connection
234            .execute_batch(include_str!(
235                "../../tests/data/sincal/synthetic-multiconductor.sql"
236            ))
237            .unwrap();
238        let multiconductor = connection.serialize("main").unwrap().to_vec();
239        for (filename, bytes, token, family) in [
240            (
241                "feeder.xml",
242                include_bytes!("../../tests/data/dist/cim/micro_feeder.xml").as_slice(),
243                "cim-dist",
244                "distribution",
245            ),
246            (
247                "case.sinx",
248                include_bytes!("../../tests/data/sincal/1-LV-rural1--0-sw.sinx").as_slice(),
249                "sincal-balanced",
250                "transmission",
251            ),
252            (
253                "case.db",
254                multiconductor.as_slice(),
255                "sincal-multiconductor",
256                "distribution",
257            ),
258        ] {
259            let mut converter = Conversion::new(filename.into());
260            converter.add_file(filename.into(), bytes);
261            let result: Value =
262                serde_json::from_str(&converter.inspect(Some(filename.into()), Some(token.into())))
263                    .unwrap();
264            assert_eq!(result["ok"], true, "{result}");
265            assert_eq!(result["family"], family);
266            assert_eq!(result["format"], token);
267        }
268        let catalog: Value = serde_json::from_str(&capabilities()).unwrap();
269        for token in ["sincal-balanced", "sincal-multiconductor"] {
270            let format = catalog["formats"]
271                .as_array()
272                .unwrap()
273                .iter()
274                .find(|format| format["token"] == token)
275                .unwrap();
276            assert_eq!(format["canRead"], true);
277            assert_eq!(format["canEmit"], false);
278        }
279    }
280
281    #[test]
282    fn gridlabd_profiles_are_listed_for_reading_but_not_offered_as_targets() {
283        let catalog: Value = serde_json::from_str(&capabilities()).unwrap();
284        for token in ["gridlabd", "gridlabd-arras"] {
285            let format = catalog["formats"]
286                .as_array()
287                .unwrap()
288                .iter()
289                .find(|format| format["token"] == token)
290                .unwrap();
291            assert_eq!(format["family"], "distribution");
292            assert_eq!(format["canRead"], true);
293            assert_eq!(format["canEmit"], false);
294        }
295    }
296
297    #[test]
298    fn dss_companion_suffixes_do_not_change_native_parse_or_source_echo() {
299        let mut converter = Conversion::new("feeder".into());
300        let files: [(&str, &[u8]); 3] = [
301            (
302                "Master.dss",
303                b"New Circuit.example basekv=12.47 bus1=source\nRedirect loads.inc\n",
304            ),
305            (
306                "loads.inc",
307                b"New Load.ld bus1=source phases=3 conn=wye kv=12.47 kw=10 kvar=2\n",
308            ),
309            ("notes.asset", b"opaque companion bytes\x00\xff"),
310        ];
311        for (name, bytes) in files {
312            converter.add_file(name.into(), bytes);
313        }
314        let result: Value =
315            serde_json::from_str(&converter.inspect(Some("Master.dss".into()), None)).unwrap();
316        assert_eq!(result["ok"], true, "{result}");
317        let result: Value = serde_json::from_str(&converter.emit("dss", "feeder".into())).unwrap();
318        assert_eq!(result["ok"], true, "{result}");
319        for (name, bytes) in files {
320            let index = converter
321                .artifacts
322                .iter()
323                .position(|artifact| artifact.name().as_str().ends_with(name))
324                .unwrap();
325            assert_eq!(converter.take_artifact(index), bytes);
326        }
327    }
328
329    #[test]
330    fn missing_primary_is_reported_instead_of_reading_another_file() {
331        let mut converter = Conversion::new("case".into());
332        converter.add_file("case9.m".into(), include_bytes!("../../tests/data/case9.m"));
333        let result: Value =
334            serde_json::from_str(&converter.inspect(Some("missing.m".into()), None)).unwrap();
335        assert_eq!(result["ok"], false);
336        assert_ne!(
337            result["diagnostics"].as_array().unwrap().as_slice(),
338            &[] as &[Value]
339        );
340    }
341
342    #[test]
343    fn binary_powerworld_source_matches_its_catalog_entry() {
344        let mut converter = Conversion::new("case.pwb".into());
345        converter.add_file(
346            "case.pwb".into(),
347            include_bytes!("../../tests/data/powerworld/ACTIVSg200.pwb"),
348        );
349        let result: Value =
350            serde_json::from_str(&converter.inspect(Some("case.pwb".into()), None)).unwrap();
351        assert_eq!(result["ok"], true);
352        assert_eq!(result["format"], "pwb");
353        assert_eq!(result["family"], "transmission");
354    }
355
356    #[test]
357    fn binding_preserves_native_artifacts_and_diagnostic_codes() {
358        let bytes = include_bytes!("../../tests/data/case9.m");
359        let mut converter = Conversion::new("case9.m".into());
360        converter.add_file("case9.m".into(), bytes);
361        let inspection: Value =
362            serde_json::from_str(&converter.inspect(Some("case9.m".into()), None)).unwrap();
363        assert_eq!(inspection["format"], "matpower");
364        assert_eq!(inspection["family"], "transmission");
365        let native =
366            powerio::parse(Source::from_memory("case9.m", bytes.to_vec()).unwrap()).unwrap();
367        for format in [
368            "matpower",
369            "powermodels-json",
370            "psse",
371            "pandapower-json",
372            "cgmes",
373        ] {
374            let emitted =
375                powerio::emit(&native, format, Destination::memory("case9").unwrap()).unwrap();
376            let output: Value =
377                serde_json::from_str(&converter.emit(format, "case9".into())).unwrap();
378            assert_eq!(
379                output["diagnostics"],
380                json!(diagnostics(emitted.diagnostics()))
381            );
382            let EmittedOutput::Memory { artifacts } = emitted.output() else {
383                panic!("memory expected")
384            };
385            assert_eq!(
386                output["layout"],
387                if emitted.layout() == powerio::OutputLayout::File {
388                    "file"
389                } else {
390                    "directory"
391                }
392            );
393            assert_eq!(converter.artifact_count(), artifacts.len());
394            for (index, artifact) in artifacts.iter().enumerate() {
395                assert_eq!(converter.take_artifact(index), artifact.bytes());
396            }
397        }
398    }
399
400    #[test]
401    fn indexed_mutation_warning_reaches_inspection_without_changing_values_or_echo() {
402        let original = include_str!("../../tests/data/case9.m");
403        let mutation = "mpc.bus(:, [3 4]) ... ignored ) ' ; ,\n = mpc.bus(:, [3 4]) / 1000;";
404        let source = format!("{original}\nx = [1 2].';\n{mutation}\n");
405        let mut converter = Conversion::new("mutated.m".into());
406        converter.add_file("mutated.m".into(), source.as_bytes());
407        let result: Value =
408            serde_json::from_str(&converter.inspect(Some("mutated.m".into()), None)).unwrap();
409        assert_eq!(result["ok"], true, "{result}");
410        let warnings: Vec<_> = result["diagnostics"]
411            .as_array()
412            .unwrap()
413            .iter()
414            .filter(|entry| entry["code"] == "READ.MATPOWER.INDEXED_ASSIGNMENT")
415            .collect();
416        assert_eq!(warnings.len(), 1);
417        assert_eq!(warnings[0]["severity"], "warning");
418        let start = source.find(mutation).unwrap();
419        assert_eq!(warnings[0]["spans"][0]["start"], start);
420        assert_eq!(warnings[0]["spans"][0]["end"], start + mutation.len());
421        let native =
422            powerio::parse(Source::from_memory("case9.m", original.as_bytes().to_vec()).unwrap())
423                .unwrap();
424        let (
425            powerio::PioValue::BalancedNetwork(expected),
426            powerio::PioValue::BalancedNetwork(actual),
427        ) = (native.value(), converter.module.as_ref().unwrap().value())
428        else {
429            panic!("balanced values expected");
430        };
431        let expected_load: f64 = expected.loads().iter().map(|load| load.p).sum();
432        let actual_load: f64 = actual.loads().iter().map(|load| load.p).sum();
433        assert!((actual_load - expected_load).abs() < 1e-12);
434        let emitted: Value =
435            serde_json::from_str(&converter.emit("matpower", "mutated.m".into())).unwrap();
436        assert_eq!(emitted["ok"], true, "{emitted}");
437        assert_eq!(converter.artifact_count(), 1);
438        assert_eq!(converter.take_artifact(0), source.as_bytes());
439    }
440}