Skip to main content

surge_io/
json.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! JSON format for native Surge network serialization.
3//!
4//! The native JSON format is a versioned document envelope around the
5//! `surge_network::Network` model:
6//!
7//! ```text
8//! {
9//!   "format": "surge-json",
10//!   "schema_version": "0.1.0",
11//!   "meta": { "producer": "surge", "profile": "network" },
12//!   "network": { ... }
13//! }
14//! ```
15//!
16//! This format is lossless for finite values and preserves `NaN` / infinities
17//! through explicit tagged JSON values rather than silently rewriting them.
18
19use 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
72/// Load a JSON network file from disk.
73pub fn load(path: impl AsRef<Path>) -> Result<Network, Error> {
74    parse_file(path.as_ref())
75}
76
77/// Load a JSON network from an in-memory string.
78pub fn loads(content: &str) -> Result<Network, Error> {
79    parse_str(content)
80}
81
82/// Save a network to a JSON file.
83pub fn save(network: &Network, path: impl AsRef<Path>) -> Result<(), Error> {
84    write_file(network, path.as_ref(), false)
85}
86
87/// Save a network to a JSON file with pretty formatting.
88pub fn save_pretty(network: &Network, path: impl AsRef<Path>) -> Result<(), Error> {
89    write_file(network, path.as_ref(), true)
90}
91
92/// Serialize a network to a JSON string.
93pub fn dumps(network: &Network) -> Result<String, Error> {
94    to_string(network, false)
95}
96
97/// Serialize a network to a pretty JSON string.
98pub fn dumps_pretty(network: &Network) -> Result<String, Error> {
99    to_string(network, true)
100}
101
102// ─── Multi-profile document API ──────────────────────────────────────────────
103
104/// A surge-json document that may carry a network, dispatch request, and/or
105/// dispatch solution.
106///
107/// The `dispatch` and `solution` fields are opaque `serde_json::Value` because
108/// surge-io does not depend on surge-dispatch. Callers deserialize them into
109/// the appropriate typed structs (`DispatchRequest`, `DispatchSolution`).
110#[derive(Debug, Clone)]
111pub struct SurgeDocument {
112    /// The network (always present in a valid surge-json document).
113    pub network: Network,
114    /// Dispatch request data (present when profile is `"dispatch"` or `"results"`).
115    pub dispatch: Option<serde_json::Value>,
116    /// Dispatch solution data (present when profile is `"results"`).
117    pub solution: Option<serde_json::Value>,
118}
119
120/// The profile of a surge-json document, inferred from what's present.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum SurgeJsonProfile {
123    /// Network only.
124    Network,
125    /// Network + dispatch request.
126    Dispatch,
127    /// Network + dispatch request + solution.
128    Results,
129}
130
131impl SurgeDocument {
132    /// Infer the profile from what fields are populated.
133    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
144/// Load a surge-json document that may contain dispatch and/or solution data.
145pub 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
157/// Parse a surge-json document from an in-memory string.
158pub 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
163/// Save a [`SurgeDocument`] to a JSON file with auto-detected zstd compression.
164pub 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
178/// Serialize a [`SurgeDocument`] to a JSON string.
179pub 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
184/// Serialize a solution payload and overwrite its `audit` block with the
185/// freshly computed exact objective-ledger audit report.
186pub 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
196/// Serialize a solution payload with a fresh `audit` block and fail fast if
197/// the exact objective-ledger audit does not pass.
198pub 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    // Validate meta if present, but accept any known profile.
412    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
543/// Migrate legacy `phase_shift_deg`, `phase_min_deg`, `phase_max_deg`, and
544/// `phase_step_deg` branch fields (stored in degrees) to their `_rad`
545/// counterparts (stored in radians).
546///
547/// Old JSON bundles serialised these values in degrees.  The struct fields have
548/// been renamed to `_rad` (with serde aliases for the old names), but the alias
549/// alone cannot convert the unit.  This migration rewrites the JSON *before*
550/// serde deserialisation so the values are in radians when they land on the new
551/// fields.
552fn 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
570/// Remove `old_key` (degrees), convert the value to radians, and insert as
571/// `new_key`.  If `new_key` already exists or `old_key` is absent, do nothing.
572fn 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; // already migrated
579    }
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            // Not a number — put it back so serde can report the error.
586            obj.insert(old_key.to_string(), val);
587        }
588    }
589}
590
591/// Migrate legacy `active_power_demand_mw` / `reactive_power_demand_mvar` fields
592/// from Bus objects into Load objects.
593///
594/// Old JSON files stored demand on buses; the new model stores it exclusively
595/// on `Load` objects. Some legacy files carry *both* representations, often with
596/// the same values duplicated. We therefore:
597/// - drop duplicate legacy demand when explicit load(s) already match it
598/// - seed a single zero-valued load when exactly one explicit load exists but is empty
599/// - create a synthetic load when none exists
600/// - reject inconsistent mixed-format cases instead of silently double-counting
601fn 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                // Remove legacy fields regardless.
643                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    // Append synthetic loads.
729    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
742/// Migrate legacy flat market/dispatch fields into the current nested model.
743///
744/// Older Surge JSON files stored:
745/// - dispatch data directly on `Generator` objects (`commitment_status`,
746///   `reserve_offers`, `emission_rates`, ramp curves, etc.)
747/// - dispatchable loads / pumped hydro / combined-cycle plants as top-level
748///   fields on `Network` rather than under `network.market_data`
749/// - dispatchable-load bus references as `bus_idx` (array index) rather than
750///   canonical external bus numbers
751/// - pumped-hydro generator references as `gen_index` rather than
752///   `GeneratorRef { bus, id }`
753fn 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        // Cleanup
1724        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}