1use 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#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
14#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
15#[non_exhaustive]
16pub struct OperatingPointSeries {
17 pub time_axis: TimeAxis,
19 #[serde(default, skip_serializing_if = "Vec::is_empty")]
21 pub points: Vec<OperatingPoint>,
22 #[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 #[must_use]
47 pub fn point(&self, index: usize) -> Option<&OperatingPoint> {
48 self.points.iter().find(|point| point.index == index)
49 }
50
51 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#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
72#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
73#[non_exhaustive]
74pub struct TimeAxis {
75 pub periods: usize,
77 #[serde(default, skip_serializing_if = "Vec::is_empty")]
79 pub duration_hours: Vec<f64>,
80 #[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
115#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
116#[non_exhaustive]
117pub struct OperatingPoint {
118 pub index: usize,
121 #[serde(default, skip_serializing_if = "Vec::is_empty")]
123 pub updates: Vec<ElementUpdate>,
124 #[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#[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 pub table: String,
155 #[serde(skip_serializing_if = "Option::is_none")]
159 pub row: Option<usize>,
160 #[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 #[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
244#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
245#[non_exhaustive]
246pub struct ElementUpdate {
247 pub element: ElementRef,
249 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
251 pub fields: BTreeMap<String, Value>,
252 #[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
268pub(crate) fn goc3_operating_points_from_str(
269 text: &str,
270) -> serde_json::Result<Option<OperatingPointSeries>> {
271 let document = Goc3Document::parse(text).map_err(|error| json_error(error.to_string()))?;
272 let network = document
273 .network()
274 .map_err(|error| json_error(error.to_string()))?;
275 let time_series = document
276 .time_series_input()
277 .map_err(|error| json_error(error.to_string()))?;
278 let Some(general) = time_series.get("general").and_then(Value::as_object) else {
279 return Ok(None);
280 };
281 let periods = general
282 .get("time_periods")
283 .and_then(Value::as_u64)
284 .unwrap_or(0) as usize;
285 if periods == 0 {
286 return Ok(None);
287 }
288 let duration_hours = general
289 .get("interval_duration")
290 .and_then(Value::as_array)
291 .map(|values| values.iter().filter_map(Value::as_f64).collect::<Vec<_>>())
292 .unwrap_or_default();
293 let device_ts = uid_map(
294 document
295 .time_series_input_records("simple_dispatchable_device")
296 .map_err(|error| json_error(error.to_string()))?,
297 );
298
299 let mut points = (0..periods).map(OperatingPoint::new).collect::<Vec<_>>();
300
301 let base_mva = network
302 .get("general")
303 .and_then(Value::as_object)
304 .and_then(|general| general.get("base_norm_mva"))
305 .and_then(Value::as_f64)
306 .unwrap_or(100.0);
307
308 add_goc3_device_updates(&document, &device_ts, base_mva, &mut points)?;
309 add_goc3_status_updates(&document, "ac_line", "branches", 0, &mut points)?;
310 let line_count = document
311 .network_records("ac_line")
312 .map_err(|error| json_error(error.to_string()))?
313 .len();
314 add_goc3_status_updates(
315 &document,
316 "two_winding_transformer",
317 "branches",
318 line_count,
319 &mut points,
320 )?;
321 add_goc3_status_updates(&document, "dc_line", "hvdc", 0, &mut points)?;
322
323 Ok(Some(OperatingPointSeries {
324 time_axis: TimeAxis {
325 periods,
326 duration_hours,
327 labels: (0..periods).map(|idx| (idx + 1).to_string()).collect(),
328 },
329 points,
330 metadata: BTreeMap::from([("source_format".to_owned(), json!("goc3-json"))]),
331 }))
332}
333
334fn add_goc3_device_updates(
335 document: &Goc3Document,
336 device_ts: &HashMap<String, &Value>,
337 base_mva: f64,
338 points: &mut [OperatingPoint],
339) -> serde_json::Result<()> {
340 for device in document
341 .dispatchable_devices()
342 .map_err(|error| json_error(error.to_string()))?
343 {
344 let Some(uid) = device.uid else {
345 continue;
346 };
347 let Some(ts_value) = device_ts.get(uid.as_str()) else {
348 continue;
349 };
350 let Some(ts) = ts_value.as_object() else {
351 continue;
352 };
353 match device.kind {
354 Goc3DeviceKind::Generators => {
355 for point in points.iter_mut() {
356 let mut fields = BTreeMap::new();
357 insert_scaled_at(&mut fields, ts, "p_ub", "pmax", point.index, base_mva);
358 insert_scaled_at(&mut fields, ts, "p_lb", "pmin", point.index, base_mva);
359 insert_scaled_at(&mut fields, ts, "q_ub", "qmax", point.index, base_mva);
360 insert_scaled_at(&mut fields, ts, "q_lb", "qmin", point.index, base_mva);
361 if let Some(cost) = document
362 .dispatchable_device_cost_at(
363 device.obj,
364 Some(ts_value),
365 point.index,
366 base_mva,
367 )
368 .map(serde_json::to_value)
369 .transpose()?
370 {
371 fields.insert("cost".to_owned(), cost);
372 }
373 if !fields.is_empty() {
374 let mut update = ElementUpdate::new(
375 ElementRef::new("generators", device.row).with_source_uid(uid.clone()),
376 fields,
377 );
378 update.metadata = per_period_metadata(ts, point.index);
379 point.updates.push(update);
380 }
381 }
382 }
383 Goc3DeviceKind::Loads => {
384 for point in points.iter_mut() {
385 let mut fields = BTreeMap::new();
386 insert_abs_scaled_at(&mut fields, ts, "p_ub", "p", point.index, base_mva);
387 insert_abs_scaled_at(&mut fields, ts, "q_ub", "q", point.index, base_mva);
388 if !fields.is_empty() {
389 let mut update = ElementUpdate::new(
390 ElementRef::new("loads", device.row).with_source_uid(uid.clone()),
391 fields,
392 );
393 update.metadata = per_period_metadata(ts, point.index);
394 point.updates.push(update);
395 }
396 }
397 }
398 }
399 }
400 Ok(())
401}
402
403fn add_goc3_status_updates(
404 document: &Goc3Document,
405 source_section: &'static str,
406 target_table: &'static str,
407 row_offset: usize,
408 points: &mut [OperatingPoint],
409) -> serde_json::Result<()> {
410 let source_items = document
411 .network_records(source_section)
412 .map_err(|error| json_error(error.to_string()))?;
413 if document.time_series_output().is_none() {
414 return Ok(());
415 }
416 let status_by_uid = uid_map(
417 document
418 .time_series_output_records(source_section)
419 .map_err(|error| json_error(error.to_string()))?,
420 );
421 for (row, item) in source_items.iter().enumerate() {
422 let Some(uid) = item.uid.as_ref() else {
423 continue;
424 };
425 let Some(status) = status_by_uid
426 .get(uid.as_str())
427 .and_then(|value| value.as_object())
428 else {
429 continue;
430 };
431 for point in points.iter_mut() {
432 if let Some(value) = array_number_at(status, "on_status", point.index) {
433 point.updates.push(ElementUpdate::new(
434 ElementRef::new(target_table, row_offset + row).with_source_uid(uid.clone()),
435 BTreeMap::from([("in_service".to_owned(), json!(value != 0.0))]),
436 ));
437 }
438 }
439 }
440 Ok(())
441}
442
443fn uid_map(items: Vec<Goc3Record<'_>>) -> HashMap<String, &Value> {
444 let mut out = HashMap::new();
445 for item in items {
446 if let Some(uid) = item.uid {
447 out.insert(uid, item.value);
448 }
449 }
450 out
451}
452
453fn insert_scaled_at(
454 fields: &mut BTreeMap<String, Value>,
455 obj: &Map<String, Value>,
456 source: &str,
457 target: &str,
458 index: usize,
459 scale: f64,
460) {
461 if let Some(value) = array_number_at(obj, source, index) {
462 fields.insert(target.to_owned(), json!(value * scale));
463 }
464}
465
466fn insert_abs_scaled_at(
467 fields: &mut BTreeMap<String, Value>,
468 obj: &Map<String, Value>,
469 source: &str,
470 target: &str,
471 index: usize,
472 scale: f64,
473) {
474 if let Some(value) = array_number_at(obj, source, index) {
475 fields.insert(target.to_owned(), json!(value.abs() * scale));
476 }
477}
478
479fn array_number_at(obj: &Map<String, Value>, key: &str, index: usize) -> Option<f64> {
480 obj.get(key)?.as_array()?.get(index)?.as_f64()
481}
482
483fn per_period_metadata(obj: &Map<String, Value>, index: usize) -> BTreeMap<String, Value> {
484 let mut metadata = BTreeMap::new();
485 for (key, value) in obj {
486 if key == "cost" || key.ends_with("_ub") || key.ends_with("_lb") {
487 continue;
488 }
489 if let Some(values) = value.as_array()
490 && let Some(value) = values.get(index)
491 {
492 metadata.insert(key.clone(), value.clone());
493 }
494 }
495 metadata
496}
497
498pub(crate) fn json_error(message: impl Into<String>) -> serde_json::Error {
499 <serde_json::Error as serde::de::Error>::custom(message.into())
500}
501
502pub(crate) fn apply_operating_point_to_model(
507 model: &ModelPayload,
508 point: &OperatingPoint,
509) -> serde_json::Result<(ModelPayload, BTreeSet<String>)> {
510 let mut value = serde_json::to_value(model)?;
511 let root = value.as_object_mut().ok_or_else(|| {
512 <serde_json::Error as serde::de::Error>::custom("model payload did not serialize to object")
513 })?;
514 let payload_key = payload_key(model);
515 let payload = root
516 .get_mut(payload_key)
517 .and_then(Value::as_object_mut)
518 .ok_or_else(|| {
519 <serde_json::Error as serde::de::Error>::custom(format!(
520 "model payload missing `{payload_key}` object"
521 ))
522 })?;
523
524 let mut indexes = HashMap::new();
525 let mut resolved_rows = Vec::with_capacity(point.updates.len());
526 for update in &point.updates {
527 let row = resolve_update(payload, &mut indexes, update).map_err(json_error)?;
528 apply_update_fields(payload, &update.element.table, row, &update.fields)?;
529 resolved_rows.push(row);
530 }
531
532 let updated_paths = point
533 .updates
534 .iter()
535 .zip(&resolved_rows)
536 .flat_map(|(update, row)| {
537 update.fields.keys().map(move |field| {
538 format!(
539 "/model/{payload_key}/{}/{row}/{}",
540 update.element.table, field
541 )
542 })
543 })
544 .collect();
545
546 let updated = serde_json::from_value(value)?;
547 validate_update_fields_survived(&updated, &point.updates, &resolved_rows)?;
548 Ok((updated, updated_paths))
549}
550
551pub(crate) fn check_series_identities(
556 model: &ModelPayload,
557 series: &OperatingPointSeries,
558) -> Vec<(usize, usize, String)> {
559 let payload_key = payload_key(model);
560 let payload = match serde_json::to_value(model) {
561 Ok(Value::Object(mut root)) => match root.remove(payload_key) {
562 Some(Value::Object(payload)) => payload,
563 _ => {
564 return vec![(
565 0,
566 0,
567 format!("model payload missing `{payload_key}` object"),
568 )];
569 }
570 },
571 _ => return vec![(0, 0, "model payload did not serialize to object".to_owned())],
572 };
573
574 let mut indexes = HashMap::new();
575 let mut findings = Vec::new();
576 for (point_pos, point) in series.points.iter().enumerate() {
577 for (update_pos, update) in point.updates.iter().enumerate() {
578 if let Err(message) = resolve_update(&payload, &mut indexes, update) {
579 findings.push((point_pos, update_pos, message));
580 }
581 }
582 }
583 findings
584}
585
586pub(crate) fn payload_key(model: &ModelPayload) -> &'static str {
587 match model {
588 ModelPayload::Balanced { .. } => "balanced_network",
589 ModelPayload::Multiconductor { .. } => "multiconductor_network",
590 }
591}
592
593pub(crate) struct IdentityIndex {
595 by_uid: HashMap<String, usize>,
596 duplicates: BTreeSet<String>,
598 has_uids: bool,
601}
602
603fn table_identity_index(table: &[Value]) -> IdentityIndex {
604 let mut by_uid = HashMap::with_capacity(table.len());
605 let mut duplicates = BTreeSet::new();
606 let mut has_uids = false;
607 for (row, value) in table.iter().enumerate() {
608 let Some(uid) = value.get("uid").and_then(Value::as_str) else {
609 continue;
610 };
611 has_uids = true;
612 if by_uid.insert(uid.to_owned(), row).is_some() {
613 duplicates.insert(uid.to_owned());
614 }
615 }
616 IdentityIndex {
617 by_uid,
618 duplicates,
619 has_uids,
620 }
621}
622
623pub(crate) fn resolve_update(
627 payload: &Map<String, Value>,
628 indexes: &mut HashMap<String, IdentityIndex>,
629 update: &ElementUpdate,
630) -> Result<usize, String> {
631 if update.fields.contains_key("uid") {
632 return Err(format!(
633 "operating point update on table `{}` must not overwrite `uid`",
634 update.element.table
635 ));
636 }
637 resolve_update_row(payload, indexes, &update.element)
638}
639
640pub(crate) fn resolve_update_row(
645 payload: &Map<String, Value>,
646 indexes: &mut HashMap<String, IdentityIndex>,
647 element: &ElementRef,
648) -> Result<usize, String> {
649 let table_name = element.table.as_str();
650 let Some(table) = payload.get(table_name).and_then(Value::as_array) else {
651 return Err(format!(
652 "operating point table `{table_name}` is not present or is not an array"
653 ));
654 };
655 let index = indexes
656 .entry(table_name.to_owned())
657 .or_insert_with(|| table_identity_index(table));
658 let resolved = match element.source_uid.as_deref() {
659 Some(uid) if index.duplicates.contains(uid) => {
660 return Err(format!(
661 "payload table `{table_name}` carries uid `{uid}` on more than one row; \
662 identity resolution is ambiguous"
663 ));
664 }
665 Some(uid) => match index.by_uid.get(uid) {
666 Some(&row) => {
667 if let Some(wire_row) = element.row
668 && wire_row != row
669 {
670 return Err(format!(
671 "update for table `{table_name}` names uid `{uid}` (row {row}) \
672 but carries row {wire_row}"
673 ));
674 }
675 row
676 }
677 None if index.has_uids => {
678 return Err(format!(
679 "unknown identity: table `{table_name}` has no row with uid `{uid}`"
680 ));
681 }
682 None => element.row.ok_or_else(|| {
683 format!(
684 "update for table `{table_name}` names uid `{uid}`, but the payload rows \
685 carry no uids and the update has no row to fall back on"
686 )
687 })?,
688 },
689 None => element.row.ok_or_else(|| {
690 format!("update for table `{table_name}` has neither row nor source_uid")
691 })?,
692 };
693 if resolved >= table.len() {
694 return Err(format!(
695 "operating point table `{table_name}` has no row {resolved}"
696 ));
697 }
698 Ok(resolved)
699}
700
701pub(crate) fn apply_update_fields(
702 payload: &mut serde_json::Map<String, Value>,
703 table_name: &str,
704 row: usize,
705 fields: &BTreeMap<String, Value>,
706) -> serde_json::Result<()> {
707 let row_object = payload
708 .get_mut(table_name)
709 .and_then(Value::as_array_mut)
710 .and_then(|table| table.get_mut(row))
711 .and_then(Value::as_object_mut)
712 .ok_or_else(|| {
713 json_error(format!(
714 "operating point table `{table_name}` has no object row {row}"
715 ))
716 })?;
717 for (field, value) in fields {
718 row_object.insert(field.clone(), value.clone());
719 }
720 Ok(())
721}
722
723pub(crate) fn validate_update_fields_survived(
724 model: &ModelPayload,
725 updates: &[ElementUpdate],
726 resolved_rows: &[usize],
727) -> serde_json::Result<()> {
728 let value = serde_json::to_value(model)?;
729 let root = value.as_object().ok_or_else(|| {
730 <serde_json::Error as serde::de::Error>::custom("model payload did not serialize to object")
731 })?;
732 let payload_key = payload_key(model);
733 let payload = root
734 .get(payload_key)
735 .and_then(Value::as_object)
736 .ok_or_else(|| {
737 <serde_json::Error as serde::de::Error>::custom(format!(
738 "model payload missing `{payload_key}` object"
739 ))
740 })?;
741
742 for (update, &resolved_row) in updates.iter().zip(resolved_rows) {
743 let table_name = update.element.table.as_str();
744 let row = payload
745 .get(table_name)
746 .and_then(Value::as_array)
747 .and_then(|table| table.get(resolved_row))
748 .and_then(Value::as_object)
749 .ok_or_else(|| {
750 json_error(format!(
751 "operating point table `{table_name}` has no object row {resolved_row} \
752 after typed materialization"
753 ))
754 })?;
755
756 for field in update.fields.keys() {
757 if !row.contains_key(field) {
758 return Err(json_error(format!(
759 "operating point field `{field}` is not present on table `{table_name}` \
760 row {resolved_row}"
761 )));
762 }
763 }
764 }
765 Ok(())
766}