1use 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 "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#[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 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)] 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 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}