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