Skip to main content

powerio_pkg/
study.rs

1//! Cumulative study edits for `.pio.json` packages.
2
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4
5use serde::{Deserialize, Serialize, de::Error as _};
6use serde_json::{Map, Value, json};
7
8use crate::model::ModelPayload;
9use crate::operating::{
10    ElementRef, ElementUpdate, IdentityIndex, apply_update_fields, payload_key, resolve_update,
11    resolve_update_row, validate_update_fields_survived,
12};
13
14/// Additive study block stored on a package.
15#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
16#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
17#[non_exhaustive]
18pub struct StudyBlock {
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub label: Option<String>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub author: Option<String>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub created_at: Option<String>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub base_operating_point: Option<usize>,
27    #[serde(default, skip_serializing_if = "Vec::is_empty")]
28    pub commits: Vec<StudyCommit>,
29    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
30    pub app: BTreeMap<String, Value>,
31    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
32    pub metadata: BTreeMap<String, Value>,
33}
34
35impl StudyBlock {
36    #[must_use]
37    pub fn is_empty(&self) -> bool {
38        self.label.is_none()
39            && self.author.is_none()
40            && self.created_at.is_none()
41            && self.base_operating_point.is_none()
42            && self.commits.is_empty()
43            && self.app.is_empty()
44            && self.metadata.is_empty()
45    }
46}
47
48/// One cumulative commit in a study block.
49#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
50#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
51#[non_exhaustive]
52pub struct StudyCommit {
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub label: Option<String>,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub created_at: Option<String>,
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub edits: Vec<StudyEdit>,
59    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
60    pub metadata: BTreeMap<String, Value>,
61}
62
63/// One study edit. Unknown edit kinds are preserved and rejected only when a
64/// caller tries to materialize them.
65#[derive(Clone, Debug, PartialEq)]
66#[non_exhaustive]
67pub enum StudyEdit {
68    DemandDelta {
69        bus: ElementRef,
70        p_mw: f64,
71        q_mvar: Option<f64>,
72    },
73    RatingDelta {
74        branch: ElementRef,
75        delta_mw: f64,
76    },
77    SetFields {
78        update: ElementUpdate,
79    },
80    Unknown {
81        kind: String,
82        value: Value,
83    },
84}
85
86impl StudyEdit {
87    #[must_use]
88    pub fn kind(&self) -> &str {
89        match self {
90            Self::DemandDelta { .. } => "demand_delta",
91            Self::RatingDelta { .. } => "rating_delta",
92            Self::SetFields { .. } => "set_fields",
93            Self::Unknown { kind, .. } => kind,
94        }
95    }
96}
97
98#[cfg(feature = "schema")]
99impl schemars::JsonSchema for StudyEdit {
100    fn schema_name() -> std::borrow::Cow<'static, str> {
101        "StudyEdit".into()
102    }
103
104    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
105        let element_ref = generator.subschema_for::<ElementRef>().to_value();
106        let element_update = generator.subschema_for::<ElementUpdate>().to_value();
107        let schema = json!({
108            "type": "object",
109            "oneOf": [
110                {
111                    "type": "object",
112                    "required": ["kind", "bus", "p_mw"],
113                    "properties": {
114                        "kind": { "const": "demand_delta" },
115                        "bus": element_ref,
116                        "p_mw": { "type": "number" },
117                        "q_mvar": { "type": "number" }
118                    }
119                },
120                {
121                    "type": "object",
122                    "required": ["kind", "branch", "delta_mw"],
123                    "properties": {
124                        "kind": { "const": "rating_delta" },
125                        "branch": element_ref,
126                        "delta_mw": { "type": "number" }
127                    }
128                },
129                {
130                    "type": "object",
131                    "required": ["kind", "update"],
132                    "properties": {
133                        "kind": { "const": "set_fields" },
134                        "update": element_update
135                    }
136                },
137                {
138                    "type": "object",
139                    "required": ["kind"],
140                    "properties": {
141                        "kind": {
142                            "type": "string",
143                            "not": {
144                                "enum": ["demand_delta", "rating_delta", "set_fields"]
145                            }
146                        }
147                    },
148                    "additionalProperties": true
149                }
150            ]
151        });
152        schemars::Schema::try_from(schema).expect("study edit schema is an object")
153    }
154}
155
156impl Serialize for StudyEdit {
157    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
158        match self {
159            Self::DemandDelta { bus, p_mw, q_mvar } => {
160                #[derive(Serialize)]
161                struct Stated<'a> {
162                    kind: &'static str,
163                    bus: &'a ElementRef,
164                    p_mw: f64,
165                    #[serde(skip_serializing_if = "Option::is_none")]
166                    q_mvar: Option<f64>,
167                }
168                Stated {
169                    kind: "demand_delta",
170                    bus,
171                    p_mw: *p_mw,
172                    q_mvar: *q_mvar,
173                }
174                .serialize(serializer)
175            }
176            Self::RatingDelta { branch, delta_mw } => {
177                #[derive(Serialize)]
178                struct Stated<'a> {
179                    kind: &'static str,
180                    branch: &'a ElementRef,
181                    delta_mw: f64,
182                }
183                Stated {
184                    kind: "rating_delta",
185                    branch,
186                    delta_mw: *delta_mw,
187                }
188                .serialize(serializer)
189            }
190            Self::SetFields { update } => {
191                #[derive(Serialize)]
192                struct Stated<'a> {
193                    kind: &'static str,
194                    update: &'a ElementUpdate,
195                }
196                Stated {
197                    kind: "set_fields",
198                    update,
199                }
200                .serialize(serializer)
201            }
202            Self::Unknown { value, .. } => value.serialize(serializer),
203        }
204    }
205}
206
207impl<'de> Deserialize<'de> for StudyEdit {
208    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
209        let value = Value::deserialize(deserializer)?;
210        let object = value
211            .as_object()
212            .ok_or_else(|| serde::de::Error::custom("study edit must be an object"))?;
213        let kind = object
214            .get("kind")
215            .and_then(Value::as_str)
216            .ok_or_else(|| serde::de::Error::custom("study edit needs string `kind`"))?;
217        match kind {
218            "demand_delta" => {
219                #[derive(Deserialize)]
220                struct Stated {
221                    bus: ElementRef,
222                    p_mw: f64,
223                    #[serde(default)]
224                    q_mvar: Option<f64>,
225                }
226                let stated = serde_json::from_value::<Stated>(value).map_err(D::Error::custom)?;
227                Ok(Self::DemandDelta {
228                    bus: stated.bus,
229                    p_mw: stated.p_mw,
230                    q_mvar: stated.q_mvar,
231                })
232            }
233            "rating_delta" => {
234                #[derive(Deserialize)]
235                struct Stated {
236                    branch: ElementRef,
237                    delta_mw: f64,
238                }
239                let stated = serde_json::from_value::<Stated>(value).map_err(D::Error::custom)?;
240                Ok(Self::RatingDelta {
241                    branch: stated.branch,
242                    delta_mw: stated.delta_mw,
243                })
244            }
245            "set_fields" => {
246                #[derive(Deserialize)]
247                struct Stated {
248                    update: ElementUpdate,
249                }
250                let stated = serde_json::from_value::<Stated>(value).map_err(D::Error::custom)?;
251                Ok(Self::SetFields {
252                    update: stated.update,
253                })
254            }
255            other => Ok(Self::Unknown {
256                kind: other.to_owned(),
257                value,
258            }),
259        }
260    }
261}
262
263/// Apply commits `0..=commit_index` to a balanced model and return the updated
264/// model plus JSON Pointer paths touched by the edits.
265pub(crate) fn apply_study_to_model(
266    model: &ModelPayload,
267    study: &StudyBlock,
268    commit_index: usize,
269) -> crate::Result<(ModelPayload, BTreeSet<String>)> {
270    if !matches!(model, ModelPayload::Balanced { .. }) {
271        return Err(crate::Error::Payload(
272            "STUDY.WRONG_MODEL_KIND: study materialization requires a balanced package".to_owned(),
273        ));
274    }
275    if study.commits.get(commit_index).is_none() {
276        return Err(crate::Error::Payload(format!(
277            "package has no study commit {commit_index}"
278        )));
279    }
280
281    let mut value = serde_json::to_value(model)?;
282    let payload_key = payload_key(model);
283    let payload = value
284        .as_object_mut()
285        .and_then(|root| root.get_mut(payload_key))
286        .and_then(Value::as_object_mut)
287        .ok_or_else(|| {
288            crate::Error::Payload(format!("model payload missing `{payload_key}` object"))
289        })?;
290
291    let mut indexes = HashMap::new();
292    let mut updated_paths = BTreeSet::new();
293    let mut set_field_updates = Vec::new();
294    let mut set_field_rows = Vec::new();
295    let mut context = StudyApplyContext {
296        payload,
297        payload_key,
298        indexes: &mut indexes,
299        updated_paths: &mut updated_paths,
300        set_field_updates: &mut set_field_updates,
301        set_field_rows: &mut set_field_rows,
302    };
303    for (commit_pos, commit) in study.commits.iter().take(commit_index + 1).enumerate() {
304        for (edit_pos, edit) in commit.edits.iter().enumerate() {
305            context.apply_edit(edit, commit_pos, edit_pos)?;
306        }
307    }
308
309    // `set_fields` values come from the document and are inserted untyped, so
310    // this deserialization fails on a caller's edit, not on our output. The
311    // blanket From would report it as a serialization failure.
312    let updated =
313        serde_json::from_value(value).map_err(|error| crate::Error::Payload(error.to_string()))?;
314    validate_update_fields_survived(&updated, &set_field_updates, &set_field_rows)?;
315    Ok((updated, updated_paths))
316}
317
318/// Dry run identity resolution for every known study edit.
319pub(crate) fn check_study_identities(
320    model: &ModelPayload,
321    study: &StudyBlock,
322) -> Vec<(usize, usize, String)> {
323    let payload_key = payload_key(model);
324    let payload = match serde_json::to_value(model) {
325        Ok(Value::Object(mut root)) => match root.remove(payload_key) {
326            Some(Value::Object(payload)) => payload,
327            _ => {
328                return vec![(
329                    0,
330                    0,
331                    format!("model payload missing `{payload_key}` object"),
332                )];
333            }
334        },
335        _ => return vec![(0, 0, "model payload did not serialize to object".to_owned())],
336    };
337
338    let mut indexes = HashMap::new();
339    let mut findings = Vec::new();
340    for (commit_pos, commit) in study.commits.iter().enumerate() {
341        for (edit_pos, edit) in commit.edits.iter().enumerate() {
342            let result = match edit {
343                StudyEdit::DemandDelta { bus, .. } => {
344                    resolve_update_row(&payload, &mut indexes, bus).map(|_| ())
345                }
346                StudyEdit::RatingDelta { branch, .. } => {
347                    resolve_update_row(&payload, &mut indexes, branch).map(|_| ())
348                }
349                StudyEdit::SetFields { update } => {
350                    resolve_update(&payload, &mut indexes, update).map(|_| ())
351                }
352                StudyEdit::Unknown { .. } => Ok(()),
353            };
354            if let Err(message) = result {
355                findings.push((commit_pos, edit_pos, message));
356            }
357        }
358    }
359    findings
360}
361
362struct StudyApplyContext<'a> {
363    payload: &'a mut Map<String, Value>,
364    payload_key: &'a str,
365    indexes: &'a mut HashMap<String, IdentityIndex>,
366    updated_paths: &'a mut BTreeSet<String>,
367    set_field_updates: &'a mut Vec<ElementUpdate>,
368    set_field_rows: &'a mut Vec<usize>,
369}
370
371impl StudyApplyContext<'_> {
372    fn apply_edit(
373        &mut self,
374        edit: &StudyEdit,
375        commit_pos: usize,
376        edit_pos: usize,
377    ) -> crate::Result<()> {
378        match edit {
379            StudyEdit::DemandDelta { bus, p_mw, q_mvar } => {
380                let bus_row = resolve_update_row(self.payload, self.indexes, bus)
381                    .map_err(crate::Error::Payload)?;
382                let touched = apply_demand_delta(self.payload, bus_row, *p_mw, *q_mvar)?;
383                for path in touched {
384                    self.updated_paths
385                        .insert(format!("/model/{}/{path}", self.payload_key));
386                }
387                self.indexes.remove("loads");
388            }
389            StudyEdit::RatingDelta { branch, delta_mw } => {
390                let branch_row = resolve_update_row(self.payload, self.indexes, branch)
391                    .map_err(crate::Error::Payload)?;
392                let branch = row_object_mut(self.payload, "branches", branch_row)?;
393                let old = number_field(branch, "rate_a", "branch", branch_row)?;
394                branch.insert("rate_a".to_owned(), json!(old + delta_mw));
395                self.updated_paths.insert(format!(
396                    "/model/{}/branches/{branch_row}/rate_a",
397                    self.payload_key
398                ));
399            }
400            StudyEdit::SetFields { update } => {
401                let row = resolve_update(self.payload, self.indexes, update)
402                    .map_err(crate::Error::Payload)?;
403                apply_update_fields(self.payload, &update.element.table, row, &update.fields)?;
404                for field in update.fields.keys() {
405                    self.updated_paths.insert(format!(
406                        "/model/{}/{}/{row}/{field}",
407                        self.payload_key, update.element.table
408                    ));
409                }
410                self.set_field_updates.push(update.clone());
411                self.set_field_rows.push(row);
412            }
413            StudyEdit::Unknown { kind, .. } => {
414                return Err(crate::Error::Payload(format!(
415                    "STUDY.UNKNOWN_EDIT_KIND: study commit {commit_pos} edit {edit_pos} has unsupported kind `{kind}`"
416                )));
417            }
418        }
419        Ok(())
420    }
421}
422
423fn apply_demand_delta(
424    payload: &mut Map<String, Value>,
425    bus_row: usize,
426    p_delta: f64,
427    q_delta: Option<f64>,
428) -> crate::Result<Vec<String>> {
429    let (bus_id, bus_uid) = {
430        let bus = row_object(payload, "buses", bus_row)?;
431        let bus_id = bus.get("id").and_then(Value::as_u64).ok_or_else(|| {
432            crate::Error::Payload(format!("bus row {bus_row} has no numeric `id`"))
433        })?;
434        let bus_uid = bus
435            .get("uid")
436            .and_then(Value::as_str)
437            .map_or_else(|| format!("buses:{bus_row}"), str::to_owned);
438        (bus_id, bus_uid)
439    };
440
441    let loads = payload
442        .get_mut("loads")
443        .and_then(Value::as_array_mut)
444        .ok_or_else(|| crate::Error::Payload("balanced payload has no `loads` array".to_owned()))?;
445    let mut rows = Vec::new();
446    let mut total_p = 0.0;
447    let mut total_q = 0.0;
448    for (row, load) in loads.iter().enumerate() {
449        let Some(load) = load.as_object() else {
450            continue;
451        };
452        if load.get("bus").and_then(Value::as_u64) != Some(bus_id) {
453            continue;
454        }
455        if !load
456            .get("in_service")
457            .and_then(Value::as_bool)
458            .unwrap_or(true)
459        {
460            continue;
461        }
462        let p = load.get("p").and_then(Value::as_f64).unwrap_or(0.0);
463        let q = load.get("q").and_then(Value::as_f64).unwrap_or(0.0);
464        rows.push((row, p, q));
465        total_p += p;
466        total_q += q;
467    }
468
469    if rows.is_empty() || total_p == 0.0 {
470        let q = q_delta.unwrap_or(0.0);
471        loads.push(json!({
472            "bus": bus_id,
473            "p": p_delta,
474            "q": q,
475            "voltage_model": null,
476            "in_service": true,
477            "uid": format!("study:load:{bus_uid}"),
478            "extras": {
479                "study": {
480                    "synthetic": true,
481                    "source": "demand_delta"
482                }
483            }
484        }));
485        let row = loads.len() - 1;
486        return Ok(vec![
487            format!("loads/{row}/p"),
488            format!("loads/{row}/q"),
489            format!("loads/{row}/uid"),
490            format!("loads/{row}/extras"),
491        ]);
492    }
493
494    let q_delta = q_delta.unwrap_or_else(|| p_delta * total_q / total_p);
495    let mut touched = Vec::new();
496    for (row, p, q) in rows {
497        let p_share = p / total_p;
498        let q_share = if total_q == 0.0 { p_share } else { q / total_q };
499        let load = loads
500            .get_mut(row)
501            .and_then(Value::as_object_mut)
502            .ok_or_else(|| crate::Error::Payload(format!("load row {row} disappeared")))?;
503        load.insert("p".to_owned(), json!(p + p_delta * p_share));
504        load.insert("q".to_owned(), json!(q + q_delta * q_share));
505        touched.push(format!("loads/{row}/p"));
506        touched.push(format!("loads/{row}/q"));
507    }
508    Ok(touched)
509}
510
511fn row_object<'a>(
512    payload: &'a Map<String, Value>,
513    table_name: &str,
514    row: usize,
515) -> crate::Result<&'a Map<String, Value>> {
516    payload
517        .get(table_name)
518        .and_then(Value::as_array)
519        .and_then(|table| table.get(row))
520        .and_then(Value::as_object)
521        .ok_or_else(|| {
522            crate::Error::Payload(format!("table `{table_name}` has no object row {row}"))
523        })
524}
525
526fn row_object_mut<'a>(
527    payload: &'a mut Map<String, Value>,
528    table_name: &str,
529    row: usize,
530) -> crate::Result<&'a mut Map<String, Value>> {
531    payload
532        .get_mut(table_name)
533        .and_then(Value::as_array_mut)
534        .and_then(|table| table.get_mut(row))
535        .and_then(Value::as_object_mut)
536        .ok_or_else(|| {
537            crate::Error::Payload(format!("table `{table_name}` has no object row {row}"))
538        })
539}
540
541fn number_field(
542    object: &Map<String, Value>,
543    field: &str,
544    label: &str,
545    row: usize,
546) -> crate::Result<f64> {
547    object.get(field).and_then(Value::as_f64).ok_or_else(|| {
548        crate::Error::Payload(format!("{label} row {row} has no numeric `{field}` field"))
549    })
550}