Skip to main content

powerio_pkg/
operating.rs

1//! Replayable operating point overlays for `.pio.json` packages.
2
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4
5use serde::{Deserialize, Serialize};
6use serde_json::{Map, Value, json};
7
8// The bridge shares the GOC3 parser's document walking, so this extractor's
9// section ordering, device row assignment, and cost mapping match the static
10// payload by construction.
11use powerio::format::goc3_bridge::{
12    DeviceTable, SectionItem, cost_at, device_rows, item_uid, number,
13};
14
15use crate::model::ModelPayload;
16
17/// A format neutral series of operating points over a package's static payload.
18#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
19#[non_exhaustive]
20pub struct OperatingPointSeries {
21    /// Shared period count, durations, and labels.
22    pub time_axis: TimeAxis,
23    /// Ordered operating states. Each state is addressed by its `index`.
24    #[serde(default, skip_serializing_if = "Vec::is_empty")]
25    pub points: Vec<OperatingPoint>,
26    /// Metadata from the source format, such as `source_format`.
27    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
28    pub metadata: BTreeMap<String, Value>,
29}
30
31impl OperatingPointSeries {
32    #[must_use]
33    pub fn new(time_axis: TimeAxis, points: Vec<OperatingPoint>) -> Self {
34        Self {
35            time_axis,
36            points,
37            metadata: BTreeMap::new(),
38        }
39    }
40
41    #[must_use]
42    pub fn is_empty(&self) -> bool {
43        self.time_axis.is_empty() && self.points.is_empty() && self.metadata.is_empty()
44    }
45
46    /// Return the first point with `index`.
47    ///
48    /// Use [`OperatingPointSeries::unique_point`] when duplicate indices must be
49    /// rejected instead of collapsed.
50    #[must_use]
51    pub fn point(&self, index: usize) -> Option<&OperatingPoint> {
52        self.points.iter().find(|point| point.index == index)
53    }
54
55    /// Return the only point with `index`, rejecting duplicate period indices.
56    pub fn unique_point(&self, index: usize) -> serde_json::Result<Option<&OperatingPoint>> {
57        let mut matches = self.points.iter().filter(|point| point.index == index);
58        let first = matches.next();
59        if matches.next().is_some() {
60            return Err(<serde_json::Error as serde::de::Error>::custom(format!(
61                "package has multiple operating points with index {index}"
62            )));
63        }
64        Ok(first)
65    }
66
67    #[must_use]
68    pub fn with_metadata(mut self, metadata: BTreeMap<String, Value>) -> Self {
69        self.metadata = metadata;
70        self
71    }
72}
73
74/// The time axis shared by every operating point in the series.
75#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
76#[non_exhaustive]
77pub struct TimeAxis {
78    /// Number of periods available in the series.
79    pub periods: usize,
80    /// Optional duration per period, in hours.
81    #[serde(default, skip_serializing_if = "Vec::is_empty")]
82    pub duration_hours: Vec<f64>,
83    /// Optional display labels for the periods.
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub labels: Vec<String>,
86}
87
88impl TimeAxis {
89    #[must_use]
90    pub fn new(periods: usize) -> Self {
91        Self {
92            periods,
93            duration_hours: Vec::new(),
94            labels: Vec::new(),
95        }
96    }
97
98    #[must_use]
99    pub fn is_empty(&self) -> bool {
100        self.periods == 0 && self.duration_hours.is_empty() && self.labels.is_empty()
101    }
102
103    #[must_use]
104    pub fn with_duration_hours(mut self, duration_hours: Vec<f64>) -> Self {
105        self.duration_hours = duration_hours;
106        self
107    }
108
109    #[must_use]
110    pub fn with_labels(mut self, labels: Vec<String>) -> Self {
111        self.labels = labels;
112        self
113    }
114}
115
116/// One replayable operating state over the package's static payload.
117#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
118#[non_exhaustive]
119pub struct OperatingPoint {
120    /// Zero based period index. Labels and durations live on the shared
121    /// [`TimeAxis`], indexed by this.
122    pub index: usize,
123    /// Field updates to apply to the static payload.
124    #[serde(default, skip_serializing_if = "Vec::is_empty")]
125    pub updates: Vec<ElementUpdate>,
126    /// Metadata from the source format for this point.
127    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
128    pub metadata: BTreeMap<String, Value>,
129}
130
131impl OperatingPoint {
132    #[must_use]
133    pub fn new(index: usize) -> Self {
134        Self {
135            index,
136            updates: Vec::new(),
137            metadata: BTreeMap::new(),
138        }
139    }
140}
141
142/// A row in one table of the static payload.
143#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
144#[non_exhaustive]
145pub struct ElementRef {
146    /// Payload table name, such as `loads`, `generators`, `branches`, or `hvdc`.
147    pub table: String,
148    /// Zero based row index in `table`.
149    pub row: usize,
150    /// Optional source record UID for diagnostics and provenance.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub source_uid: Option<String>,
153}
154
155impl ElementRef {
156    #[must_use]
157    pub fn new(table: impl Into<String>, row: usize) -> Self {
158        Self {
159            table: table.into(),
160            row,
161            source_uid: None,
162        }
163    }
164
165    #[must_use]
166    pub fn with_source_uid(mut self, uid: impl Into<String>) -> Self {
167        self.source_uid = Some(uid.into());
168        self
169    }
170}
171
172/// Field values to apply to one static payload row.
173#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
174#[non_exhaustive]
175pub struct ElementUpdate {
176    /// Table row to update.
177    pub element: ElementRef,
178    /// JSON field values to overwrite on that row.
179    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
180    pub fields: BTreeMap<String, Value>,
181    /// Metadata from the source format for this update.
182    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
183    pub metadata: BTreeMap<String, Value>,
184}
185
186impl ElementUpdate {
187    #[must_use]
188    pub fn new(element: ElementRef, fields: BTreeMap<String, Value>) -> Self {
189        Self {
190            element,
191            fields,
192            metadata: BTreeMap::new(),
193        }
194    }
195}
196
197pub(crate) fn goc3_operating_points_from_str(
198    text: &str,
199) -> serde_json::Result<Option<OperatingPointSeries>> {
200    let root: Value = serde_json::from_str(text)?;
201    let Some(root) = root.as_object() else {
202        return Ok(None);
203    };
204    let Some(network) = root.get("network").and_then(Value::as_object) else {
205        return Ok(None);
206    };
207    let Some(time_series) = root.get("time_series_input").and_then(Value::as_object) else {
208        return Ok(None);
209    };
210    let Some(general) = time_series.get("general").and_then(Value::as_object) else {
211        return Ok(None);
212    };
213    let periods = general
214        .get("time_periods")
215        .and_then(Value::as_u64)
216        .unwrap_or(0) as usize;
217    if periods == 0 {
218        return Ok(None);
219    }
220    let duration_hours = general
221        .get("interval_duration")
222        .and_then(Value::as_array)
223        .map(|values| values.iter().filter_map(Value::as_f64).collect::<Vec<_>>())
224        .unwrap_or_default();
225    let device_ts = uid_map(section(time_series, "simple_dispatchable_device")?);
226    let output = root.get("time_series_output").and_then(Value::as_object);
227
228    let mut points = (0..periods).map(OperatingPoint::new).collect::<Vec<_>>();
229
230    let base_mva = network
231        .get("general")
232        .and_then(Value::as_object)
233        .and_then(|general| number(general, "base_norm_mva"))
234        .unwrap_or(100.0);
235
236    add_goc3_device_updates(network, &device_ts, base_mva, &mut points)?;
237    add_goc3_status_updates(network, output, "ac_line", "branches", 0, &mut points)?;
238    let line_count = section(network, "ac_line")?.len();
239    add_goc3_status_updates(
240        network,
241        output,
242        "two_winding_transformer",
243        "branches",
244        line_count,
245        &mut points,
246    )?;
247    add_goc3_status_updates(network, output, "dc_line", "hvdc", 0, &mut points)?;
248
249    Ok(Some(OperatingPointSeries {
250        time_axis: TimeAxis {
251            periods,
252            duration_hours,
253            labels: (0..periods).map(|idx| (idx + 1).to_string()).collect(),
254        },
255        points,
256        metadata: BTreeMap::from([("source_format".to_owned(), json!("goc3-json"))]),
257    }))
258}
259
260fn add_goc3_device_updates(
261    network: &Map<String, Value>,
262    device_ts: &HashMap<String, &Value>,
263    base_mva: f64,
264    points: &mut [OperatingPoint],
265) -> serde_json::Result<()> {
266    for device in device_rows(network).map_err(|err| json_error(err.to_string()))? {
267        let Some(uid) = device.uid else {
268            continue;
269        };
270        let Some(ts_value) = device_ts.get(uid.as_str()) else {
271            continue;
272        };
273        let Some(ts) = ts_value.as_object() else {
274            continue;
275        };
276        match device.table {
277            DeviceTable::Generators => {
278                for point in points.iter_mut() {
279                    let mut fields = BTreeMap::new();
280                    insert_scaled_at(&mut fields, ts, "p_ub", "pmax", point.index, base_mva);
281                    insert_scaled_at(&mut fields, ts, "p_lb", "pmin", point.index, base_mva);
282                    insert_scaled_at(&mut fields, ts, "q_ub", "qmax", point.index, base_mva);
283                    insert_scaled_at(&mut fields, ts, "q_lb", "qmin", point.index, base_mva);
284                    if let Some(cost) = cost_at(device.obj, Some(ts_value), point.index, base_mva)
285                        .map(serde_json::to_value)
286                        .transpose()?
287                    {
288                        fields.insert("cost".to_owned(), cost);
289                    }
290                    if !fields.is_empty() {
291                        let mut update = ElementUpdate::new(
292                            ElementRef::new("generators", device.row).with_source_uid(uid.clone()),
293                            fields,
294                        );
295                        update.metadata = per_period_metadata(ts, point.index);
296                        point.updates.push(update);
297                    }
298                }
299            }
300            DeviceTable::Loads => {
301                for point in points.iter_mut() {
302                    let mut fields = BTreeMap::new();
303                    insert_abs_scaled_at(&mut fields, ts, "p_ub", "p", point.index, base_mva);
304                    insert_abs_scaled_at(&mut fields, ts, "q_ub", "q", point.index, base_mva);
305                    if !fields.is_empty() {
306                        let mut update = ElementUpdate::new(
307                            ElementRef::new("loads", device.row).with_source_uid(uid.clone()),
308                            fields,
309                        );
310                        update.metadata = per_period_metadata(ts, point.index);
311                        point.updates.push(update);
312                    }
313                }
314            }
315        }
316    }
317    Ok(())
318}
319
320fn add_goc3_status_updates(
321    network: &Map<String, Value>,
322    output: Option<&Map<String, Value>>,
323    source_section: &'static str,
324    target_table: &'static str,
325    row_offset: usize,
326    points: &mut [OperatingPoint],
327) -> serde_json::Result<()> {
328    let source_items = section(network, source_section)?;
329    let Some(output) = output else {
330        return Ok(());
331    };
332    let status_by_uid = uid_map(section(output, source_section)?);
333    for (row, item) in source_items.iter().enumerate() {
334        let Some(obj) = item.value.as_object() else {
335            continue;
336        };
337        let Some(uid) = item_uid(*item, obj) else {
338            continue;
339        };
340        let Some(status) = status_by_uid
341            .get(uid.as_str())
342            .and_then(|value| value.as_object())
343        else {
344            continue;
345        };
346        for point in points.iter_mut() {
347            if let Some(value) = array_number_at(status, "on_status", point.index) {
348                point.updates.push(ElementUpdate::new(
349                    ElementRef::new(target_table, row_offset + row).with_source_uid(uid.clone()),
350                    BTreeMap::from([("in_service".to_owned(), json!(value != 0.0))]),
351                ));
352            }
353        }
354    }
355    Ok(())
356}
357
358fn section<'a>(
359    parent: &'a Map<String, Value>,
360    name: &'static str,
361) -> serde_json::Result<Vec<SectionItem<'a>>> {
362    powerio::format::goc3_bridge::section(parent, name).map_err(|err| json_error(err.to_string()))
363}
364
365fn uid_map(items: Vec<SectionItem<'_>>) -> HashMap<String, &Value> {
366    let mut out = HashMap::new();
367    for item in items {
368        if let Some(obj) = item.value.as_object()
369            && let Some(uid) = item_uid(item, obj)
370        {
371            out.insert(uid, item.value);
372        }
373    }
374    out
375}
376
377fn insert_scaled_at(
378    fields: &mut BTreeMap<String, Value>,
379    obj: &Map<String, Value>,
380    source: &str,
381    target: &str,
382    index: usize,
383    scale: f64,
384) {
385    if let Some(value) = array_number_at(obj, source, index) {
386        fields.insert(target.to_owned(), json!(value * scale));
387    }
388}
389
390fn insert_abs_scaled_at(
391    fields: &mut BTreeMap<String, Value>,
392    obj: &Map<String, Value>,
393    source: &str,
394    target: &str,
395    index: usize,
396    scale: f64,
397) {
398    if let Some(value) = array_number_at(obj, source, index) {
399        fields.insert(target.to_owned(), json!(value.abs() * scale));
400    }
401}
402
403fn array_number_at(obj: &Map<String, Value>, key: &str, index: usize) -> Option<f64> {
404    obj.get(key)?.as_array()?.get(index)?.as_f64()
405}
406
407fn per_period_metadata(obj: &Map<String, Value>, index: usize) -> BTreeMap<String, Value> {
408    let mut metadata = BTreeMap::new();
409    for (key, value) in obj {
410        if key == "cost" || key.ends_with("_ub") || key.ends_with("_lb") {
411            continue;
412        }
413        if let Some(values) = value.as_array()
414            && let Some(value) = values.get(index)
415        {
416            metadata.insert(key.clone(), value.clone());
417        }
418    }
419    metadata
420}
421
422fn json_error(message: impl Into<String>) -> serde_json::Error {
423    <serde_json::Error as serde::de::Error>::custom(message.into())
424}
425
426pub(crate) fn apply_operating_point_to_model(
427    model: &ModelPayload,
428    point: &OperatingPoint,
429) -> serde_json::Result<ModelPayload> {
430    let mut value = serde_json::to_value(model)?;
431    let root = value.as_object_mut().ok_or_else(|| {
432        <serde_json::Error as serde::de::Error>::custom("model payload did not serialize to object")
433    })?;
434    let payload_key = payload_key(model);
435    let payload = root
436        .get_mut(payload_key)
437        .and_then(Value::as_object_mut)
438        .ok_or_else(|| {
439            <serde_json::Error as serde::de::Error>::custom(format!(
440                "model payload missing `{payload_key}` object"
441            ))
442        })?;
443
444    for update in &point.updates {
445        apply_update(payload, update)?;
446    }
447
448    let updated = serde_json::from_value(value)?;
449    validate_update_fields_survived(&updated, &point.updates)?;
450    Ok(updated)
451}
452
453pub(crate) fn operating_point_update_paths(
454    model: &ModelPayload,
455    point: &OperatingPoint,
456) -> BTreeSet<String> {
457    let payload_key = payload_key(model);
458    point
459        .updates
460        .iter()
461        .flat_map(|update| {
462            update.fields.keys().map(move |field| {
463                format!(
464                    "/model/{payload_key}/{}/{}/{}",
465                    update.element.table, update.element.row, field
466                )
467            })
468        })
469        .collect()
470}
471
472fn payload_key(model: &ModelPayload) -> &'static str {
473    match model {
474        ModelPayload::Balanced { .. } => "balanced_network",
475        ModelPayload::Multiconductor { .. } => "multiconductor_network",
476    }
477}
478
479fn apply_update(
480    payload: &mut serde_json::Map<String, Value>,
481    update: &ElementUpdate,
482) -> serde_json::Result<()> {
483    let table_name = update.element.table.as_str();
484    let table = payload
485        .get_mut(table_name)
486        .and_then(Value::as_array_mut)
487        .ok_or_else(|| {
488            <serde_json::Error as serde::de::Error>::custom(format!(
489                "operating point table `{table_name}` is not present or is not an array"
490            ))
491        })?;
492    let row = table
493        .get_mut(update.element.row)
494        .and_then(Value::as_object_mut)
495        .ok_or_else(|| {
496            <serde_json::Error as serde::de::Error>::custom(format!(
497                "operating point table `{table_name}` has no object row {}",
498                update.element.row
499            ))
500        })?;
501
502    for (field, value) in &update.fields {
503        row.insert(field.clone(), value.clone());
504    }
505    Ok(())
506}
507
508fn validate_update_fields_survived(
509    model: &ModelPayload,
510    updates: &[ElementUpdate],
511) -> serde_json::Result<()> {
512    let value = serde_json::to_value(model)?;
513    let root = value.as_object().ok_or_else(|| {
514        <serde_json::Error as serde::de::Error>::custom("model payload did not serialize to object")
515    })?;
516    let payload_key = payload_key(model);
517    let payload = root
518        .get(payload_key)
519        .and_then(Value::as_object)
520        .ok_or_else(|| {
521            <serde_json::Error as serde::de::Error>::custom(format!(
522                "model payload missing `{payload_key}` object"
523            ))
524        })?;
525
526    for update in updates {
527        let table_name = update.element.table.as_str();
528        let table = payload
529            .get(table_name)
530            .and_then(Value::as_array)
531            .ok_or_else(|| {
532                <serde_json::Error as serde::de::Error>::custom(format!(
533                    "operating point table `{table_name}` is not present after typed materialization"
534                ))
535            })?;
536        let row = table
537            .get(update.element.row)
538            .and_then(Value::as_object)
539            .ok_or_else(|| {
540                <serde_json::Error as serde::de::Error>::custom(format!(
541                    "operating point table `{table_name}` has no object row {} after typed materialization",
542                    update.element.row
543                ))
544            })?;
545
546        for field in update.fields.keys() {
547            if !row.contains_key(field) {
548                return Err(<serde_json::Error as serde::de::Error>::custom(format!(
549                    "operating point field `{field}` is not present on table `{table_name}` row {}",
550                    update.element.row
551                )));
552            }
553        }
554    }
555    Ok(())
556}