1use std::collections::{BTreeMap, BTreeSet, HashMap};
4
5use serde::{Deserialize, Serialize};
6use serde_json::{Map, Value, json};
7
8use powerio::format::goc3_bridge::{
12 DeviceTable, SectionItem, cost_at, device_rows, item_uid, number,
13};
14
15use crate::model::ModelPayload;
16
17#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
19#[non_exhaustive]
20pub struct OperatingPointSeries {
21 pub time_axis: TimeAxis,
23 #[serde(default, skip_serializing_if = "Vec::is_empty")]
25 pub points: Vec<OperatingPoint>,
26 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
28 pub metadata: BTreeMap<String, Value>,
29}
30
31impl OperatingPointSeries {
32 #[must_use]
33 pub fn new(time_axis: TimeAxis, points: Vec<OperatingPoint>) -> Self {
34 Self {
35 time_axis,
36 points,
37 metadata: BTreeMap::new(),
38 }
39 }
40
41 #[must_use]
42 pub fn is_empty(&self) -> bool {
43 self.time_axis.is_empty() && self.points.is_empty() && self.metadata.is_empty()
44 }
45
46 #[must_use]
51 pub fn point(&self, index: usize) -> Option<&OperatingPoint> {
52 self.points.iter().find(|point| point.index == index)
53 }
54
55 pub fn unique_point(&self, index: usize) -> serde_json::Result<Option<&OperatingPoint>> {
57 let mut matches = self.points.iter().filter(|point| point.index == index);
58 let first = matches.next();
59 if matches.next().is_some() {
60 return Err(<serde_json::Error as serde::de::Error>::custom(format!(
61 "package has multiple operating points with index {index}"
62 )));
63 }
64 Ok(first)
65 }
66
67 #[must_use]
68 pub fn with_metadata(mut self, metadata: BTreeMap<String, Value>) -> Self {
69 self.metadata = metadata;
70 self
71 }
72}
73
74#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
76#[non_exhaustive]
77pub struct TimeAxis {
78 pub periods: usize,
80 #[serde(default, skip_serializing_if = "Vec::is_empty")]
82 pub duration_hours: Vec<f64>,
83 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 pub labels: Vec<String>,
86}
87
88impl TimeAxis {
89 #[must_use]
90 pub fn new(periods: usize) -> Self {
91 Self {
92 periods,
93 duration_hours: Vec::new(),
94 labels: Vec::new(),
95 }
96 }
97
98 #[must_use]
99 pub fn is_empty(&self) -> bool {
100 self.periods == 0 && self.duration_hours.is_empty() && self.labels.is_empty()
101 }
102
103 #[must_use]
104 pub fn with_duration_hours(mut self, duration_hours: Vec<f64>) -> Self {
105 self.duration_hours = duration_hours;
106 self
107 }
108
109 #[must_use]
110 pub fn with_labels(mut self, labels: Vec<String>) -> Self {
111 self.labels = labels;
112 self
113 }
114}
115
116#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
118#[non_exhaustive]
119pub struct OperatingPoint {
120 pub index: usize,
123 #[serde(default, skip_serializing_if = "Vec::is_empty")]
125 pub updates: Vec<ElementUpdate>,
126 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
128 pub metadata: BTreeMap<String, Value>,
129}
130
131impl OperatingPoint {
132 #[must_use]
133 pub fn new(index: usize) -> Self {
134 Self {
135 index,
136 updates: Vec::new(),
137 metadata: BTreeMap::new(),
138 }
139 }
140}
141
142#[derive(Clone, Debug, PartialEq, Eq)]
151#[non_exhaustive]
152pub struct ElementRef {
153 pub table: String,
155 pub row: usize,
158 pub source_uid: Option<String>,
160 row_present: bool,
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,
171 source_uid: None,
172 row_present: true,
173 }
174 }
175
176 #[must_use]
178 pub fn by_source_uid(table: impl Into<String>, uid: impl Into<String>) -> Self {
179 Self {
180 table: table.into(),
181 row: 0,
182 source_uid: Some(uid.into()),
183 row_present: false,
184 }
185 }
186
187 #[must_use]
188 pub fn with_source_uid(mut self, uid: impl Into<String>) -> Self {
189 self.source_uid = Some(uid.into());
190 self
191 }
192
193 #[must_use]
195 pub fn wire_row(&self) -> Option<usize> {
196 self.row_present.then_some(self.row)
197 }
198}
199
200impl Serialize for ElementRef {
201 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
202 use serde::ser::SerializeStruct;
203 let len = 1 + usize::from(self.row_present) + usize::from(self.source_uid.is_some());
204 let mut state = serializer.serialize_struct("ElementRef", len)?;
205 state.serialize_field("table", &self.table)?;
206 if self.row_present {
207 state.serialize_field("row", &self.row)?;
208 }
209 if let Some(uid) = &self.source_uid {
210 state.serialize_field("source_uid", uid)?;
211 }
212 state.end()
213 }
214}
215
216impl<'de> Deserialize<'de> for ElementRef {
217 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
218 #[derive(Deserialize)]
219 struct Wire {
220 table: String,
221 #[serde(default)]
222 row: Option<usize>,
223 #[serde(default)]
224 source_uid: Option<String>,
225 }
226 let wire = Wire::deserialize(deserializer)?;
227 if wire.row.is_none() && wire.source_uid.is_none() {
228 return Err(serde::de::Error::custom(
229 "element ref needs `row` or `source_uid`",
230 ));
231 }
232 Ok(Self {
233 table: wire.table,
234 row_present: wire.row.is_some(),
235 row: wire.row.unwrap_or(0),
236 source_uid: wire.source_uid,
237 })
238 }
239}
240
241#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
243#[non_exhaustive]
244pub struct ElementUpdate {
245 pub element: ElementRef,
247 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
249 pub fields: BTreeMap<String, Value>,
250 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
252 pub metadata: BTreeMap<String, Value>,
253}
254
255impl ElementUpdate {
256 #[must_use]
257 pub fn new(element: ElementRef, fields: BTreeMap<String, Value>) -> Self {
258 Self {
259 element,
260 fields,
261 metadata: BTreeMap::new(),
262 }
263 }
264}
265
266pub(crate) fn goc3_operating_points_from_str(
267 text: &str,
268) -> serde_json::Result<Option<OperatingPointSeries>> {
269 let root: Value = serde_json::from_str(text)?;
270 let Some(root) = root.as_object() else {
271 return Ok(None);
272 };
273 let Some(network) = root.get("network").and_then(Value::as_object) else {
274 return Ok(None);
275 };
276 let Some(time_series) = root.get("time_series_input").and_then(Value::as_object) else {
277 return Ok(None);
278 };
279 let Some(general) = time_series.get("general").and_then(Value::as_object) else {
280 return Ok(None);
281 };
282 let periods = general
283 .get("time_periods")
284 .and_then(Value::as_u64)
285 .unwrap_or(0) as usize;
286 if periods == 0 {
287 return Ok(None);
288 }
289 let duration_hours = general
290 .get("interval_duration")
291 .and_then(Value::as_array)
292 .map(|values| values.iter().filter_map(Value::as_f64).collect::<Vec<_>>())
293 .unwrap_or_default();
294 let device_ts = uid_map(section(time_series, "simple_dispatchable_device")?);
295 let output = root.get("time_series_output").and_then(Value::as_object);
296
297 let mut points = (0..periods).map(OperatingPoint::new).collect::<Vec<_>>();
298
299 let base_mva = network
300 .get("general")
301 .and_then(Value::as_object)
302 .and_then(|general| number(general, "base_norm_mva"))
303 .unwrap_or(100.0);
304
305 add_goc3_device_updates(network, &device_ts, base_mva, &mut points)?;
306 add_goc3_status_updates(network, output, "ac_line", "branches", 0, &mut points)?;
307 let line_count = section(network, "ac_line")?.len();
308 add_goc3_status_updates(
309 network,
310 output,
311 "two_winding_transformer",
312 "branches",
313 line_count,
314 &mut points,
315 )?;
316 add_goc3_status_updates(network, output, "dc_line", "hvdc", 0, &mut points)?;
317
318 Ok(Some(OperatingPointSeries {
319 time_axis: TimeAxis {
320 periods,
321 duration_hours,
322 labels: (0..periods).map(|idx| (idx + 1).to_string()).collect(),
323 },
324 points,
325 metadata: BTreeMap::from([("source_format".to_owned(), json!("goc3-json"))]),
326 }))
327}
328
329fn add_goc3_device_updates(
330 network: &Map<String, Value>,
331 device_ts: &HashMap<String, &Value>,
332 base_mva: f64,
333 points: &mut [OperatingPoint],
334) -> serde_json::Result<()> {
335 for device in device_rows(network).map_err(|err| json_error(err.to_string()))? {
336 let Some(uid) = device.uid else {
337 continue;
338 };
339 let Some(ts_value) = device_ts.get(uid.as_str()) else {
340 continue;
341 };
342 let Some(ts) = ts_value.as_object() else {
343 continue;
344 };
345 match device.table {
346 DeviceTable::Generators => {
347 for point in points.iter_mut() {
348 let mut fields = BTreeMap::new();
349 insert_scaled_at(&mut fields, ts, "p_ub", "pmax", point.index, base_mva);
350 insert_scaled_at(&mut fields, ts, "p_lb", "pmin", point.index, base_mva);
351 insert_scaled_at(&mut fields, ts, "q_ub", "qmax", point.index, base_mva);
352 insert_scaled_at(&mut fields, ts, "q_lb", "qmin", point.index, base_mva);
353 if let Some(cost) = cost_at(device.obj, Some(ts_value), point.index, base_mva)
354 .map(serde_json::to_value)
355 .transpose()?
356 {
357 fields.insert("cost".to_owned(), cost);
358 }
359 if !fields.is_empty() {
360 let mut update = ElementUpdate::new(
361 ElementRef::new("generators", device.row).with_source_uid(uid.clone()),
362 fields,
363 );
364 update.metadata = per_period_metadata(ts, point.index);
365 point.updates.push(update);
366 }
367 }
368 }
369 DeviceTable::Loads => {
370 for point in points.iter_mut() {
371 let mut fields = BTreeMap::new();
372 insert_abs_scaled_at(&mut fields, ts, "p_ub", "p", point.index, base_mva);
373 insert_abs_scaled_at(&mut fields, ts, "q_ub", "q", point.index, base_mva);
374 if !fields.is_empty() {
375 let mut update = ElementUpdate::new(
376 ElementRef::new("loads", device.row).with_source_uid(uid.clone()),
377 fields,
378 );
379 update.metadata = per_period_metadata(ts, point.index);
380 point.updates.push(update);
381 }
382 }
383 }
384 }
385 }
386 Ok(())
387}
388
389fn add_goc3_status_updates(
390 network: &Map<String, Value>,
391 output: Option<&Map<String, Value>>,
392 source_section: &'static str,
393 target_table: &'static str,
394 row_offset: usize,
395 points: &mut [OperatingPoint],
396) -> serde_json::Result<()> {
397 let source_items = section(network, source_section)?;
398 let Some(output) = output else {
399 return Ok(());
400 };
401 let status_by_uid = uid_map(section(output, source_section)?);
402 for (row, item) in source_items.iter().enumerate() {
403 let Some(obj) = item.value.as_object() else {
404 continue;
405 };
406 let Some(uid) = item_uid(*item, obj) else {
407 continue;
408 };
409 let Some(status) = status_by_uid
410 .get(uid.as_str())
411 .and_then(|value| value.as_object())
412 else {
413 continue;
414 };
415 for point in points.iter_mut() {
416 if let Some(value) = array_number_at(status, "on_status", point.index) {
417 point.updates.push(ElementUpdate::new(
418 ElementRef::new(target_table, row_offset + row).with_source_uid(uid.clone()),
419 BTreeMap::from([("in_service".to_owned(), json!(value != 0.0))]),
420 ));
421 }
422 }
423 }
424 Ok(())
425}
426
427fn section<'a>(
428 parent: &'a Map<String, Value>,
429 name: &'static str,
430) -> serde_json::Result<Vec<SectionItem<'a>>> {
431 powerio::format::goc3_bridge::section(parent, name).map_err(|err| json_error(err.to_string()))
432}
433
434fn uid_map(items: Vec<SectionItem<'_>>) -> HashMap<String, &Value> {
435 let mut out = HashMap::new();
436 for item in items {
437 if let Some(obj) = item.value.as_object()
438 && let Some(uid) = item_uid(item, obj)
439 {
440 out.insert(uid, item.value);
441 }
442 }
443 out
444}
445
446fn insert_scaled_at(
447 fields: &mut BTreeMap<String, Value>,
448 obj: &Map<String, Value>,
449 source: &str,
450 target: &str,
451 index: usize,
452 scale: f64,
453) {
454 if let Some(value) = array_number_at(obj, source, index) {
455 fields.insert(target.to_owned(), json!(value * scale));
456 }
457}
458
459fn insert_abs_scaled_at(
460 fields: &mut BTreeMap<String, Value>,
461 obj: &Map<String, Value>,
462 source: &str,
463 target: &str,
464 index: usize,
465 scale: f64,
466) {
467 if let Some(value) = array_number_at(obj, source, index) {
468 fields.insert(target.to_owned(), json!(value.abs() * scale));
469 }
470}
471
472fn array_number_at(obj: &Map<String, Value>, key: &str, index: usize) -> Option<f64> {
473 obj.get(key)?.as_array()?.get(index)?.as_f64()
474}
475
476fn per_period_metadata(obj: &Map<String, Value>, index: usize) -> BTreeMap<String, Value> {
477 let mut metadata = BTreeMap::new();
478 for (key, value) in obj {
479 if key == "cost" || key.ends_with("_ub") || key.ends_with("_lb") {
480 continue;
481 }
482 if let Some(values) = value.as_array()
483 && let Some(value) = values.get(index)
484 {
485 metadata.insert(key.clone(), value.clone());
486 }
487 }
488 metadata
489}
490
491fn json_error(message: impl Into<String>) -> serde_json::Error {
492 <serde_json::Error as serde::de::Error>::custom(message.into())
493}
494
495pub(crate) fn apply_operating_point_to_model(
500 model: &ModelPayload,
501 point: &OperatingPoint,
502) -> serde_json::Result<(ModelPayload, BTreeSet<String>)> {
503 let mut value = serde_json::to_value(model)?;
504 let root = value.as_object_mut().ok_or_else(|| {
505 <serde_json::Error as serde::de::Error>::custom("model payload did not serialize to object")
506 })?;
507 let payload_key = payload_key(model);
508 let payload = root
509 .get_mut(payload_key)
510 .and_then(Value::as_object_mut)
511 .ok_or_else(|| {
512 <serde_json::Error as serde::de::Error>::custom(format!(
513 "model payload missing `{payload_key}` object"
514 ))
515 })?;
516
517 let mut indexes = HashMap::new();
518 let mut resolved_rows = Vec::with_capacity(point.updates.len());
519 for update in &point.updates {
520 let row = resolve_update(payload, &mut indexes, update).map_err(json_error)?;
521 apply_update_fields(payload, &update.element.table, row, &update.fields)?;
522 resolved_rows.push(row);
523 }
524
525 let updated_paths = point
526 .updates
527 .iter()
528 .zip(&resolved_rows)
529 .flat_map(|(update, row)| {
530 update.fields.keys().map(move |field| {
531 format!(
532 "/model/{payload_key}/{}/{row}/{}",
533 update.element.table, field
534 )
535 })
536 })
537 .collect();
538
539 let updated = serde_json::from_value(value)?;
540 validate_update_fields_survived(&updated, &point.updates, &resolved_rows)?;
541 Ok((updated, updated_paths))
542}
543
544pub(crate) fn check_series_identities(
549 model: &ModelPayload,
550 series: &OperatingPointSeries,
551) -> Vec<(usize, usize, String)> {
552 let payload_key = payload_key(model);
553 let payload = match serde_json::to_value(model) {
554 Ok(Value::Object(mut root)) => match root.remove(payload_key) {
555 Some(Value::Object(payload)) => payload,
556 _ => {
557 return vec![(
558 0,
559 0,
560 format!("model payload missing `{payload_key}` object"),
561 )];
562 }
563 },
564 _ => return vec![(0, 0, "model payload did not serialize to object".to_owned())],
565 };
566
567 let mut indexes = HashMap::new();
568 let mut findings = Vec::new();
569 for (point_pos, point) in series.points.iter().enumerate() {
570 for (update_pos, update) in point.updates.iter().enumerate() {
571 if let Err(message) = resolve_update(&payload, &mut indexes, update) {
572 findings.push((point_pos, update_pos, message));
573 }
574 }
575 }
576 findings
577}
578
579fn payload_key(model: &ModelPayload) -> &'static str {
580 match model {
581 ModelPayload::Balanced { .. } => "balanced_network",
582 ModelPayload::Multiconductor { .. } => "multiconductor_network",
583 }
584}
585
586struct IdentityIndex {
588 by_uid: HashMap<String, usize>,
589 duplicates: BTreeSet<String>,
591 has_uids: bool,
594}
595
596fn table_identity_index(table: &[Value]) -> IdentityIndex {
597 let mut by_uid = HashMap::with_capacity(table.len());
598 let mut duplicates = BTreeSet::new();
599 let mut has_uids = false;
600 for (row, value) in table.iter().enumerate() {
601 let Some(uid) = value.get("uid").and_then(Value::as_str) else {
602 continue;
603 };
604 has_uids = true;
605 if by_uid.insert(uid.to_owned(), row).is_some() {
606 duplicates.insert(uid.to_owned());
607 }
608 }
609 IdentityIndex {
610 by_uid,
611 duplicates,
612 has_uids,
613 }
614}
615
616fn resolve_update(
620 payload: &Map<String, Value>,
621 indexes: &mut HashMap<String, IdentityIndex>,
622 update: &ElementUpdate,
623) -> Result<usize, String> {
624 if update.fields.contains_key("uid") {
625 return Err(format!(
626 "operating point update on table `{}` must not overwrite `uid`",
627 update.element.table
628 ));
629 }
630 resolve_update_row(payload, indexes, &update.element)
631}
632
633fn resolve_update_row(
638 payload: &Map<String, Value>,
639 indexes: &mut HashMap<String, IdentityIndex>,
640 element: &ElementRef,
641) -> Result<usize, String> {
642 let table_name = element.table.as_str();
643 let Some(table) = payload.get(table_name).and_then(Value::as_array) else {
644 return Err(format!(
645 "operating point table `{table_name}` is not present or is not an array"
646 ));
647 };
648 let index = indexes
649 .entry(table_name.to_owned())
650 .or_insert_with(|| table_identity_index(table));
651 let resolved = match element.source_uid.as_deref() {
652 Some(uid) if index.duplicates.contains(uid) => {
653 return Err(format!(
654 "payload table `{table_name}` carries uid `{uid}` on more than one row; \
655 identity resolution is ambiguous"
656 ));
657 }
658 Some(uid) => match index.by_uid.get(uid) {
659 Some(&row) => {
660 if let Some(wire_row) = element.wire_row()
661 && wire_row != row
662 {
663 return Err(format!(
664 "update for table `{table_name}` names uid `{uid}` (row {row}) \
665 but carries row {wire_row}"
666 ));
667 }
668 row
669 }
670 None if index.has_uids => {
671 return Err(format!(
672 "unknown identity: table `{table_name}` has no row with uid `{uid}`"
673 ));
674 }
675 None => element.wire_row().ok_or_else(|| {
676 format!(
677 "update for table `{table_name}` names uid `{uid}`, but the payload rows \
678 carry no uids and the update has no row to fall back on"
679 )
680 })?,
681 },
682 None => element.wire_row().ok_or_else(|| {
683 format!("update for table `{table_name}` has neither row nor source_uid")
684 })?,
685 };
686 if resolved >= table.len() {
687 return Err(format!(
688 "operating point table `{table_name}` has no row {resolved}"
689 ));
690 }
691 Ok(resolved)
692}
693
694fn apply_update_fields(
695 payload: &mut serde_json::Map<String, Value>,
696 table_name: &str,
697 row: usize,
698 fields: &BTreeMap<String, Value>,
699) -> serde_json::Result<()> {
700 let row_object = payload
701 .get_mut(table_name)
702 .and_then(Value::as_array_mut)
703 .and_then(|table| table.get_mut(row))
704 .and_then(Value::as_object_mut)
705 .ok_or_else(|| {
706 json_error(format!(
707 "operating point table `{table_name}` has no object row {row}"
708 ))
709 })?;
710 for (field, value) in fields {
711 row_object.insert(field.clone(), value.clone());
712 }
713 Ok(())
714}
715
716fn validate_update_fields_survived(
717 model: &ModelPayload,
718 updates: &[ElementUpdate],
719 resolved_rows: &[usize],
720) -> serde_json::Result<()> {
721 let value = serde_json::to_value(model)?;
722 let root = value.as_object().ok_or_else(|| {
723 <serde_json::Error as serde::de::Error>::custom("model payload did not serialize to object")
724 })?;
725 let payload_key = payload_key(model);
726 let payload = root
727 .get(payload_key)
728 .and_then(Value::as_object)
729 .ok_or_else(|| {
730 <serde_json::Error as serde::de::Error>::custom(format!(
731 "model payload missing `{payload_key}` object"
732 ))
733 })?;
734
735 for (update, &resolved_row) in updates.iter().zip(resolved_rows) {
736 let table_name = update.element.table.as_str();
737 let row = payload
738 .get(table_name)
739 .and_then(Value::as_array)
740 .and_then(|table| table.get(resolved_row))
741 .and_then(Value::as_object)
742 .ok_or_else(|| {
743 json_error(format!(
744 "operating point table `{table_name}` has no object row {resolved_row} \
745 after typed materialization"
746 ))
747 })?;
748
749 for field in update.fields.keys() {
750 if !row.contains_key(field) {
751 return Err(json_error(format!(
752 "operating point field `{field}` is not present on table `{table_name}` \
753 row {resolved_row}"
754 )));
755 }
756 }
757 }
758 Ok(())
759}