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