1use std::io::BufReader;
20use std::path::Path;
21
22use serde::Serialize;
23use serde_value::Value as SerdeValue;
24use surge_network::Network;
25use surge_solution::{AuditableSolution, SolutionAuditReport};
26use thiserror::Error;
27
28pub const SURGE_JSON_FORMAT: &str = "surge-json";
29pub const SURGE_JSON_SCHEMA_VERSION: &str = "0.1.0";
30
31const SPECIAL_FLOAT_TAG: &str = "$surge_float";
32const SPECIAL_BYTES_TAG: &str = "$surge_bytes";
33const SPECIAL_MAP_TAG: &str = "$surge_map";
34const FORMAT_FIELD: &str = "format";
35const SCHEMA_VERSION_FIELD: &str = "schema_version";
36const META_FIELD: &str = "meta";
37const NETWORK_FIELD: &str = "network";
38const META_PRODUCER_FIELD: &str = "producer";
39const META_PROFILE_FIELD: &str = "profile";
40const DISPATCH_FIELD: &str = "dispatch";
41const SOLUTION_FIELD: &str = "solution";
42const AUDIT_FIELD: &str = "audit";
43const META_PRODUCER: &str = "surge";
44const META_PROFILE_NETWORK: &str = "network";
45const META_PROFILE_DISPATCH: &str = "dispatch";
46const META_PROFILE_RESULTS: &str = "results";
47
48#[derive(Error, Debug)]
49pub enum Error {
50 #[error("I/O error: {0}")]
51 Io(#[from] std::io::Error),
52
53 #[error("JSON error: {0}")]
54 Json(#[from] serde_json::Error),
55
56 #[error("serde-value serialization error: {0}")]
57 ValueSerialize(#[from] serde_value::SerializerError),
58
59 #[error("serde-value deserialization error: {0}")]
60 ValueDeserialize(#[from] serde_value::DeserializerError),
61
62 #[error("invalid tagged JSON value: {0}")]
63 InvalidTaggedValue(String),
64
65 #[error("invalid JSON document: {0}")]
66 InvalidDocument(String),
67
68 #[error("solution audit failed: {0}")]
69 SolutionAuditFailed(String),
70}
71
72pub fn load(path: impl AsRef<Path>) -> Result<Network, Error> {
74 parse_file(path.as_ref())
75}
76
77pub fn loads(content: &str) -> Result<Network, Error> {
79 parse_str(content)
80}
81
82pub fn save(network: &Network, path: impl AsRef<Path>) -> Result<(), Error> {
84 write_file(network, path.as_ref(), false)
85}
86
87pub fn save_pretty(network: &Network, path: impl AsRef<Path>) -> Result<(), Error> {
89 write_file(network, path.as_ref(), true)
90}
91
92pub fn dumps(network: &Network) -> Result<String, Error> {
94 to_string(network, false)
95}
96
97pub fn dumps_pretty(network: &Network) -> Result<String, Error> {
99 to_string(network, true)
100}
101
102#[derive(Debug, Clone)]
111pub struct SurgeDocument {
112 pub network: Network,
114 pub dispatch: Option<serde_json::Value>,
116 pub solution: Option<serde_json::Value>,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum SurgeJsonProfile {
123 Network,
125 Dispatch,
127 Results,
129}
130
131impl SurgeDocument {
132 pub fn profile(&self) -> SurgeJsonProfile {
134 if self.solution.is_some() {
135 SurgeJsonProfile::Results
136 } else if self.dispatch.is_some() {
137 SurgeJsonProfile::Dispatch
138 } else {
139 SurgeJsonProfile::Network
140 }
141 }
142}
143
144pub fn load_document(path: impl AsRef<Path>) -> Result<SurgeDocument, Error> {
146 let path = path.as_ref();
147 let file = std::fs::File::open(path)?;
148 let json: serde_json::Value = if path_uses_zstd(path) {
149 let reader = zstd::stream::read::Decoder::new(file)?;
150 serde_json::from_reader(BufReader::new(reader))?
151 } else {
152 serde_json::from_reader(BufReader::new(file))?
153 };
154 decode_document_full(json)
155}
156
157pub fn loads_document(content: &str) -> Result<SurgeDocument, Error> {
159 let json: serde_json::Value = serde_json::from_str(content)?;
160 decode_document_full(json)
161}
162
163pub fn save_document(doc: &SurgeDocument, path: impl AsRef<Path>) -> Result<(), Error> {
165 let path = path.as_ref();
166 let json = encode_document_full(doc)?;
167 let file = std::fs::File::create(path)?;
168 if path_uses_zstd(path) {
169 let mut encoder = zstd::stream::write::Encoder::new(file, 9)?;
170 serde_json::to_writer(&mut encoder, &json)?;
171 encoder.finish()?;
172 } else {
173 serde_json::to_writer_pretty(file, &json)?;
174 }
175 Ok(())
176}
177
178pub fn dumps_document(doc: &SurgeDocument) -> Result<String, Error> {
180 let json = encode_document_full(doc)?;
181 Ok(serde_json::to_string_pretty(&json)?)
182}
183
184pub fn encode_audited_solution<T>(solution: &T) -> Result<serde_json::Value, Error>
187where
188 T: Serialize + AuditableSolution,
189{
190 let audit = solution.computed_solution_audit();
191 let mut json = serde_json::to_value(solution)?;
192 inject_solution_audit(&mut json, &audit)?;
193 Ok(json)
194}
195
196pub fn encode_checked_audited_solution<T>(solution: &T) -> Result<serde_json::Value, Error>
199where
200 T: Serialize + AuditableSolution,
201{
202 let audit = solution.computed_solution_audit();
203 let mut json = serde_json::to_value(solution)?;
204 inject_solution_audit(&mut json, &audit)?;
205 if !audit.audit_passed {
206 return Err(Error::SolutionAuditFailed(format_solution_audit_failure(
207 &audit,
208 )));
209 }
210 Ok(json)
211}
212
213fn parse_file(path: &Path) -> Result<Network, Error> {
214 let file = std::fs::File::open(path)?;
215 let json: serde_json::Value = if path_uses_zstd(path) {
216 let reader = zstd::stream::read::Decoder::new(file)?;
217 serde_json::from_reader(BufReader::new(reader))?
218 } else {
219 serde_json::from_reader(BufReader::new(file))?
220 };
221 decode_document(json)
222}
223
224fn parse_str(content: &str) -> Result<Network, Error> {
225 let json: serde_json::Value = serde_json::from_str(content)?;
226 decode_document(json)
227}
228
229fn write_file(network: &Network, path: &Path, pretty: bool) -> Result<(), Error> {
230 let file = std::fs::File::create(path)?;
231 let json = encode_document(network)?;
232 if path_uses_zstd(path) {
233 let mut encoder = zstd::stream::write::Encoder::new(file, 9)?;
234 if pretty {
235 serde_json::to_writer_pretty(&mut encoder, &json)?;
236 } else {
237 serde_json::to_writer(&mut encoder, &json)?;
238 }
239 encoder.finish()?;
240 } else if pretty {
241 serde_json::to_writer_pretty(file, &json)?;
242 } else {
243 serde_json::to_writer(file, &json)?;
244 }
245 Ok(())
246}
247
248fn to_string(network: &Network, pretty: bool) -> Result<String, Error> {
249 let json = encode_document(network)?;
250 let json = if pretty {
251 serde_json::to_string_pretty(&json)?
252 } else {
253 serde_json::to_string(&json)?
254 };
255 Ok(json)
256}
257
258fn path_uses_zstd(path: &Path) -> bool {
259 path.file_name()
260 .and_then(|value| value.to_str())
261 .is_some_and(|value| value.to_ascii_lowercase().ends_with(".zst"))
262}
263
264fn inject_solution_audit(
265 solution_json: &mut serde_json::Value,
266 audit: &SolutionAuditReport,
267) -> Result<(), Error> {
268 let object = solution_json.as_object_mut().ok_or_else(|| {
269 Error::InvalidDocument("solution payload must serialize as a JSON object".to_string())
270 })?;
271 object.insert(AUDIT_FIELD.to_string(), serde_json::to_value(audit)?);
272 Ok(())
273}
274
275fn format_solution_audit_failure(audit: &SolutionAuditReport) -> String {
276 let mut message = format!("{} mismatch(es) detected", audit.ledger_mismatches.len());
277 if let Some(first) = audit.ledger_mismatches.first() {
278 message.push_str(&format!(
279 "; first mismatch: {:?} {} {} (expected {:.6}, actual {:.6})",
280 first.scope_kind,
281 first.scope_id,
282 first.field,
283 first.expected_dollars,
284 first.actual_dollars,
285 ));
286 }
287 if audit.has_residual_terms {
288 message.push_str("; residual terms remain in the objective ledger");
289 }
290 message
291}
292
293fn encode_document(network: &Network) -> Result<serde_json::Value, Error> {
294 let mut object = serde_json::Map::new();
295 object.insert(
296 FORMAT_FIELD.to_string(),
297 serde_json::Value::String(SURGE_JSON_FORMAT.to_string()),
298 );
299 object.insert(
300 SCHEMA_VERSION_FIELD.to_string(),
301 serde_json::Value::String(SURGE_JSON_SCHEMA_VERSION.to_string()),
302 );
303 object.insert(META_FIELD.to_string(), encode_meta());
304 object.insert(NETWORK_FIELD.to_string(), encode_network(network)?);
305 Ok(serde_json::Value::Object(object))
306}
307
308fn decode_document(json: serde_json::Value) -> Result<Network, Error> {
309 let object = json.as_object().ok_or_else(|| {
310 Error::InvalidDocument("expected top-level JSON object document".to_string())
311 })?;
312
313 let format = object
314 .get(FORMAT_FIELD)
315 .and_then(serde_json::Value::as_str)
316 .ok_or_else(|| {
317 Error::InvalidDocument(format!("missing or invalid '{FORMAT_FIELD}' field"))
318 })?;
319 if format != SURGE_JSON_FORMAT {
320 return Err(Error::InvalidDocument(format!(
321 "unsupported '{FORMAT_FIELD}' value '{format}'"
322 )));
323 }
324
325 let schema_version = object
326 .get(SCHEMA_VERSION_FIELD)
327 .and_then(serde_json::Value::as_str)
328 .ok_or_else(|| {
329 Error::InvalidDocument(format!("missing or invalid '{SCHEMA_VERSION_FIELD}' field"))
330 })?;
331 if schema_version != SURGE_JSON_SCHEMA_VERSION {
332 return Err(Error::InvalidDocument(format!(
333 "unsupported '{SCHEMA_VERSION_FIELD}' value '{schema_version}'"
334 )));
335 }
336
337 if let Some(meta) = object.get(META_FIELD) {
338 validate_meta(meta)?;
339 }
340
341 let network = object
342 .get(NETWORK_FIELD)
343 .cloned()
344 .ok_or_else(|| Error::InvalidDocument(format!("missing '{NETWORK_FIELD}' field")))?;
345
346 decode_network(network)
347}
348
349fn encode_document_full(doc: &SurgeDocument) -> Result<serde_json::Value, Error> {
350 let profile = doc.profile();
351 let profile_str = match profile {
352 SurgeJsonProfile::Network => META_PROFILE_NETWORK,
353 SurgeJsonProfile::Dispatch => META_PROFILE_DISPATCH,
354 SurgeJsonProfile::Results => META_PROFILE_RESULTS,
355 };
356
357 let mut object = serde_json::Map::new();
358 object.insert(
359 FORMAT_FIELD.to_string(),
360 serde_json::Value::String(SURGE_JSON_FORMAT.to_string()),
361 );
362 object.insert(
363 SCHEMA_VERSION_FIELD.to_string(),
364 serde_json::Value::String(SURGE_JSON_SCHEMA_VERSION.to_string()),
365 );
366 object.insert(
367 META_FIELD.to_string(),
368 encode_meta_with_profile(profile_str),
369 );
370 object.insert(NETWORK_FIELD.to_string(), encode_network(&doc.network)?);
371
372 if let Some(ref dispatch) = doc.dispatch {
373 object.insert(DISPATCH_FIELD.to_string(), dispatch.clone());
374 }
375 if let Some(ref solution) = doc.solution {
376 object.insert(SOLUTION_FIELD.to_string(), solution.clone());
377 }
378
379 Ok(serde_json::Value::Object(object))
380}
381
382fn decode_document_full(json: serde_json::Value) -> Result<SurgeDocument, Error> {
383 let object = json.as_object().ok_or_else(|| {
384 Error::InvalidDocument("expected top-level JSON object document".to_string())
385 })?;
386
387 let format = object
388 .get(FORMAT_FIELD)
389 .and_then(serde_json::Value::as_str)
390 .ok_or_else(|| {
391 Error::InvalidDocument(format!("missing or invalid '{FORMAT_FIELD}' field"))
392 })?;
393 if format != SURGE_JSON_FORMAT {
394 return Err(Error::InvalidDocument(format!(
395 "unsupported '{FORMAT_FIELD}' value '{format}'"
396 )));
397 }
398
399 let schema_version = object
400 .get(SCHEMA_VERSION_FIELD)
401 .and_then(serde_json::Value::as_str)
402 .ok_or_else(|| {
403 Error::InvalidDocument(format!("missing or invalid '{SCHEMA_VERSION_FIELD}' field"))
404 })?;
405 if schema_version != SURGE_JSON_SCHEMA_VERSION {
406 return Err(Error::InvalidDocument(format!(
407 "unsupported '{SCHEMA_VERSION_FIELD}' value '{schema_version}'"
408 )));
409 }
410
411 if let Some(meta) = object.get(META_FIELD) {
413 validate_meta_any_profile(meta)?;
414 }
415
416 let network_json = object
417 .get(NETWORK_FIELD)
418 .cloned()
419 .ok_or_else(|| Error::InvalidDocument(format!("missing '{NETWORK_FIELD}' field")))?;
420 let network = decode_network(network_json)?;
421
422 let dispatch = object.get(DISPATCH_FIELD).cloned();
423 let solution = object.get(SOLUTION_FIELD).cloned();
424
425 Ok(SurgeDocument {
426 network,
427 dispatch,
428 solution,
429 })
430}
431
432fn encode_meta_with_profile(profile: &str) -> serde_json::Value {
433 let mut meta = serde_json::Map::new();
434 meta.insert(
435 META_PRODUCER_FIELD.to_string(),
436 serde_json::Value::String(META_PRODUCER.to_string()),
437 );
438 meta.insert(
439 META_PROFILE_FIELD.to_string(),
440 serde_json::Value::String(profile.to_string()),
441 );
442 serde_json::Value::Object(meta)
443}
444
445fn validate_meta_any_profile(meta: &serde_json::Value) -> Result<(), Error> {
446 let object = meta
447 .as_object()
448 .ok_or_else(|| Error::InvalidDocument(format!("'{META_FIELD}' must be a JSON object")))?;
449
450 if let Some(producer) = object.get(META_PRODUCER_FIELD) {
451 let producer = producer.as_str().ok_or_else(|| {
452 Error::InvalidDocument(format!(
453 "'{META_FIELD}.{META_PRODUCER_FIELD}' must be a string"
454 ))
455 })?;
456 if producer != META_PRODUCER {
457 return Err(Error::InvalidDocument(format!(
458 "unsupported '{META_FIELD}.{META_PRODUCER_FIELD}' value '{producer}'"
459 )));
460 }
461 }
462
463 if let Some(profile) = object.get(META_PROFILE_FIELD) {
464 let profile = profile.as_str().ok_or_else(|| {
465 Error::InvalidDocument(format!(
466 "'{META_FIELD}.{META_PROFILE_FIELD}' must be a string"
467 ))
468 })?;
469 if !matches!(
470 profile,
471 META_PROFILE_NETWORK | META_PROFILE_DISPATCH | META_PROFILE_RESULTS
472 ) {
473 return Err(Error::InvalidDocument(format!(
474 "unsupported '{META_FIELD}.{META_PROFILE_FIELD}' value '{profile}'"
475 )));
476 }
477 }
478
479 Ok(())
480}
481
482pub(crate) fn encode_meta() -> serde_json::Value {
483 let mut meta = serde_json::Map::new();
484 meta.insert(
485 META_PRODUCER_FIELD.to_string(),
486 serde_json::Value::String(META_PRODUCER.to_string()),
487 );
488 meta.insert(
489 META_PROFILE_FIELD.to_string(),
490 serde_json::Value::String(META_PROFILE_NETWORK.to_string()),
491 );
492 serde_json::Value::Object(meta)
493}
494
495pub(crate) fn validate_meta(meta: &serde_json::Value) -> Result<(), Error> {
496 let object = meta
497 .as_object()
498 .ok_or_else(|| Error::InvalidDocument(format!("'{META_FIELD}' must be a JSON object")))?;
499
500 if let Some(producer) = object.get(META_PRODUCER_FIELD) {
501 let producer = producer.as_str().ok_or_else(|| {
502 Error::InvalidDocument(format!(
503 "'{META_FIELD}.{META_PRODUCER_FIELD}' must be a string"
504 ))
505 })?;
506 if producer != META_PRODUCER {
507 return Err(Error::InvalidDocument(format!(
508 "unsupported '{META_FIELD}.{META_PRODUCER_FIELD}' value '{producer}'"
509 )));
510 }
511 }
512
513 if let Some(profile) = object.get(META_PROFILE_FIELD) {
514 let profile = profile.as_str().ok_or_else(|| {
515 Error::InvalidDocument(format!(
516 "'{META_FIELD}.{META_PROFILE_FIELD}' must be a string"
517 ))
518 })?;
519 if profile != META_PROFILE_NETWORK {
520 return Err(Error::InvalidDocument(format!(
521 "unsupported '{META_FIELD}.{META_PROFILE_FIELD}' value '{profile}'"
522 )));
523 }
524 }
525
526 Ok(())
527}
528
529pub(crate) fn encode_network(network: &Network) -> Result<serde_json::Value, Error> {
530 let value = serde_value::to_value(network)?;
531 value_to_json(value)
532}
533
534pub(crate) fn decode_network(json: serde_json::Value) -> Result<Network, Error> {
535 let json = migrate_phase_shift_deg_to_rad(json);
536 let json = migrate_bus_demand_to_loads(json)?;
537 let json = migrate_legacy_market_layout(json)?;
538 let value = json_to_value(json)?;
539 let network: Network = value.deserialize_into()?;
540 Ok(network)
541}
542
543fn migrate_phase_shift_deg_to_rad(mut json: serde_json::Value) -> serde_json::Value {
553 let branches = json
554 .as_object_mut()
555 .and_then(|o| o.get_mut("branches"))
556 .and_then(|v| v.as_array_mut());
557 if let Some(branches) = branches {
558 for br in branches.iter_mut() {
559 if let Some(obj) = br.as_object_mut() {
560 migrate_deg_field(obj, "phase_shift_deg", "phase_shift_rad");
561 migrate_deg_field(obj, "phase_min_deg", "phase_min_rad");
562 migrate_deg_field(obj, "phase_max_deg", "phase_max_rad");
563 migrate_deg_field(obj, "phase_step_deg", "phase_step_rad");
564 }
565 }
566 }
567 json
568}
569
570fn migrate_deg_field(
573 obj: &mut serde_json::Map<String, serde_json::Value>,
574 old_key: &str,
575 new_key: &str,
576) {
577 if obj.contains_key(new_key) {
578 return; }
580 if let Some(val) = obj.remove(old_key) {
581 if let Some(deg) = val.as_f64() {
582 let rad = deg.to_radians();
583 obj.insert(new_key.to_string(), serde_json::Value::from(rad));
584 } else {
585 obj.insert(old_key.to_string(), val);
587 }
588 }
589}
590
591fn migrate_bus_demand_to_loads(mut json: serde_json::Value) -> Result<serde_json::Value, Error> {
602 let root = match json.as_object_mut() {
603 Some(o) => o,
604 None => return Ok(json),
605 };
606
607 let mut loads_by_bus = std::collections::HashMap::<u32, Vec<(usize, f64, f64)>>::new();
608 if let Some(loads) = root.get("loads").and_then(|v| v.as_array()) {
609 for (idx, load) in loads.iter().enumerate() {
610 if let Some(bus) = load.get("bus").and_then(|v| v.as_u64()) {
611 let pd = load
612 .get("active_power_demand_mw")
613 .and_then(|v| v.as_f64())
614 .unwrap_or(0.0);
615 let qd = load
616 .get("reactive_power_demand_mvar")
617 .and_then(|v| v.as_f64())
618 .unwrap_or(0.0);
619 loads_by_bus
620 .entry(bus as u32)
621 .or_default()
622 .push((idx, pd, qd));
623 }
624 }
625 }
626
627 let mut load_updates: Vec<(usize, f64, f64)> = Vec::new();
628 let mut synthetic_loads: Vec<serde_json::Value> = Vec::new();
629 if let Some(buses) = root.get_mut("buses").and_then(|v| v.as_array_mut()) {
630 for bus_val in buses.iter_mut() {
631 if let Some(bus_obj) = bus_val.as_object_mut() {
632 let pd = bus_obj
633 .get("active_power_demand_mw")
634 .and_then(|v| v.as_f64())
635 .unwrap_or(0.0);
636 let qd = bus_obj
637 .get("reactive_power_demand_mvar")
638 .and_then(|v| v.as_f64())
639 .unwrap_or(0.0);
640 let bus_number = bus_obj.get("number").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
641
642 bus_obj.remove("active_power_demand_mw");
644 bus_obj.remove("reactive_power_demand_mvar");
645
646 if pd.abs() > 1e-12 || qd.abs() > 1e-12 {
647 let matches_legacy = |existing_pd: f64, existing_qd: f64| {
648 (existing_pd - pd).abs() <= 1e-9 && (existing_qd - qd).abs() <= 1e-9
649 };
650 match loads_by_bus.get(&bus_number).map(Vec::as_slice) {
651 Some([(idx, existing_pd, existing_qd)]) => {
652 if matches_legacy(*existing_pd, *existing_qd) {
653 continue;
654 }
655 if existing_pd.abs() <= 1e-12 && existing_qd.abs() <= 1e-12 {
656 load_updates.push((*idx, pd, qd));
657 } else {
658 return Err(Error::InvalidDocument(format!(
659 "legacy bus demand on bus {bus_number} conflicts with existing explicit load data"
660 )));
661 }
662 }
663 Some(indices) if indices.len() > 1 => {
664 let total_pd: f64 = indices.iter().map(|(_, p, _)| *p).sum();
665 let total_qd: f64 = indices.iter().map(|(_, _, q)| *q).sum();
666 if matches_legacy(total_pd, total_qd) {
667 continue;
668 }
669 return Err(Error::InvalidDocument(format!(
670 "legacy bus demand on bus {bus_number} conflicts with {} explicit loads already on the bus",
671 indices.len()
672 )));
673 }
674 _ => {
675 let mut load = serde_json::Map::new();
676 load.insert("bus".to_string(), serde_json::json!(bus_number));
677 load.insert(
678 "id".to_string(),
679 serde_json::json!(format!("__migrated_{}", bus_number)),
680 );
681 load.insert(
682 "active_power_demand_mw".to_string(),
683 serde_json::json!(pd),
684 );
685 load.insert(
686 "reactive_power_demand_mvar".to_string(),
687 serde_json::json!(qd),
688 );
689 load.insert("in_service".to_string(), serde_json::json!(true));
690 synthetic_loads.push(serde_json::Value::Object(load));
691 }
692 }
693 }
694 }
695 }
696 }
697
698 if !load_updates.is_empty() {
699 let loads = root
700 .get_mut("loads")
701 .and_then(|v| v.as_array_mut())
702 .ok_or_else(|| Error::InvalidDocument("missing 'loads' field".to_string()))?;
703 for (idx, pd, qd) in load_updates {
704 let Some(load_obj) = loads.get_mut(idx).and_then(|v| v.as_object_mut()) else {
705 return Err(Error::InvalidDocument(format!(
706 "legacy bus demand migration failed because load index {idx} is not an object"
707 )));
708 };
709 let existing_pd = load_obj
710 .get("active_power_demand_mw")
711 .and_then(|v| v.as_f64())
712 .unwrap_or(0.0);
713 let existing_qd = load_obj
714 .get("reactive_power_demand_mvar")
715 .and_then(|v| v.as_f64())
716 .unwrap_or(0.0);
717 load_obj.insert(
718 "active_power_demand_mw".to_string(),
719 serde_json::json!(existing_pd + pd),
720 );
721 load_obj.insert(
722 "reactive_power_demand_mvar".to_string(),
723 serde_json::json!(existing_qd + qd),
724 );
725 }
726 }
727
728 if !synthetic_loads.is_empty() {
730 let loads_value = root.entry("loads").or_insert_with(|| serde_json::json!([]));
731 let Some(loads) = loads_value.as_array_mut() else {
732 return Err(Error::InvalidDocument(
733 "legacy demand migration requires `loads` to be an array".to_string(),
734 ));
735 };
736 loads.extend(synthetic_loads);
737 }
738
739 Ok(json)
740}
741
742fn migrate_legacy_market_layout(mut json: serde_json::Value) -> Result<serde_json::Value, Error> {
754 let Some(root) = json.as_object_mut() else {
755 return Ok(json);
756 };
757
758 migrate_legacy_generator_fields(root)?;
759 migrate_legacy_dispatchable_loads(root)?;
760 migrate_legacy_pumped_hydro_units(root)?;
761 migrate_legacy_market_sections(root)?;
762
763 Ok(json)
764}
765
766fn migrate_legacy_generator_fields(
767 root: &mut serde_json::Map<String, serde_json::Value>,
768) -> Result<(), Error> {
769 let Some(generators) = root
770 .get_mut("generators")
771 .and_then(|value| value.as_array_mut())
772 else {
773 return Ok(());
774 };
775
776 for (idx, generator) in generators.iter_mut().enumerate() {
777 let Some(generator_obj) = generator.as_object_mut() else {
778 return Err(Error::InvalidDocument(format!(
779 "legacy generator migration failed because generator index {idx} is not an object"
780 )));
781 };
782
783 migrate_legacy_generator_type(generator_obj);
784
785 let fault_data = take_legacy_fields(
786 generator_obj,
787 &[
788 ("xs", "xs"),
789 ("x2_pu", "x2_pu"),
790 ("r2_pu", "r2_pu"),
791 ("x0_pu", "x0_pu"),
792 ("r0_pu", "r0_pu"),
793 ("zn", "zn"),
794 ],
795 );
796 let inverter = take_legacy_fields(
797 generator_obj,
798 &[
799 ("s_rated_mva", "s_rated_mva"),
800 ("p_available_mw", "p_available_mw"),
801 ("curtailable", "curtailable"),
802 ("grid_forming", "grid_forming"),
803 ("inverter_loss_a_mw", "inverter_loss_a_mw"),
804 ("inverter_loss_b", "inverter_loss_b_pu"),
805 ("inverter_loss_b_pu", "inverter_loss_b_pu"),
806 ],
807 );
808 let commitment = take_legacy_fields(
809 generator_obj,
810 &[
811 ("commitment_status", "status"),
812 ("p_ecomin", "p_ecomin"),
813 ("p_ecomax", "p_ecomax"),
814 ("p_emergency_min", "p_emergency_min"),
815 ("p_emergency_max", "p_emergency_max"),
816 ("p_reg_min", "p_reg_min"),
817 ("p_reg_max", "p_reg_max"),
818 ("min_up_time_hr", "min_up_time_hr"),
819 ("min_down_time_hr", "min_down_time_hr"),
820 ("max_up_time_hr", "max_up_time_hr"),
821 ("min_run_at_pmin_hr", "min_run_at_pmin_hr"),
822 ("max_starts_per_day", "max_starts_per_day"),
823 ("max_starts_per_week", "max_starts_per_week"),
824 ("max_energy_mwh_per_day", "max_energy_mwh_per_day"),
825 ("shutdown_ramp_mw_per_min", "shutdown_ramp_mw_per_min"),
826 ("startup_ramp_mw_per_min", "startup_ramp_mw_per_min"),
827 ("forbidden_zones", "forbidden_zones"),
828 ("hours_online", "hours_online"),
829 ("hours_offline", "hours_offline"),
830 ],
831 );
832 let ramping = take_legacy_fields(
833 generator_obj,
834 &[
835 ("ramp_up_curve", "ramp_up_curve"),
836 ("ramp_down_curve", "ramp_down_curve"),
837 ("emergency_ramp_up_curve", "emergency_ramp_up_curve"),
838 ("emergency_ramp_down_curve", "emergency_ramp_down_curve"),
839 ("reg_ramp_up_curve", "reg_ramp_up_curve"),
840 ("reg_ramp_down_curve", "reg_ramp_down_curve"),
841 ],
842 );
843 let fuel = take_legacy_fields(
844 generator_obj,
845 &[
846 ("fuel_type", "fuel_type"),
847 ("heat_rate_btu_mwh", "heat_rate_btu_mwh"),
848 ("primary_fuel", "primary_fuel"),
849 ("backup_fuel", "backup_fuel"),
850 ("fuel_switch_time_min", "fuel_switch_time_min"),
851 ("on_backup_fuel", "on_backup_fuel"),
852 ("emission_rates", "emission_rates"),
853 ],
854 );
855 let market = take_legacy_fields(
856 generator_obj,
857 &[
858 ("energy_offer", "energy_offer"),
859 ("reserve_offers", "reserve_offers"),
860 ("qualifications", "qualifications"),
861 ],
862 );
863 let reactive_capability = take_legacy_fields(generator_obj, &[("pq_curve", "pq_curve")]);
864
865 merge_legacy_generator_group(generator_obj, "fault_data", fault_data)?;
866 merge_legacy_generator_group(generator_obj, "inverter", inverter)?;
867 merge_legacy_generator_group(generator_obj, "commitment", commitment)?;
868 merge_legacy_generator_group(generator_obj, "ramping", ramping)?;
869 merge_legacy_generator_group(generator_obj, "fuel", fuel)?;
870 merge_legacy_generator_group(generator_obj, "market", market)?;
871 merge_legacy_generator_group(generator_obj, "reactive_capability", reactive_capability)?;
872 }
873
874 Ok(())
875}
876
877fn migrate_legacy_generator_type(generator_obj: &mut serde_json::Map<String, serde_json::Value>) {
878 let Some(legacy_type) = generator_obj
879 .get("gen_type")
880 .and_then(|value| value.as_str())
881 else {
882 return;
883 };
884 let replacement = match legacy_type {
885 "Wind" => {
886 generator_obj
887 .entry("technology".to_string())
888 .or_insert_with(|| serde_json::json!("Wind"));
889 Some("InverterBased")
890 }
891 "Solar" => {
892 generator_obj
893 .entry("technology".to_string())
894 .or_insert_with(|| serde_json::json!("SolarPv"));
895 Some("InverterBased")
896 }
897 "InverterOther" => Some("InverterBased"),
898 _ => None,
899 };
900 if let Some(value) = replacement {
901 generator_obj.insert("gen_type".to_string(), serde_json::json!(value));
902 }
903}
904
905fn take_legacy_fields(
906 obj: &mut serde_json::Map<String, serde_json::Value>,
907 mappings: &[(&str, &str)],
908) -> serde_json::Map<String, serde_json::Value> {
909 let mut nested = serde_json::Map::new();
910 for (old_key, new_key) in mappings {
911 if let Some(value) = obj.remove(*old_key) {
912 nested.entry((*new_key).to_string()).or_insert(value);
913 }
914 }
915 nested
916}
917
918fn merge_legacy_generator_group(
919 generator_obj: &mut serde_json::Map<String, serde_json::Value>,
920 group_key: &str,
921 legacy_fields: serde_json::Map<String, serde_json::Value>,
922) -> Result<(), Error> {
923 if legacy_fields.is_empty() {
924 return Ok(());
925 }
926 let nested = generator_obj
927 .entry(group_key.to_string())
928 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
929 let nested_obj = nested.as_object_mut().ok_or_else(|| {
930 Error::InvalidDocument(format!(
931 "legacy generator migration requires '{group_key}' to be an object"
932 ))
933 })?;
934 for (key, value) in legacy_fields {
935 nested_obj.entry(key).or_insert(value);
936 }
937 Ok(())
938}
939
940fn migrate_legacy_dispatchable_loads(
941 root: &mut serde_json::Map<String, serde_json::Value>,
942) -> Result<(), Error> {
943 let Some(dispatchable_loads) = root.remove("dispatchable_loads") else {
944 return Ok(());
945 };
946 let Some(loads) = dispatchable_loads.as_array() else {
947 return Err(Error::InvalidDocument(
948 "legacy market migration requires 'dispatchable_loads' to be an array".to_string(),
949 ));
950 };
951
952 let bus_numbers = bus_numbers_by_index(root)?;
953 let mut migrated = Vec::with_capacity(loads.len());
954 for (idx, load) in loads.iter().enumerate() {
955 let Some(load_obj) = load.as_object() else {
956 return Err(Error::InvalidDocument(format!(
957 "legacy dispatchable-load migration failed because resource index {idx} is not an object"
958 )));
959 };
960 let mut load_obj = load_obj.clone();
961 if !load_obj.contains_key("bus")
962 && let Some(bus_idx) = load_obj.remove("bus_idx")
963 {
964 let Some(bus_idx) = bus_idx.as_u64() else {
965 return Err(Error::InvalidDocument(format!(
966 "legacy dispatchable-load bus_idx at index {idx} must be an unsigned integer"
967 )));
968 };
969 let Some(&bus_number) = bus_numbers.get(bus_idx as usize) else {
970 return Err(Error::InvalidDocument(format!(
971 "legacy dispatchable-load bus_idx {bus_idx} is out of range"
972 )));
973 };
974 load_obj.insert("bus".to_string(), serde_json::json!(bus_number));
975 }
976 migrated.push(serde_json::Value::Object(load_obj));
977 }
978
979 insert_market_data_section(
980 root,
981 "dispatchable_loads",
982 serde_json::Value::Array(migrated),
983 )
984}
985
986fn migrate_legacy_pumped_hydro_units(
987 root: &mut serde_json::Map<String, serde_json::Value>,
988) -> Result<(), Error> {
989 let Some(pumped_hydro_units) = root.remove("pumped_hydro_units") else {
990 return Ok(());
991 };
992 let Some(units) = pumped_hydro_units.as_array() else {
993 return Err(Error::InvalidDocument(
994 "legacy market migration requires 'pumped_hydro_units' to be an array".to_string(),
995 ));
996 };
997
998 let generator_refs = generator_refs_by_index(root)?;
999 let mut migrated = Vec::with_capacity(units.len());
1000 for (idx, unit) in units.iter().enumerate() {
1001 let Some(unit_obj) = unit.as_object() else {
1002 return Err(Error::InvalidDocument(format!(
1003 "legacy pumped-hydro migration failed because unit index {idx} is not an object"
1004 )));
1005 };
1006 let mut unit_obj = unit_obj.clone();
1007 if !unit_obj.contains_key("generator")
1008 && let Some(gen_index) = unit_obj.remove("gen_index")
1009 {
1010 let Some(gen_index) = gen_index.as_u64() else {
1011 return Err(Error::InvalidDocument(format!(
1012 "legacy pumped-hydro gen_index at unit {idx} must be an unsigned integer"
1013 )));
1014 };
1015 let Some((bus, id)) = generator_refs.get(gen_index as usize) else {
1016 return Err(Error::InvalidDocument(format!(
1017 "legacy pumped-hydro gen_index {gen_index} is out of range"
1018 )));
1019 };
1020 unit_obj.insert(
1021 "generator".to_string(),
1022 serde_json::json!({ "bus": bus, "id": id }),
1023 );
1024 }
1025 migrated.push(serde_json::Value::Object(unit_obj));
1026 }
1027
1028 insert_market_data_section(
1029 root,
1030 "pumped_hydro_units",
1031 serde_json::Value::Array(migrated),
1032 )
1033}
1034
1035fn migrate_legacy_market_sections(
1036 root: &mut serde_json::Map<String, serde_json::Value>,
1037) -> Result<(), Error> {
1038 for section in [
1039 "combined_cycle_plants",
1040 "outage_schedule",
1041 "reserve_zones",
1042 "ambient",
1043 "emission_policy",
1044 "market_rules",
1045 ] {
1046 if let Some(value) = root.remove(section) {
1047 insert_market_data_section(root, section, value)?;
1048 }
1049 }
1050 Ok(())
1051}
1052
1053fn insert_market_data_section(
1054 root: &mut serde_json::Map<String, serde_json::Value>,
1055 section: &str,
1056 value: serde_json::Value,
1057) -> Result<(), Error> {
1058 let market_data = root
1059 .entry("market_data".to_string())
1060 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
1061 let market_data_obj = market_data.as_object_mut().ok_or_else(|| {
1062 Error::InvalidDocument(
1063 "legacy market migration requires 'market_data' to be an object".to_string(),
1064 )
1065 })?;
1066 market_data_obj.entry(section.to_string()).or_insert(value);
1067 Ok(())
1068}
1069
1070fn bus_numbers_by_index(
1071 root: &serde_json::Map<String, serde_json::Value>,
1072) -> Result<Vec<u32>, Error> {
1073 let Some(buses) = root.get("buses").and_then(|value| value.as_array()) else {
1074 return Ok(Vec::new());
1075 };
1076
1077 let mut numbers = Vec::with_capacity(buses.len());
1078 for (idx, bus) in buses.iter().enumerate() {
1079 let Some(number) = bus.get("number").and_then(|value| value.as_u64()) else {
1080 return Err(Error::InvalidDocument(format!(
1081 "legacy market migration requires bus index {idx} to carry an unsigned 'number'"
1082 )));
1083 };
1084 numbers.push(number as u32);
1085 }
1086 Ok(numbers)
1087}
1088
1089fn generator_refs_by_index(
1090 root: &serde_json::Map<String, serde_json::Value>,
1091) -> Result<Vec<(u32, String)>, Error> {
1092 let Some(generators) = root.get("generators").and_then(|value| value.as_array()) else {
1093 return Ok(Vec::new());
1094 };
1095
1096 let mut refs = Vec::with_capacity(generators.len());
1097 for (idx, generator) in generators.iter().enumerate() {
1098 let Some(bus) = generator.get("bus").and_then(|value| value.as_u64()) else {
1099 return Err(Error::InvalidDocument(format!(
1100 "legacy market migration requires generator index {idx} to carry an unsigned 'bus'"
1101 )));
1102 };
1103 let id = generator
1104 .get("id")
1105 .and_then(|value| value.as_str())
1106 .ok_or_else(|| {
1107 Error::InvalidDocument(format!(
1108 "legacy market migration requires generator index {idx} to carry a string 'id'"
1109 ))
1110 })?
1111 .to_string();
1112 refs.push((bus as u32, id));
1113 }
1114 Ok(refs)
1115}
1116
1117fn value_to_json(value: SerdeValue) -> Result<serde_json::Value, Error> {
1118 use serde_json::{Map, Number, Value};
1119
1120 fn special_float(value: &str) -> Value {
1121 Value::Object(Map::from_iter([(
1122 SPECIAL_FLOAT_TAG.to_string(),
1123 Value::String(value.to_string()),
1124 )]))
1125 }
1126
1127 fn special_bytes(bytes: Vec<u8>) -> Value {
1128 Value::Object(Map::from_iter([(
1129 SPECIAL_BYTES_TAG.to_string(),
1130 Value::Array(
1131 bytes
1132 .into_iter()
1133 .map(|byte| Value::Number(Number::from(byte)))
1134 .collect(),
1135 ),
1136 )]))
1137 }
1138
1139 fn special_map(entries: Vec<Value>) -> Value {
1140 Value::Object(Map::from_iter([(
1141 SPECIAL_MAP_TAG.to_string(),
1142 Value::Array(entries),
1143 )]))
1144 }
1145
1146 fn map_key_to_string(key: SerdeValue) -> Result<String, Error> {
1147 Ok(match key {
1148 SerdeValue::Bool(value) => value.to_string(),
1149 SerdeValue::U8(value) => value.to_string(),
1150 SerdeValue::U16(value) => value.to_string(),
1151 SerdeValue::U32(value) => value.to_string(),
1152 SerdeValue::U64(value) => value.to_string(),
1153 SerdeValue::I8(value) => value.to_string(),
1154 SerdeValue::I16(value) => value.to_string(),
1155 SerdeValue::I32(value) => value.to_string(),
1156 SerdeValue::I64(value) => value.to_string(),
1157 SerdeValue::F32(value) => value.to_string(),
1158 SerdeValue::F64(value) => value.to_string(),
1159 SerdeValue::Char(value) => value.to_string(),
1160 SerdeValue::String(value) => value,
1161 other => {
1162 return Err(Error::InvalidTaggedValue(format!(
1163 "unsupported map key value {other:?}"
1164 )));
1165 }
1166 })
1167 }
1168
1169 Ok(match value {
1170 SerdeValue::Bool(value) => Value::Bool(value),
1171 SerdeValue::U8(value) => Value::Number(Number::from(value)),
1172 SerdeValue::U16(value) => Value::Number(Number::from(value)),
1173 SerdeValue::U32(value) => Value::Number(Number::from(value)),
1174 SerdeValue::U64(value) => Value::Number(Number::from(value)),
1175 SerdeValue::I8(value) => Value::Number(Number::from(value)),
1176 SerdeValue::I16(value) => Value::Number(Number::from(value)),
1177 SerdeValue::I32(value) => Value::Number(Number::from(value)),
1178 SerdeValue::I64(value) => Value::Number(Number::from(value)),
1179 SerdeValue::F32(value) => {
1180 if value.is_finite() {
1181 Value::Number(Number::from_f64(value as f64).expect("finite f32 is JSON-safe"))
1182 } else if value.is_nan() {
1183 special_float("NaN")
1184 } else if value.is_sign_positive() {
1185 special_float("Infinity")
1186 } else {
1187 special_float("-Infinity")
1188 }
1189 }
1190 SerdeValue::F64(value) => {
1191 if value.is_finite() {
1192 Value::Number(Number::from_f64(value).expect("finite f64 is JSON-safe"))
1193 } else if value.is_nan() {
1194 special_float("NaN")
1195 } else if value.is_sign_positive() {
1196 special_float("Infinity")
1197 } else {
1198 special_float("-Infinity")
1199 }
1200 }
1201 SerdeValue::Char(value) => Value::String(value.to_string()),
1202 SerdeValue::String(value) => Value::String(value),
1203 SerdeValue::Unit | SerdeValue::Option(None) => Value::Null,
1204 SerdeValue::Option(Some(value)) | SerdeValue::Newtype(value) => value_to_json(*value)?,
1205 SerdeValue::Seq(values) => Value::Array(
1206 values
1207 .into_iter()
1208 .map(value_to_json)
1209 .collect::<Result<Vec<_>, _>>()?,
1210 ),
1211 SerdeValue::Map(values) => {
1212 let all_string_keys = values
1213 .keys()
1214 .all(|key| matches!(key, SerdeValue::String(_)));
1215 if all_string_keys {
1216 let mut object = Map::with_capacity(values.len());
1217 for (key, value) in values {
1218 object.insert(map_key_to_string(key)?, value_to_json(value)?);
1219 }
1220 Value::Object(object)
1221 } else {
1222 let mut entries = Vec::with_capacity(values.len());
1223 for (key, value) in values {
1224 entries.push(Value::Array(vec![
1225 value_to_json(key)?,
1226 value_to_json(value)?,
1227 ]));
1228 }
1229 special_map(entries)
1230 }
1231 }
1232 SerdeValue::Bytes(bytes) => special_bytes(bytes),
1233 })
1234}
1235
1236fn json_to_value(value: serde_json::Value) -> Result<SerdeValue, Error> {
1237 use serde_json::Value;
1238
1239 fn parse_special_float(value: &str) -> Result<SerdeValue, Error> {
1240 match value {
1241 "NaN" => Ok(SerdeValue::F64(f64::NAN)),
1242 "Infinity" => Ok(SerdeValue::F64(f64::INFINITY)),
1243 "-Infinity" => Ok(SerdeValue::F64(f64::NEG_INFINITY)),
1244 other => Err(Error::InvalidTaggedValue(format!(
1245 "unknown special float marker {other}"
1246 ))),
1247 }
1248 }
1249
1250 Ok(match value {
1251 Value::Null => SerdeValue::Option(None),
1252 Value::Bool(value) => SerdeValue::Bool(value),
1253 Value::Number(value) => {
1254 if let Some(value) = value.as_i64() {
1255 SerdeValue::I64(value)
1256 } else if let Some(value) = value.as_u64() {
1257 SerdeValue::U64(value)
1258 } else if let Some(value) = value.as_f64() {
1259 SerdeValue::F64(value)
1260 } else {
1261 return Err(Error::InvalidTaggedValue(
1262 "unsupported JSON number representation".to_string(),
1263 ));
1264 }
1265 }
1266 Value::String(value) => SerdeValue::String(value),
1267 Value::Array(values) => SerdeValue::Seq(
1268 values
1269 .into_iter()
1270 .map(json_to_value)
1271 .collect::<Result<Vec<_>, _>>()?,
1272 ),
1273 Value::Object(mut object) => {
1274 if object.len() == 1 {
1275 if let Some(Value::String(value)) = object.remove(SPECIAL_FLOAT_TAG) {
1276 return parse_special_float(&value);
1277 }
1278 if let Some(Value::Array(values)) = object.remove(SPECIAL_BYTES_TAG) {
1279 let mut bytes = Vec::with_capacity(values.len());
1280 for value in values {
1281 let Value::Number(number) = value else {
1282 return Err(Error::InvalidTaggedValue(
1283 "byte tag must contain only numbers".to_string(),
1284 ));
1285 };
1286 let Some(value) = number.as_u64() else {
1287 return Err(Error::InvalidTaggedValue(
1288 "byte tag numbers must be unsigned integers".to_string(),
1289 ));
1290 };
1291 bytes.push(u8::try_from(value).map_err(|_| {
1292 Error::InvalidTaggedValue(format!(
1293 "byte tag value {value} is out of range"
1294 ))
1295 })?);
1296 }
1297 return Ok(SerdeValue::Bytes(bytes));
1298 }
1299 if let Some(Value::Array(entries)) = object.remove(SPECIAL_MAP_TAG) {
1300 let mut map = std::collections::BTreeMap::new();
1301 for entry in entries {
1302 let Value::Array(mut pair) = entry else {
1303 return Err(Error::InvalidTaggedValue(
1304 "map tag must contain [key, value] pairs".to_string(),
1305 ));
1306 };
1307 if pair.len() != 2 {
1308 return Err(Error::InvalidTaggedValue(
1309 "map tag pairs must contain exactly two values".to_string(),
1310 ));
1311 }
1312 let value = json_to_value(pair.pop().expect("pair length checked"))?;
1313 let key = json_to_value(pair.pop().expect("pair length checked"))?;
1314 map.insert(key, value);
1315 }
1316 return Ok(SerdeValue::Map(map));
1317 }
1318 }
1319
1320 let mut map = std::collections::BTreeMap::new();
1321 for (key, value) in object {
1322 map.insert(SerdeValue::String(key), json_to_value(value)?);
1323 }
1324 SerdeValue::Map(map)
1325 }
1326 })
1327}
1328
1329#[cfg(test)]
1330mod tests {
1331 use super::*;
1332 use serde::Serialize;
1333 use surge_network::network::generator::{CommitmentStatus, GenType, GeneratorTechnology};
1334 use surge_network::network::{Branch, Bus, BusType, Generator};
1335 use surge_solution::{
1336 AuditableSolution, ObjectiveLedgerMismatch, ObjectiveLedgerScopeKind, SolutionAuditReport,
1337 };
1338
1339 #[derive(Clone, Serialize)]
1340 struct FakeAuditedSolution {
1341 total_cost: f64,
1342 #[serde(default)]
1343 audit: SolutionAuditReport,
1344 #[serde(skip)]
1345 computed_audit: SolutionAuditReport,
1346 }
1347
1348 impl AuditableSolution for FakeAuditedSolution {
1349 fn computed_solution_audit(&self) -> SolutionAuditReport {
1350 self.computed_audit.clone()
1351 }
1352 }
1353
1354 #[test]
1355 fn test_roundtrip() {
1356 let mut network = Network::new("test_json");
1357 network.base_mva = 100.0;
1358 network.buses.push(Bus::new(1, BusType::Slack, 138.0));
1359 network.buses.push(Bus::new(2, BusType::PQ, 138.0));
1360 network.generators.push(Generator::new(1, 100.0, 1.06));
1361 network
1362 .branches
1363 .push(Branch::new_line(1, 2, 0.01, 0.1, 0.02));
1364
1365 let json_str = to_string(&network, false).expect("failed to serialize");
1366 assert!(json_str.contains(SURGE_JSON_FORMAT));
1367 assert!(json_str.contains(SURGE_JSON_SCHEMA_VERSION));
1368 assert!(json_str.contains(META_FIELD));
1369 let parsed = parse_str(&json_str).expect("failed to parse");
1370
1371 assert_eq!(parsed.name, "test_json");
1372 assert_eq!(parsed.base_mva, 100.0);
1373 assert_eq!(parsed.n_buses(), 2);
1374 assert_eq!(parsed.generators.len(), 1);
1375 assert_eq!(parsed.n_branches(), 1);
1376 assert!((parsed.buses[0].base_kv - 138.0).abs() < 1e-10);
1377 }
1378
1379 #[test]
1380 fn test_legacy_bus_demand_duplicate_with_existing_load_is_dropped() {
1381 let mut network = Network::new("merge_test");
1382 network.base_mva = 100.0;
1383 network.buses.push(Bus::new(1, BusType::Slack, 138.0));
1384 network
1385 .loads
1386 .push(surge_network::network::Load::new(1, 75.0, 30.0));
1387
1388 let json_str = to_string(&network, false).expect("failed to serialize");
1389 let mut doc: serde_json::Value = serde_json::from_str(&json_str).expect("valid json");
1390 let network_obj = doc
1391 .get_mut("network")
1392 .and_then(serde_json::Value::as_object_mut)
1393 .expect("serialized document should contain a network object");
1394 let buses = network_obj
1395 .get_mut("buses")
1396 .and_then(serde_json::Value::as_array_mut)
1397 .expect("serialized network should contain buses");
1398 buses[0]
1399 .as_object_mut()
1400 .expect("bus entry should be an object")
1401 .insert(
1402 "active_power_demand_mw".to_string(),
1403 serde_json::json!(75.0),
1404 );
1405 buses[0]
1406 .as_object_mut()
1407 .expect("bus entry should be an object")
1408 .insert(
1409 "reactive_power_demand_mvar".to_string(),
1410 serde_json::json!(30.0),
1411 );
1412
1413 let parsed =
1414 parse_str(&doc.to_string()).expect("duplicate legacy demand should be ignored");
1415 assert_eq!(parsed.loads.len(), 1);
1416 assert!((parsed.loads[0].active_power_demand_mw - 75.0).abs() < 1e-10);
1417 assert!((parsed.loads[0].reactive_power_demand_mvar - 30.0).abs() < 1e-10);
1418 }
1419
1420 #[test]
1421 fn test_legacy_bus_demand_conflicting_with_existing_load_errors() {
1422 let mut network = Network::new("merge_test_conflict");
1423 network.base_mva = 100.0;
1424 network.buses.push(Bus::new(1, BusType::Slack, 138.0));
1425 network
1426 .loads
1427 .push(surge_network::network::Load::new(1, 25.0, 10.0));
1428
1429 let json_str = to_string(&network, false).expect("failed to serialize");
1430 let mut doc: serde_json::Value = serde_json::from_str(&json_str).expect("valid json");
1431 let network_obj = doc
1432 .get_mut("network")
1433 .and_then(serde_json::Value::as_object_mut)
1434 .expect("serialized document should contain a network object");
1435 let buses = network_obj
1436 .get_mut("buses")
1437 .and_then(serde_json::Value::as_array_mut)
1438 .expect("serialized network should contain buses");
1439 buses[0]
1440 .as_object_mut()
1441 .expect("bus entry should be an object")
1442 .insert(
1443 "active_power_demand_mw".to_string(),
1444 serde_json::json!(75.0),
1445 );
1446 buses[0]
1447 .as_object_mut()
1448 .expect("bus entry should be an object")
1449 .insert(
1450 "reactive_power_demand_mvar".to_string(),
1451 serde_json::json!(30.0),
1452 );
1453
1454 let err =
1455 parse_str(&doc.to_string()).expect_err("conflicting mixed-format demand should error");
1456 assert!(
1457 err.to_string()
1458 .contains("conflicts with existing explicit load data"),
1459 "unexpected error: {err}"
1460 );
1461 }
1462
1463 #[test]
1464 fn test_legacy_bus_demand_rejects_non_array_loads_field() {
1465 let mut network = Network::new("bad-loads-shape");
1466 network.buses.push(Bus::new(1, BusType::Slack, 138.0));
1467
1468 let mut doc = encode_document(&network).expect("serialize document");
1469 let network_obj = doc
1470 .get_mut("network")
1471 .and_then(serde_json::Value::as_object_mut)
1472 .expect("serialized network should contain an object");
1473 let buses = network_obj
1474 .get_mut("buses")
1475 .and_then(serde_json::Value::as_array_mut)
1476 .expect("serialized network should contain buses");
1477 let bus = buses[0]
1478 .as_object_mut()
1479 .expect("serialized bus should be an object");
1480 bus.insert(
1481 "active_power_demand_mw".to_string(),
1482 serde_json::json!(10.0),
1483 );
1484 bus.insert(
1485 "reactive_power_demand_mvar".to_string(),
1486 serde_json::json!(5.0),
1487 );
1488 network_obj.insert("loads".to_string(), serde_json::json!({}));
1489
1490 let err = parse_str(&doc.to_string()).expect_err("non-array loads should be rejected");
1491 assert!(matches!(err, Error::InvalidDocument(msg) if msg.contains("loads")));
1492 }
1493
1494 #[test]
1495 fn test_legacy_flat_generator_dispatch_fields_are_migrated() {
1496 let mut network = Network::new("legacy_generator_fields");
1497 network.base_mva = 100.0;
1498 network.buses.push(Bus::new(1, BusType::Slack, 138.0));
1499 network.generators.push(Generator::new(1, 50.0, 1.0));
1500
1501 let mut doc = encode_document(&network).expect("serialize document");
1502 let network_obj = doc
1503 .get_mut("network")
1504 .and_then(serde_json::Value::as_object_mut)
1505 .expect("serialized document should contain a network object");
1506 let generator = network_obj
1507 .get_mut("generators")
1508 .and_then(serde_json::Value::as_array_mut)
1509 .and_then(|generators| generators.first_mut())
1510 .and_then(serde_json::Value::as_object_mut)
1511 .expect("serialized network should contain a generator object");
1512 generator.insert(
1513 "commitment_status".to_string(),
1514 serde_json::json!("MustRun"),
1515 );
1516 generator.insert("min_up_time_hr".to_string(), serde_json::json!(4.0));
1517 generator.insert("hours_online".to_string(), serde_json::json!(3.0));
1518 generator.insert("ramp_up_curve".to_string(), serde_json::json!([[0.0, 6.0]]));
1519 generator.insert(
1520 "reserve_offers".to_string(),
1521 serde_json::json!([{ "product_id": "spin", "capacity_mw": 20.0, "cost_per_mwh": 4.0 }]),
1522 );
1523 generator.insert(
1524 "qualifications".to_string(),
1525 serde_json::json!({ "spin": true, "reg_up": false }),
1526 );
1527 generator.insert("fuel_type".to_string(), serde_json::json!("gas"));
1528 generator.insert(
1529 "emission_rates".to_string(),
1530 serde_json::json!({ "co2": 0.42, "nox": 0.01, "so2": 0.0, "pm25": 0.0 }),
1531 );
1532 generator.insert("curtailable".to_string(), serde_json::json!(true));
1533 generator.insert("grid_forming".to_string(), serde_json::json!(true));
1534 generator.insert("inverter_loss_a_mw".to_string(), serde_json::json!(0.5));
1535 generator.insert("inverter_loss_b".to_string(), serde_json::json!(0.02));
1536
1537 let parsed =
1538 parse_str(&doc.to_string()).expect("legacy flat generator fields should migrate");
1539 let generator = &parsed.generators[0];
1540 let commitment = generator
1541 .commitment
1542 .as_ref()
1543 .expect("commitment fields should be nested during migration");
1544 assert_eq!(commitment.status, CommitmentStatus::MustRun);
1545 assert_eq!(commitment.min_up_time_hr, Some(4.0));
1546 assert!((commitment.hours_online - 3.0).abs() < 1e-9);
1547 assert_eq!(
1548 generator
1549 .ramping
1550 .as_ref()
1551 .expect("ramp fields should migrate")
1552 .ramp_up_curve,
1553 vec![(0.0, 6.0)]
1554 );
1555 let market = generator
1556 .market
1557 .as_ref()
1558 .expect("market fields should migrate");
1559 assert_eq!(market.reserve_offers.len(), 1);
1560 assert_eq!(market.qualifications.get("spin"), Some(&true));
1561 let fuel = generator.fuel.as_ref().expect("fuel fields should migrate");
1562 assert_eq!(fuel.fuel_type.as_deref(), Some("gas"));
1563 assert!((fuel.emission_rates.co2 - 0.42).abs() < 1e-9);
1564 let inverter = generator
1565 .inverter
1566 .as_ref()
1567 .expect("legacy inverter fields should migrate");
1568 assert!(inverter.curtailable);
1569 assert!(inverter.grid_forming);
1570 assert!((inverter.inverter_loss_a_mw - 0.5).abs() < 1e-9);
1571 assert!((inverter.inverter_loss_b_pu - 0.02).abs() < 1e-9);
1572 }
1573
1574 #[test]
1575 fn test_legacy_generator_type_is_narrowed_to_electrical_class() {
1576 let mut network = Network::new("legacy_generator_type");
1577 network.base_mva = 100.0;
1578 network.buses.push(Bus::new(1, BusType::Slack, 138.0));
1579 network.generators.push(Generator::new(1, 50.0, 1.0));
1580
1581 let mut doc = encode_document(&network).expect("serialize document");
1582 let network_obj = doc
1583 .get_mut("network")
1584 .and_then(serde_json::Value::as_object_mut)
1585 .expect("serialized document should contain a network object");
1586 let generator = network_obj
1587 .get_mut("generators")
1588 .and_then(serde_json::Value::as_array_mut)
1589 .and_then(|generators| generators.first_mut())
1590 .and_then(serde_json::Value::as_object_mut)
1591 .expect("serialized network should contain a generator object");
1592 generator.insert("gen_type".to_string(), serde_json::json!("Wind"));
1593
1594 let parsed = parse_str(&doc.to_string()).expect("legacy generator type should migrate");
1595 let generator = &parsed.generators[0];
1596 assert_eq!(generator.gen_type, GenType::InverterBased);
1597 assert_eq!(generator.technology, Some(GeneratorTechnology::Wind));
1598 }
1599
1600 #[test]
1601 fn test_legacy_flat_market_sections_are_nested_under_market_data() {
1602 let mut network = Network::new("legacy_market_sections");
1603 network.base_mva = 100.0;
1604 network.buses.push(Bus::new(1, BusType::Slack, 138.0));
1605 network.buses.push(Bus::new(2, BusType::PQ, 138.0));
1606 network
1607 .generators
1608 .push(Generator::with_id("gen_a", 1, 60.0, 1.0));
1609 network
1610 .generators
1611 .push(Generator::with_id("gen_b", 2, 40.0, 1.0));
1612
1613 let mut doc = encode_document(&network).expect("serialize document");
1614 let network_obj = doc
1615 .get_mut("network")
1616 .and_then(serde_json::Value::as_object_mut)
1617 .expect("serialized document should contain a network object");
1618 network_obj.insert(
1619 "dispatchable_loads".to_string(),
1620 serde_json::json!([{
1621 "bus_idx": 1,
1622 "p_sched_pu": 0.2,
1623 "q_sched_pu": 0.0,
1624 "p_min_pu": 0.0,
1625 "p_max_pu": 0.2,
1626 "q_min_pu": 0.0,
1627 "q_max_pu": 0.0,
1628 "archetype": "Curtailable",
1629 "cost_model": { "LinearCurtailment": { "cost_per_mw": 100.0 } },
1630 "fixed_power_factor": true,
1631 "in_service": true,
1632 "resource_id": "legacy_dr"
1633 }]),
1634 );
1635 network_obj.insert(
1636 "pumped_hydro_units".to_string(),
1637 serde_json::json!([{
1638 "name": "legacy_ph",
1639 "gen_index": 1,
1640 "variable_speed": false,
1641 "pump_mw_fixed": 0.0,
1642 "pump_mw_min": 20.0,
1643 "pump_mw_max": 80.0,
1644 "mode_transition_min": 5.0,
1645 "condenser_capable": false,
1646 "forbidden_zone": null,
1647 "upper_reservoir_mwh": 500.0,
1648 "lower_reservoir_mwh": 1.7976931348623157e308,
1649 "soc_initial_mwh": 250.0,
1650 "soc_min_mwh": 50.0,
1651 "soc_max_mwh": 450.0,
1652 "efficiency_generate": 0.9,
1653 "efficiency_pump": 0.88,
1654 "head_curve": [],
1655 "n_units": 1,
1656 "shared_penstock_mw_max": null,
1657 "min_release_mw": 0.0,
1658 "ramp_rate_mw_per_min": null,
1659 "startup_time_gen_min": 5.0,
1660 "startup_time_pump_min": 10.0,
1661 "startup_cost": 200.0
1662 }]),
1663 );
1664 network_obj.insert(
1665 "combined_cycle_plants".to_string(),
1666 serde_json::json!([{
1667 "name": "legacy_cc",
1668 "configs": [{
1669 "name": "GT_ONLY",
1670 "gen_indices": [0],
1671 "p_min_mw": 20.0,
1672 "p_max_mw": 80.0,
1673 "heat_rate_curve": [],
1674 "energy_offer": null,
1675 "ramp_up_curve": [],
1676 "ramp_down_curve": [],
1677 "no_load_cost": 0.0,
1678 "min_up_time_hr": 1.0,
1679 "min_down_time_hr": 1.0
1680 }],
1681 "transitions": [],
1682 "active_config": "GT_ONLY",
1683 "hours_in_config": 2.0,
1684 "duct_firing_capable": false
1685 }]),
1686 );
1687
1688 let parsed = parse_str(&doc.to_string()).expect("legacy market sections should migrate");
1689 assert_eq!(parsed.market_data.dispatchable_loads.len(), 1);
1690 assert_eq!(parsed.market_data.dispatchable_loads[0].bus, 2);
1691 assert_eq!(
1692 parsed.market_data.dispatchable_loads[0].resource_id,
1693 "legacy_dr"
1694 );
1695 assert_eq!(parsed.market_data.pumped_hydro_units.len(), 1);
1696 assert_eq!(parsed.market_data.pumped_hydro_units[0].generator.bus, 2);
1697 assert_eq!(
1698 parsed.market_data.pumped_hydro_units[0].generator.id,
1699 "gen_b"
1700 );
1701 assert_eq!(parsed.market_data.combined_cycle_plants.len(), 1);
1702 assert_eq!(
1703 parsed.market_data.combined_cycle_plants[0].name,
1704 "legacy_cc"
1705 );
1706 }
1707
1708 #[test]
1709 fn test_file_roundtrip() {
1710 let mut network = Network::new("file_test");
1711 network.buses.push(Bus::new(1, BusType::Slack, 345.0));
1712 network.generators.push(Generator::new(1, 50.0, 1.04));
1713 network
1714 .branches
1715 .push(Branch::new_line(1, 1, 0.0, 0.01, 0.0));
1716
1717 let tmp = std::env::temp_dir().join("surge_test_roundtrip.surge.json");
1718 write_file(&network, &tmp, false).expect("failed to write");
1719 let parsed = parse_file(&tmp).expect("failed to read");
1720 assert_eq!(parsed.name, "file_test");
1721 assert_eq!(parsed.n_buses(), 1);
1722
1723 let _ = std::fs::remove_file(&tmp);
1725 }
1726
1727 #[test]
1728 fn test_non_finite_values_roundtrip() {
1729 let mut network = Network::new("non_finite");
1730 network.buses.push(Bus::new(1, BusType::Slack, 345.0));
1731 let mut generator = Generator::new(1, 50.0, 1.04);
1732 generator.pmax = f64::INFINITY;
1733 generator.qmin = f64::NEG_INFINITY;
1734 network.generators.push(generator);
1735
1736 let json = to_string(&network, false).expect("non-finite values should serialize");
1737 assert!(json.contains(SPECIAL_FLOAT_TAG));
1738
1739 let round_tripped = parse_str(&json).expect("non-finite values should deserialize");
1740 assert!(round_tripped.generators[0].pmax.is_infinite());
1741 assert!(round_tripped.generators[0].pmax.is_sign_positive());
1742 assert!(round_tripped.generators[0].qmin.is_infinite());
1743 assert!(round_tripped.generators[0].qmin.is_sign_negative());
1744 }
1745
1746 #[test]
1747 fn test_zstd_file_roundtrip() {
1748 let mut network = Network::new("zstd_json");
1749 network.buses.push(Bus::new(1, BusType::Slack, 345.0));
1750 let tmp = std::env::temp_dir().join("surge_test_roundtrip.surge.json.zst");
1751 save(&network, &tmp).expect("failed to save zstd json");
1752 let parsed = load(&tmp).expect("failed to load zstd json");
1753 assert_eq!(parsed.name, "zstd_json");
1754 let _ = std::fs::remove_file(&tmp);
1755 }
1756
1757 #[test]
1758 fn test_missing_document_metadata_is_rejected() {
1759 let result = parse_str("{\"base_mva\":100.0}");
1760 assert!(result.is_err(), "bare network JSON should be rejected");
1761 }
1762
1763 #[test]
1764 fn test_unknown_schema_version_is_rejected() {
1765 let result = parse_str(
1766 r#"{
1767 "format": "surge-json",
1768 "schema_version": "999.0.0",
1769 "network": {}
1770 }"#,
1771 );
1772 assert!(result.is_err(), "unknown schema version should be rejected");
1773 }
1774
1775 #[test]
1776 fn test_invalid_meta_profile_is_rejected() {
1777 let result = parse_str(
1778 r#"{
1779 "format": "surge-json",
1780 "schema_version": "0.1.0",
1781 "meta": { "producer": "surge", "profile": "solution" },
1782 "network": {}
1783 }"#,
1784 );
1785 assert!(result.is_err(), "unknown meta profile should be rejected");
1786 }
1787
1788 #[test]
1789 fn test_encode_audited_solution_overwrites_stale_audit_block() {
1790 let solution = FakeAuditedSolution {
1791 total_cost: 123.0,
1792 audit: SolutionAuditReport {
1793 audit_passed: false,
1794 ..Default::default()
1795 },
1796 computed_audit: SolutionAuditReport::from_mismatches(Vec::new()),
1797 };
1798
1799 let json = encode_audited_solution(&solution).expect("audit injection should succeed");
1800 let audit = json
1801 .get("audit")
1802 .and_then(serde_json::Value::as_object)
1803 .expect("encoded solution should carry an audit object");
1804 assert_eq!(
1805 audit
1806 .get("audit_passed")
1807 .and_then(serde_json::Value::as_bool),
1808 Some(true)
1809 );
1810 assert_eq!(
1811 audit
1812 .get("schema_version")
1813 .and_then(serde_json::Value::as_str),
1814 Some(surge_solution::SOLUTION_AUDIT_SCHEMA_VERSION)
1815 );
1816 }
1817
1818 #[test]
1819 fn test_encode_checked_audited_solution_rejects_failed_audit() {
1820 let mismatch = ObjectiveLedgerMismatch {
1821 scope_kind: ObjectiveLedgerScopeKind::DispatchSolution,
1822 scope_id: "summary".to_string(),
1823 field: "total_cost".to_string(),
1824 expected_dollars: 10.0,
1825 actual_dollars: 11.0,
1826 difference: 1.0,
1827 };
1828 let solution = FakeAuditedSolution {
1829 total_cost: 11.0,
1830 audit: SolutionAuditReport::default(),
1831 computed_audit: SolutionAuditReport::from_mismatches(vec![mismatch]),
1832 };
1833
1834 let err = encode_checked_audited_solution(&solution).expect_err("audit must fail fast");
1835 assert!(
1836 err.to_string().contains("solution audit failed"),
1837 "unexpected error: {err}"
1838 );
1839 }
1840}