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