1use 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#[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#[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#[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
263pub(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 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
318pub(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}