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#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
20#[non_exhaustive]
21pub struct OperatingPointSeries {
22    /// Shared period count, durations, and labels.
23    pub time_axis: TimeAxis,
24    /// Ordered operating states. Each state is addressed by its `index`.
25    #[serde(default, skip_serializing_if = "Vec::is_empty")]
26    pub points: Vec<OperatingPoint>,
27    /// Metadata from the source format, such as `source_format`.
28    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
29    pub metadata: BTreeMap<String, Value>,
30}
31
32impl OperatingPointSeries {
33    #[must_use]
34    pub fn new(time_axis: TimeAxis, points: Vec<OperatingPoint>) -> Self {
35        Self {
36            time_axis,
37            points,
38            metadata: BTreeMap::new(),
39        }
40    }
41
42    #[must_use]
43    pub fn is_empty(&self) -> bool {
44        self.time_axis.is_empty() && self.points.is_empty() && self.metadata.is_empty()
45    }
46
47    /// Return the first point with `index`.
48    ///
49    /// Use [`OperatingPointSeries::unique_point`] when duplicate indices must be
50    /// rejected instead of collapsed.
51    #[must_use]
52    pub fn point(&self, index: usize) -> Option<&OperatingPoint> {
53        self.points.iter().find(|point| point.index == index)
54    }
55
56    /// Return the only point with `index`, rejecting duplicate period indices.
57    pub fn unique_point(&self, index: usize) -> serde_json::Result<Option<&OperatingPoint>> {
58        let mut matches = self.points.iter().filter(|point| point.index == index);
59        let first = matches.next();
60        if matches.next().is_some() {
61            return Err(<serde_json::Error as serde::de::Error>::custom(format!(
62                "package has multiple operating points with index {index}"
63            )));
64        }
65        Ok(first)
66    }
67
68    #[must_use]
69    pub fn with_metadata(mut self, metadata: BTreeMap<String, Value>) -> Self {
70        self.metadata = metadata;
71        self
72    }
73}
74
75/// The time axis shared by every operating point in the series.
76#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
77#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
78#[non_exhaustive]
79pub struct TimeAxis {
80    /// Number of periods available in the series.
81    pub periods: usize,
82    /// Optional duration per period, in hours.
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub duration_hours: Vec<f64>,
85    /// Optional display labels for the periods.
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub labels: Vec<String>,
88}
89
90impl TimeAxis {
91    #[must_use]
92    pub fn new(periods: usize) -> Self {
93        Self {
94            periods,
95            duration_hours: Vec::new(),
96            labels: Vec::new(),
97        }
98    }
99
100    #[must_use]
101    pub fn is_empty(&self) -> bool {
102        self.periods == 0 && self.duration_hours.is_empty() && self.labels.is_empty()
103    }
104
105    #[must_use]
106    pub fn with_duration_hours(mut self, duration_hours: Vec<f64>) -> Self {
107        self.duration_hours = duration_hours;
108        self
109    }
110
111    #[must_use]
112    pub fn with_labels(mut self, labels: Vec<String>) -> Self {
113        self.labels = labels;
114        self
115    }
116}
117
118/// One replayable operating state over the package's static payload.
119#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
120#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
121#[non_exhaustive]
122pub struct OperatingPoint {
123    /// Zero based period index. Labels and durations live on the shared
124    /// [`TimeAxis`], indexed by this.
125    pub index: usize,
126    /// Field updates to apply to the static payload.
127    #[serde(default, skip_serializing_if = "Vec::is_empty")]
128    pub updates: Vec<ElementUpdate>,
129    /// Metadata from the source format for this point.
130    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
131    pub metadata: BTreeMap<String, Value>,
132}
133
134impl OperatingPoint {
135    #[must_use]
136    pub fn new(index: usize) -> Self {
137        Self {
138            index,
139            updates: Vec::new(),
140            metadata: BTreeMap::new(),
141        }
142    }
143}
144
145/// A row in one table of the static payload.
146///
147/// `source_uid` is the row's payload identity: when the referenced table
148/// carries `uid` values, a present `source_uid` resolves the target row and a
149/// present `row` must agree with it. In a table without uids (packages written
150/// before payload identity existed), `source_uid` is advisory and `row`
151/// addresses the update alone. On the wire, `row` may be omitted when
152/// `source_uid` is given.
153#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
155#[cfg_attr(feature = "schema", schemars(transform = element_ref_schema))]
156#[non_exhaustive]
157pub struct ElementRef {
158    /// Payload table name, such as `loads`, `generators`, `branches`, or `hvdc`.
159    pub table: String,
160    /// Zero based row index in `table`, when the producer addressed one.
161    /// `None` on refs built by [`ElementRef::by_source_uid`], which address by
162    /// identity alone.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub row: Option<usize>,
165    /// The row's payload identity (its `uid` field), when the producer knows it.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub source_uid: Option<String>,
168}
169
170impl ElementRef {
171    #[must_use]
172    pub fn new(table: impl Into<String>, row: usize) -> Self {
173        Self {
174            table: table.into(),
175            row: Some(row),
176            source_uid: None,
177        }
178    }
179
180    /// Address a row by payload identity alone; no `row` is serialized.
181    #[must_use]
182    pub fn by_source_uid(table: impl Into<String>, uid: impl Into<String>) -> Self {
183        Self {
184            table: table.into(),
185            row: None,
186            source_uid: Some(uid.into()),
187        }
188    }
189
190    #[must_use]
191    pub fn with_source_uid(mut self, uid: impl Into<String>) -> Self {
192        self.source_uid = Some(uid.into());
193        self
194    }
195}
196
197#[cfg(feature = "schema")]
198fn element_ref_schema(schema: &mut schemars::Schema) {
199    schema.ensure_object().insert(
200        "anyOf".to_owned(),
201        json!([
202            {
203                "required": ["row"],
204                "properties": {
205                    "row": {
206                        "format": "uint",
207                        "minimum": 0,
208                        "type": "integer"
209                    }
210                }
211            },
212            {
213                "required": ["source_uid"],
214                "properties": {
215                    "source_uid": { "type": "string" }
216                }
217            }
218        ]),
219    );
220}
221
222impl<'de> Deserialize<'de> for ElementRef {
223    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
224        #[derive(Deserialize)]
225        #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
226        struct Wire {
227            table: String,
228            #[serde(default)]
229            row: Option<usize>,
230            #[serde(default)]
231            source_uid: Option<String>,
232        }
233        let wire = Wire::deserialize(deserializer)?;
234        if wire.row.is_none() && wire.source_uid.is_none() {
235            return Err(serde::de::Error::custom(
236                "element ref needs `row` or `source_uid`",
237            ));
238        }
239        Ok(Self {
240            table: wire.table,
241            row: wire.row,
242            source_uid: wire.source_uid,
243        })
244    }
245}
246
247/// Field values to apply to one static payload row.
248#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
249#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
250#[non_exhaustive]
251pub struct ElementUpdate {
252    /// Table row to update.
253    pub element: ElementRef,
254    /// JSON field values to overwrite on that row.
255    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
256    pub fields: BTreeMap<String, Value>,
257    /// Metadata from the source format for this update.
258    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
259    pub metadata: BTreeMap<String, Value>,
260}
261
262impl ElementUpdate {
263    #[must_use]
264    pub fn new(element: ElementRef, fields: BTreeMap<String, Value>) -> Self {
265        Self {
266            element,
267            fields,
268            metadata: BTreeMap::new(),
269        }
270    }
271}
272
273pub(crate) fn goc3_operating_points_from_str(
274    text: &str,
275) -> serde_json::Result<Option<OperatingPointSeries>> {
276    let root: Value = serde_json::from_str(text)?;
277    let Some(root) = root.as_object() else {
278        return Ok(None);
279    };
280    let Some(network) = root.get("network").and_then(Value::as_object) else {
281        return Ok(None);
282    };
283    let Some(time_series) = root.get("time_series_input").and_then(Value::as_object) else {
284        return Ok(None);
285    };
286    let Some(general) = time_series.get("general").and_then(Value::as_object) else {
287        return Ok(None);
288    };
289    let periods = general
290        .get("time_periods")
291        .and_then(Value::as_u64)
292        .unwrap_or(0) as usize;
293    if periods == 0 {
294        return Ok(None);
295    }
296    let duration_hours = general
297        .get("interval_duration")
298        .and_then(Value::as_array)
299        .map(|values| values.iter().filter_map(Value::as_f64).collect::<Vec<_>>())
300        .unwrap_or_default();
301    let device_ts = uid_map(section(time_series, "simple_dispatchable_device")?);
302    let output = root.get("time_series_output").and_then(Value::as_object);
303
304    let mut points = (0..periods).map(OperatingPoint::new).collect::<Vec<_>>();
305
306    let base_mva = network
307        .get("general")
308        .and_then(Value::as_object)
309        .and_then(|general| number(general, "base_norm_mva"))
310        .unwrap_or(100.0);
311
312    add_goc3_device_updates(network, &device_ts, base_mva, &mut points)?;
313    add_goc3_status_updates(network, output, "ac_line", "branches", 0, &mut points)?;
314    let line_count = section(network, "ac_line")?.len();
315    add_goc3_status_updates(
316        network,
317        output,
318        "two_winding_transformer",
319        "branches",
320        line_count,
321        &mut points,
322    )?;
323    add_goc3_status_updates(network, output, "dc_line", "hvdc", 0, &mut points)?;
324
325    Ok(Some(OperatingPointSeries {
326        time_axis: TimeAxis {
327            periods,
328            duration_hours,
329            labels: (0..periods).map(|idx| (idx + 1).to_string()).collect(),
330        },
331        points,
332        metadata: BTreeMap::from([("source_format".to_owned(), json!("goc3-json"))]),
333    }))
334}
335
336fn add_goc3_device_updates(
337    network: &Map<String, Value>,
338    device_ts: &HashMap<String, &Value>,
339    base_mva: f64,
340    points: &mut [OperatingPoint],
341) -> serde_json::Result<()> {
342    for device in device_rows(network).map_err(|err| json_error(err.to_string()))? {
343        let Some(uid) = device.uid else {
344            continue;
345        };
346        let Some(ts_value) = device_ts.get(uid.as_str()) else {
347            continue;
348        };
349        let Some(ts) = ts_value.as_object() else {
350            continue;
351        };
352        match device.table {
353            DeviceTable::Generators => {
354                for point in points.iter_mut() {
355                    let mut fields = BTreeMap::new();
356                    insert_scaled_at(&mut fields, ts, "p_ub", "pmax", point.index, base_mva);
357                    insert_scaled_at(&mut fields, ts, "p_lb", "pmin", point.index, base_mva);
358                    insert_scaled_at(&mut fields, ts, "q_ub", "qmax", point.index, base_mva);
359                    insert_scaled_at(&mut fields, ts, "q_lb", "qmin", point.index, base_mva);
360                    if let Some(cost) = cost_at(device.obj, Some(ts_value), point.index, base_mva)
361                        .map(serde_json::to_value)
362                        .transpose()?
363                    {
364                        fields.insert("cost".to_owned(), cost);
365                    }
366                    if !fields.is_empty() {
367                        let mut update = ElementUpdate::new(
368                            ElementRef::new("generators", device.row).with_source_uid(uid.clone()),
369                            fields,
370                        );
371                        update.metadata = per_period_metadata(ts, point.index);
372                        point.updates.push(update);
373                    }
374                }
375            }
376            DeviceTable::Loads => {
377                for point in points.iter_mut() {
378                    let mut fields = BTreeMap::new();
379                    insert_abs_scaled_at(&mut fields, ts, "p_ub", "p", point.index, base_mva);
380                    insert_abs_scaled_at(&mut fields, ts, "q_ub", "q", point.index, base_mva);
381                    if !fields.is_empty() {
382                        let mut update = ElementUpdate::new(
383                            ElementRef::new("loads", device.row).with_source_uid(uid.clone()),
384                            fields,
385                        );
386                        update.metadata = per_period_metadata(ts, point.index);
387                        point.updates.push(update);
388                    }
389                }
390            }
391        }
392    }
393    Ok(())
394}
395
396fn add_goc3_status_updates(
397    network: &Map<String, Value>,
398    output: Option<&Map<String, Value>>,
399    source_section: &'static str,
400    target_table: &'static str,
401    row_offset: usize,
402    points: &mut [OperatingPoint],
403) -> serde_json::Result<()> {
404    let source_items = section(network, source_section)?;
405    let Some(output) = output else {
406        return Ok(());
407    };
408    let status_by_uid = uid_map(section(output, source_section)?);
409    for (row, item) in source_items.iter().enumerate() {
410        let Some(obj) = item.value.as_object() else {
411            continue;
412        };
413        let Some(uid) = item_uid(*item, obj) else {
414            continue;
415        };
416        let Some(status) = status_by_uid
417            .get(uid.as_str())
418            .and_then(|value| value.as_object())
419        else {
420            continue;
421        };
422        for point in points.iter_mut() {
423            if let Some(value) = array_number_at(status, "on_status", point.index) {
424                point.updates.push(ElementUpdate::new(
425                    ElementRef::new(target_table, row_offset + row).with_source_uid(uid.clone()),
426                    BTreeMap::from([("in_service".to_owned(), json!(value != 0.0))]),
427                ));
428            }
429        }
430    }
431    Ok(())
432}
433
434fn section<'a>(
435    parent: &'a Map<String, Value>,
436    name: &'static str,
437) -> serde_json::Result<Vec<SectionItem<'a>>> {
438    powerio::format::goc3_bridge::section(parent, name).map_err(|err| json_error(err.to_string()))
439}
440
441fn uid_map(items: Vec<SectionItem<'_>>) -> HashMap<String, &Value> {
442    let mut out = HashMap::new();
443    for item in items {
444        if let Some(obj) = item.value.as_object()
445            && let Some(uid) = item_uid(item, obj)
446        {
447            out.insert(uid, item.value);
448        }
449    }
450    out
451}
452
453fn insert_scaled_at(
454    fields: &mut BTreeMap<String, Value>,
455    obj: &Map<String, Value>,
456    source: &str,
457    target: &str,
458    index: usize,
459    scale: f64,
460) {
461    if let Some(value) = array_number_at(obj, source, index) {
462        fields.insert(target.to_owned(), json!(value * scale));
463    }
464}
465
466fn insert_abs_scaled_at(
467    fields: &mut BTreeMap<String, Value>,
468    obj: &Map<String, Value>,
469    source: &str,
470    target: &str,
471    index: usize,
472    scale: f64,
473) {
474    if let Some(value) = array_number_at(obj, source, index) {
475        fields.insert(target.to_owned(), json!(value.abs() * scale));
476    }
477}
478
479fn array_number_at(obj: &Map<String, Value>, key: &str, index: usize) -> Option<f64> {
480    obj.get(key)?.as_array()?.get(index)?.as_f64()
481}
482
483fn per_period_metadata(obj: &Map<String, Value>, index: usize) -> BTreeMap<String, Value> {
484    let mut metadata = BTreeMap::new();
485    for (key, value) in obj {
486        if key == "cost" || key.ends_with("_ub") || key.ends_with("_lb") {
487            continue;
488        }
489        if let Some(values) = value.as_array()
490            && let Some(value) = values.get(index)
491        {
492            metadata.insert(key.clone(), value.clone());
493        }
494    }
495    metadata
496}
497
498pub(crate) fn json_error(message: impl Into<String>) -> serde_json::Error {
499    <serde_json::Error as serde::de::Error>::custom(message.into())
500}
501
502/// Apply one operating point to the payload and return the updated model plus
503/// the JSON Pointer paths of every field written, computed from the resolved
504/// rows so stale provenance cleanup follows identity resolution, never a stale
505/// wire row.
506pub(crate) fn apply_operating_point_to_model(
507    model: &ModelPayload,
508    point: &OperatingPoint,
509) -> serde_json::Result<(ModelPayload, BTreeSet<String>)> {
510    let mut value = serde_json::to_value(model)?;
511    let root = value.as_object_mut().ok_or_else(|| {
512        <serde_json::Error as serde::de::Error>::custom("model payload did not serialize to object")
513    })?;
514    let payload_key = payload_key(model);
515    let payload = root
516        .get_mut(payload_key)
517        .and_then(Value::as_object_mut)
518        .ok_or_else(|| {
519            <serde_json::Error as serde::de::Error>::custom(format!(
520                "model payload missing `{payload_key}` object"
521            ))
522        })?;
523
524    let mut indexes = HashMap::new();
525    let mut resolved_rows = Vec::with_capacity(point.updates.len());
526    for update in &point.updates {
527        let row = resolve_update(payload, &mut indexes, update).map_err(json_error)?;
528        apply_update_fields(payload, &update.element.table, row, &update.fields)?;
529        resolved_rows.push(row);
530    }
531
532    let updated_paths = point
533        .updates
534        .iter()
535        .zip(&resolved_rows)
536        .flat_map(|(update, row)| {
537            update.fields.keys().map(move |field| {
538                format!(
539                    "/model/{payload_key}/{}/{row}/{}",
540                    update.element.table, field
541                )
542            })
543        })
544        .collect();
545
546    let updated = serde_json::from_value(value)?;
547    validate_update_fields_survived(&updated, &point.updates, &resolved_rows)?;
548    Ok((updated, updated_paths))
549}
550
551/// Dry run identity resolution over a whole series, returning `(point_position,
552/// update_position, message)` for every update that fails to resolve. The
553/// payload is serialized once and the per table indexes are shared across the
554/// series.
555pub(crate) fn check_series_identities(
556    model: &ModelPayload,
557    series: &OperatingPointSeries,
558) -> Vec<(usize, usize, String)> {
559    let payload_key = payload_key(model);
560    let payload = match serde_json::to_value(model) {
561        Ok(Value::Object(mut root)) => match root.remove(payload_key) {
562            Some(Value::Object(payload)) => payload,
563            _ => {
564                return vec![(
565                    0,
566                    0,
567                    format!("model payload missing `{payload_key}` object"),
568                )];
569            }
570        },
571        _ => return vec![(0, 0, "model payload did not serialize to object".to_owned())],
572    };
573
574    let mut indexes = HashMap::new();
575    let mut findings = Vec::new();
576    for (point_pos, point) in series.points.iter().enumerate() {
577        for (update_pos, update) in point.updates.iter().enumerate() {
578            if let Err(message) = resolve_update(&payload, &mut indexes, update) {
579                findings.push((point_pos, update_pos, message));
580            }
581        }
582    }
583    findings
584}
585
586pub(crate) fn payload_key(model: &ModelPayload) -> &'static str {
587    match model {
588        ModelPayload::Balanced { .. } => "balanced_network",
589        ModelPayload::Multiconductor { .. } => "multiconductor_network",
590    }
591}
592
593/// The uid -> row index for one payload table.
594pub(crate) struct IdentityIndex {
595    by_uid: HashMap<String, usize>,
596    /// Uids on more than one row; resolving through one is ambiguous.
597    duplicates: BTreeSet<String>,
598    /// Whether any row carries a uid. A table with none keeps the row-only
599    /// semantics packages had before payload identity existed.
600    has_uids: bool,
601}
602
603fn table_identity_index(table: &[Value]) -> IdentityIndex {
604    let mut by_uid = HashMap::with_capacity(table.len());
605    let mut duplicates = BTreeSet::new();
606    let mut has_uids = false;
607    for (row, value) in table.iter().enumerate() {
608        let Some(uid) = value.get("uid").and_then(Value::as_str) else {
609            continue;
610        };
611        has_uids = true;
612        if by_uid.insert(uid.to_owned(), row).is_some() {
613            duplicates.insert(uid.to_owned());
614        }
615    }
616    IdentityIndex {
617        by_uid,
618        duplicates,
619        has_uids,
620    }
621}
622
623/// Resolve one update to its payload row, first rejecting any update that would
624/// rewrite `uid`. Identity is immutable: letting a field write change it would
625/// invalidate the per table indexes mid application.
626pub(crate) fn resolve_update(
627    payload: &Map<String, Value>,
628    indexes: &mut HashMap<String, IdentityIndex>,
629    update: &ElementUpdate,
630) -> Result<usize, String> {
631    if update.fields.contains_key("uid") {
632        return Err(format!(
633            "operating point update on table `{}` must not overwrite `uid`",
634            update.element.table
635        ));
636    }
637    resolve_update_row(payload, indexes, &update.element)
638}
639
640/// Resolve one element ref to a payload row. A `source_uid` that resolves in a
641/// uid bearing table is authoritative and a present wire `row` must agree with
642/// it; an unknown or duplicated uid in such a table is an error; a table without
643/// uids falls back to the wire row.
644pub(crate) fn resolve_update_row(
645    payload: &Map<String, Value>,
646    indexes: &mut HashMap<String, IdentityIndex>,
647    element: &ElementRef,
648) -> Result<usize, String> {
649    let table_name = element.table.as_str();
650    let Some(table) = payload.get(table_name).and_then(Value::as_array) else {
651        return Err(format!(
652            "operating point table `{table_name}` is not present or is not an array"
653        ));
654    };
655    let index = indexes
656        .entry(table_name.to_owned())
657        .or_insert_with(|| table_identity_index(table));
658    let resolved = match element.source_uid.as_deref() {
659        Some(uid) if index.duplicates.contains(uid) => {
660            return Err(format!(
661                "payload table `{table_name}` carries uid `{uid}` on more than one row; \
662                 identity resolution is ambiguous"
663            ));
664        }
665        Some(uid) => match index.by_uid.get(uid) {
666            Some(&row) => {
667                if let Some(wire_row) = element.row
668                    && wire_row != row
669                {
670                    return Err(format!(
671                        "update for table `{table_name}` names uid `{uid}` (row {row}) \
672                         but carries row {wire_row}"
673                    ));
674                }
675                row
676            }
677            None if index.has_uids => {
678                return Err(format!(
679                    "unknown identity: table `{table_name}` has no row with uid `{uid}`"
680                ));
681            }
682            None => element.row.ok_or_else(|| {
683                format!(
684                    "update for table `{table_name}` names uid `{uid}`, but the payload rows \
685                     carry no uids and the update has no row to fall back on"
686                )
687            })?,
688        },
689        None => element.row.ok_or_else(|| {
690            format!("update for table `{table_name}` has neither row nor source_uid")
691        })?,
692    };
693    if resolved >= table.len() {
694        return Err(format!(
695            "operating point table `{table_name}` has no row {resolved}"
696        ));
697    }
698    Ok(resolved)
699}
700
701pub(crate) fn apply_update_fields(
702    payload: &mut serde_json::Map<String, Value>,
703    table_name: &str,
704    row: usize,
705    fields: &BTreeMap<String, Value>,
706) -> serde_json::Result<()> {
707    let row_object = payload
708        .get_mut(table_name)
709        .and_then(Value::as_array_mut)
710        .and_then(|table| table.get_mut(row))
711        .and_then(Value::as_object_mut)
712        .ok_or_else(|| {
713            json_error(format!(
714                "operating point table `{table_name}` has no object row {row}"
715            ))
716        })?;
717    for (field, value) in fields {
718        row_object.insert(field.clone(), value.clone());
719    }
720    Ok(())
721}
722
723pub(crate) fn validate_update_fields_survived(
724    model: &ModelPayload,
725    updates: &[ElementUpdate],
726    resolved_rows: &[usize],
727) -> serde_json::Result<()> {
728    let value = serde_json::to_value(model)?;
729    let root = value.as_object().ok_or_else(|| {
730        <serde_json::Error as serde::de::Error>::custom("model payload did not serialize to object")
731    })?;
732    let payload_key = payload_key(model);
733    let payload = root
734        .get(payload_key)
735        .and_then(Value::as_object)
736        .ok_or_else(|| {
737            <serde_json::Error as serde::de::Error>::custom(format!(
738                "model payload missing `{payload_key}` object"
739            ))
740        })?;
741
742    for (update, &resolved_row) in updates.iter().zip(resolved_rows) {
743        let table_name = update.element.table.as_str();
744        let row = payload
745            .get(table_name)
746            .and_then(Value::as_array)
747            .and_then(|table| table.get(resolved_row))
748            .and_then(Value::as_object)
749            .ok_or_else(|| {
750                json_error(format!(
751                    "operating point table `{table_name}` has no object row {resolved_row} \
752                     after typed materialization"
753                ))
754            })?;
755
756        for field in update.fields.keys() {
757            if !row.contains_key(field) {
758                return Err(json_error(format!(
759                    "operating point field `{field}` is not present on table `{table_name}` \
760                     row {resolved_row}"
761                )));
762            }
763        }
764    }
765    Ok(())
766}