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) -> crate::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(crate::Error::Payload(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. In the document, `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 Stated {
222            table: String,
223            #[serde(default)]
224            row: Option<usize>,
225            #[serde(default)]
226            source_uid: Option<String>,
227        }
228        let stated = Stated::deserialize(deserializer)?;
229        if stated.row.is_none() && stated.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: stated.table,
236            row: stated.row,
237            source_uid: stated.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) -> crate::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(document: &Goc3Document) -> crate::Result<Option<OperatingPointSeries>> {
290    let network = document.network()?;
291    let time_series = document.time_series_input()?;
292    let Some(general) = time_series.get("general").and_then(Value::as_object) else {
293        return Ok(None);
294    };
295    let periods = general
296        .get("time_periods")
297        .and_then(Value::as_u64)
298        .unwrap_or(0) as usize;
299    if periods == 0 {
300        return Ok(None);
301    }
302    // `periods` comes straight from the case file and sizes the per-period
303    // point and label vectors below, so an oversized value would drive an
304    // unbounded up-front allocation (a hard abort, not a catchable panic).
305    // Bind it to the real data: `interval_duration` carries one entry per
306    // period, so its array length is the authoritative count — the SCOPF
307    // loader enforces the same equality. A mismatch is a malformed series.
308    let intervals = general.get("interval_duration").and_then(Value::as_array);
309    let interval_len = intervals.map_or(0, Vec::len);
310    if interval_len != periods {
311        return Err(crate::Error::Payload(format!(
312            "time_series_input.general.time_periods ({periods}) does not match the \
313             interval_duration length ({interval_len})"
314        )));
315    }
316    let duration_hours = intervals
317        .map(|values| values.iter().filter_map(Value::as_f64).collect::<Vec<_>>())
318        .unwrap_or_default();
319    let device_ts = uid_map(document.time_series_input_records("simple_dispatchable_device")?);
320
321    let mut points = (0..periods).map(OperatingPoint::new).collect::<Vec<_>>();
322
323    let base_mva = network
324        .get("general")
325        .and_then(Value::as_object)
326        .and_then(|general| general.get("base_norm_mva"))
327        .and_then(Value::as_f64)
328        .unwrap_or(100.0);
329
330    add_goc3_device_updates(document, &device_ts, base_mva, &mut points)?;
331    add_goc3_status_updates(document, "ac_line", "branches", 0, &mut points)?;
332    let line_count = document.network_records("ac_line")?.len();
333    add_goc3_status_updates(
334        document,
335        "two_winding_transformer",
336        "branches",
337        line_count,
338        &mut points,
339    )?;
340    add_goc3_status_updates(document, "dc_line", "hvdc", 0, &mut points)?;
341
342    Ok(Some(OperatingPointSeries {
343        time_axis: TimeAxis {
344            periods,
345            duration_hours,
346            labels: (0..periods).map(|idx| (idx + 1).to_string()).collect(),
347        },
348        points,
349        metadata: BTreeMap::from([("source_format".to_owned(), json!("goc3-json"))]),
350    }))
351}
352
353fn add_goc3_device_updates(
354    document: &Goc3Document,
355    device_ts: &HashMap<String, &Value>,
356    base_mva: f64,
357    points: &mut [OperatingPoint],
358) -> crate::Result<()> {
359    for device in document.dispatchable_devices()? {
360        let Some(uid) = device.uid else {
361            continue;
362        };
363        let Some(ts_value) = device_ts.get(uid.as_str()) else {
364            continue;
365        };
366        let Some(ts) = ts_value.as_object() else {
367            continue;
368        };
369        match device.kind {
370            Goc3DeviceKind::Generators => {
371                for point in points.iter_mut() {
372                    let mut fields = BTreeMap::new();
373                    insert_scaled_at(&mut fields, ts, "p_ub", "pmax", point.index, base_mva);
374                    insert_scaled_at(&mut fields, ts, "p_lb", "pmin", point.index, base_mva);
375                    insert_scaled_at(&mut fields, ts, "q_ub", "qmax", point.index, base_mva);
376                    insert_scaled_at(&mut fields, ts, "q_lb", "qmin", point.index, base_mva);
377                    if let Some(cost) = document
378                        .dispatchable_device_cost_at(
379                            device.obj,
380                            Some(ts_value),
381                            point.index,
382                            base_mva,
383                        )
384                        .map(serde_json::to_value)
385                        .transpose()?
386                    {
387                        fields.insert("cost".to_owned(), cost);
388                    }
389                    if !fields.is_empty() {
390                        let mut update = ElementUpdate::new(
391                            ElementRef::new("generators", device.row).with_source_uid(uid.clone()),
392                            fields,
393                        );
394                        update.metadata = per_period_metadata(ts, point.index);
395                        point.updates.push(update);
396                    }
397                }
398            }
399            Goc3DeviceKind::Loads => {
400                for point in points.iter_mut() {
401                    let mut fields = BTreeMap::new();
402                    insert_abs_scaled_at(&mut fields, ts, "p_ub", "p", point.index, base_mva);
403                    insert_abs_scaled_at(&mut fields, ts, "q_ub", "q", point.index, base_mva);
404                    if !fields.is_empty() {
405                        let mut update = ElementUpdate::new(
406                            ElementRef::new("loads", device.row).with_source_uid(uid.clone()),
407                            fields,
408                        );
409                        update.metadata = per_period_metadata(ts, point.index);
410                        point.updates.push(update);
411                    }
412                }
413            }
414        }
415    }
416    Ok(())
417}
418
419fn add_goc3_status_updates(
420    document: &Goc3Document,
421    source_section: &'static str,
422    target_table: &'static str,
423    row_offset: usize,
424    points: &mut [OperatingPoint],
425) -> crate::Result<()> {
426    let source_items = document.network_records(source_section)?;
427    if document.time_series_output().is_none() {
428        return Ok(());
429    }
430    let status_by_uid = uid_map(document.time_series_output_records(source_section)?);
431    for (row, item) in source_items.iter().enumerate() {
432        let Some(uid) = item.uid.as_ref() else {
433            continue;
434        };
435        let Some(status) = status_by_uid
436            .get(uid.as_str())
437            .and_then(|value| value.as_object())
438        else {
439            continue;
440        };
441        for point in points.iter_mut() {
442            if let Some(value) = array_number_at(status, "on_status", point.index) {
443                point.updates.push(ElementUpdate::new(
444                    ElementRef::new(target_table, row_offset + row).with_source_uid(uid.clone()),
445                    BTreeMap::from([("in_service".to_owned(), json!(value != 0.0))]),
446                ));
447            }
448        }
449    }
450    Ok(())
451}
452
453fn uid_map(items: Vec<Goc3Record<'_>>) -> HashMap<String, &Value> {
454    let mut out = HashMap::new();
455    for item in items {
456        if let Some(uid) = item.uid {
457            out.insert(uid, item.value);
458        }
459    }
460    out
461}
462
463fn insert_scaled_at(
464    fields: &mut BTreeMap<String, Value>,
465    obj: &Map<String, Value>,
466    source: &str,
467    target: &str,
468    index: usize,
469    scale: f64,
470) {
471    if let Some(value) = array_number_at(obj, source, index) {
472        fields.insert(target.to_owned(), json!(value * scale));
473    }
474}
475
476fn insert_abs_scaled_at(
477    fields: &mut BTreeMap<String, Value>,
478    obj: &Map<String, Value>,
479    source: &str,
480    target: &str,
481    index: usize,
482    scale: f64,
483) {
484    if let Some(value) = array_number_at(obj, source, index) {
485        fields.insert(target.to_owned(), json!(value.abs() * scale));
486    }
487}
488
489fn array_number_at(obj: &Map<String, Value>, key: &str, index: usize) -> Option<f64> {
490    obj.get(key)?.as_array()?.get(index)?.as_f64()
491}
492
493fn per_period_metadata(obj: &Map<String, Value>, index: usize) -> BTreeMap<String, Value> {
494    let mut metadata = BTreeMap::new();
495    for (key, value) in obj {
496        if key == "cost" || key.ends_with("_ub") || key.ends_with("_lb") {
497            continue;
498        }
499        if let Some(values) = value.as_array()
500            && let Some(value) = values.get(index)
501        {
502            metadata.insert(key.clone(), value.clone());
503        }
504    }
505    metadata
506}
507
508/// Apply one operating point to the payload and return the updated model plus
509/// the JSON Pointer paths of every field written, computed from the resolved
510/// rows so stale provenance cleanup follows identity resolution, never a stale
511/// stated row.
512pub(crate) fn apply_operating_point_to_model(
513    model: &ModelPayload,
514    point: &OperatingPoint,
515) -> crate::Result<(ModelPayload, BTreeSet<String>)> {
516    let mut value = serde_json::to_value(model)?;
517    let root = value.as_object_mut().ok_or_else(|| {
518        crate::Error::Payload("model payload did not serialize to object".to_owned())
519    })?;
520    let payload_key = payload_key(model);
521    let payload = root
522        .get_mut(payload_key)
523        .and_then(Value::as_object_mut)
524        .ok_or_else(|| {
525            crate::Error::Payload(format!("model payload missing `{payload_key}` object"))
526        })?;
527
528    let mut indexes = HashMap::new();
529    let mut resolved_rows = Vec::with_capacity(point.updates.len());
530    for update in &point.updates {
531        let row = resolve_update(payload, &mut indexes, update).map_err(crate::Error::Payload)?;
532        apply_update_fields(payload, &update.element.table, row, &update.fields)?;
533        resolved_rows.push(row);
534    }
535
536    let updated_paths = point
537        .updates
538        .iter()
539        .zip(&resolved_rows)
540        .flat_map(|(update, row)| {
541            update.fields.keys().map(move |field| {
542                format!(
543                    "/model/{payload_key}/{}/{row}/{}",
544                    update.element.table, field
545                )
546            })
547        })
548        .collect();
549
550    // As in `study.rs`: the operating point's `set_fields` values are the
551    // document's, inserted untyped, so a wrong type here is the caller's data
552    // rather than our serialization.
553    let updated =
554        serde_json::from_value(value).map_err(|error| crate::Error::Payload(error.to_string()))?;
555    validate_update_fields_survived(&updated, &point.updates, &resolved_rows)?;
556    Ok((updated, updated_paths))
557}
558
559/// Dry run identity resolution over a whole series, returning `(point_position,
560/// update_position, message)` for every update that fails to resolve. The
561/// payload is serialized once and the per table indexes are shared across the
562/// series.
563pub(crate) fn check_series_identities(
564    model: &ModelPayload,
565    series: &OperatingPointSeries,
566) -> Vec<(usize, usize, String)> {
567    let payload_key = payload_key(model);
568    let payload = match serde_json::to_value(model) {
569        Ok(Value::Object(mut root)) => match root.remove(payload_key) {
570            Some(Value::Object(payload)) => payload,
571            _ => {
572                return vec![(
573                    0,
574                    0,
575                    format!("model payload missing `{payload_key}` object"),
576                )];
577            }
578        },
579        _ => return vec![(0, 0, "model payload did not serialize to object".to_owned())],
580    };
581
582    let mut indexes = HashMap::new();
583    let mut findings = Vec::new();
584    for (point_pos, point) in series.points.iter().enumerate() {
585        for (update_pos, update) in point.updates.iter().enumerate() {
586            if let Err(message) = resolve_update(&payload, &mut indexes, update) {
587                findings.push((point_pos, update_pos, message));
588            }
589        }
590    }
591    findings
592}
593
594pub(crate) fn payload_key(model: &ModelPayload) -> &'static str {
595    match model {
596        ModelPayload::Balanced { .. } => "balanced_network",
597        ModelPayload::Multiconductor { .. } => "multiconductor_network",
598    }
599}
600
601/// The uid -> row index for one payload table.
602pub(crate) struct IdentityIndex {
603    by_uid: HashMap<String, usize>,
604    /// Uids on more than one row; resolving through one is ambiguous.
605    duplicates: BTreeSet<String>,
606    /// Whether any row carries a uid. A table with none keeps the row-only
607    /// semantics packages had before payload identity existed.
608    has_uids: bool,
609}
610
611fn table_identity_index(table: &[Value]) -> IdentityIndex {
612    let mut by_uid = HashMap::with_capacity(table.len());
613    let mut duplicates = BTreeSet::new();
614    let mut has_uids = false;
615    for (row, value) in table.iter().enumerate() {
616        let Some(uid) = value.get("uid").and_then(Value::as_str) else {
617            continue;
618        };
619        has_uids = true;
620        if by_uid.insert(uid.to_owned(), row).is_some() {
621            duplicates.insert(uid.to_owned());
622        }
623    }
624    IdentityIndex {
625        by_uid,
626        duplicates,
627        has_uids,
628    }
629}
630
631/// Resolve one update to its payload row, first rejecting any update that would
632/// rewrite `uid`. Identity is immutable: letting a field write change it would
633/// invalidate the per table indexes mid application.
634pub(crate) fn resolve_update(
635    payload: &Map<String, Value>,
636    indexes: &mut HashMap<String, IdentityIndex>,
637    update: &ElementUpdate,
638) -> Result<usize, String> {
639    if update.fields.contains_key("uid") {
640        return Err(format!(
641            "operating point update on table `{}` must not overwrite `uid`",
642            update.element.table
643        ));
644    }
645    resolve_update_row(payload, indexes, &update.element)
646}
647
648/// Resolve one element ref to a payload row. A `source_uid` that resolves in a
649/// uid bearing table is authoritative and a present `row` must agree with
650/// it; an unknown or duplicated uid in such a table is an error; a table without
651/// uids falls back to the stated row.
652pub(crate) fn resolve_update_row(
653    payload: &Map<String, Value>,
654    indexes: &mut HashMap<String, IdentityIndex>,
655    element: &ElementRef,
656) -> Result<usize, String> {
657    let table_name = element.table.as_str();
658    let Some(table) = payload.get(table_name).and_then(Value::as_array) else {
659        return Err(format!(
660            "operating point table `{table_name}` is not present or is not an array"
661        ));
662    };
663    let index = indexes
664        .entry(table_name.to_owned())
665        .or_insert_with(|| table_identity_index(table));
666    let resolved = match element.source_uid.as_deref() {
667        Some(uid) if index.duplicates.contains(uid) => {
668            return Err(format!(
669                "payload table `{table_name}` carries uid `{uid}` on more than one row; \
670                 identity resolution is ambiguous"
671            ));
672        }
673        Some(uid) => match index.by_uid.get(uid) {
674            Some(&row) => {
675                if let Some(stated_row) = element.row
676                    && stated_row != row
677                {
678                    return Err(format!(
679                        "update for table `{table_name}` names uid `{uid}` (row {row}) \
680                         but carries row {stated_row}"
681                    ));
682                }
683                row
684            }
685            None if index.has_uids => {
686                return Err(format!(
687                    "unknown identity: table `{table_name}` has no row with uid `{uid}`"
688                ));
689            }
690            None => element.row.ok_or_else(|| {
691                format!(
692                    "update for table `{table_name}` names uid `{uid}`, but the payload rows \
693                     carry no uids and the update has no row to fall back on"
694                )
695            })?,
696        },
697        None => element.row.ok_or_else(|| {
698            format!("update for table `{table_name}` has neither row nor source_uid")
699        })?,
700    };
701    if resolved >= table.len() {
702        return Err(format!(
703            "operating point table `{table_name}` has no row {resolved}"
704        ));
705    }
706    Ok(resolved)
707}
708
709pub(crate) fn apply_update_fields(
710    payload: &mut serde_json::Map<String, Value>,
711    table_name: &str,
712    row: usize,
713    fields: &BTreeMap<String, Value>,
714) -> crate::Result<()> {
715    let row_object = payload
716        .get_mut(table_name)
717        .and_then(Value::as_array_mut)
718        .and_then(|table| table.get_mut(row))
719        .and_then(Value::as_object_mut)
720        .ok_or_else(|| {
721            crate::Error::Payload(format!(
722                "operating point table `{table_name}` has no object row {row}"
723            ))
724        })?;
725    for (field, value) in fields {
726        row_object.insert(field.clone(), value.clone());
727    }
728    Ok(())
729}
730
731pub(crate) fn validate_update_fields_survived(
732    model: &ModelPayload,
733    updates: &[ElementUpdate],
734    resolved_rows: &[usize],
735) -> crate::Result<()> {
736    let value = serde_json::to_value(model)?;
737    let root = value.as_object().ok_or_else(|| {
738        crate::Error::Payload("model payload did not serialize to object".to_owned())
739    })?;
740    let payload_key = payload_key(model);
741    let payload = root
742        .get(payload_key)
743        .and_then(Value::as_object)
744        .ok_or_else(|| {
745            crate::Error::Payload(format!("model payload missing `{payload_key}` object"))
746        })?;
747
748    for (update, &resolved_row) in updates.iter().zip(resolved_rows) {
749        let table_name = update.element.table.as_str();
750        let row = payload
751            .get(table_name)
752            .and_then(Value::as_array)
753            .and_then(|table| table.get(resolved_row))
754            .and_then(Value::as_object)
755            .ok_or_else(|| {
756                crate::Error::Payload(format!(
757                    "operating point table `{table_name}` has no object row {resolved_row} \
758                     after typed materialization"
759                ))
760            })?;
761
762        for field in update.fields.keys() {
763            if !row.contains_key(field) {
764                return Err(crate::Error::Payload(format!(
765                    "operating point field `{field}` is not present on table `{table_name}` \
766                     row {resolved_row}"
767                )));
768            }
769        }
770    }
771    Ok(())
772}