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