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, Serialize)]
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
192impl<'de> Deserialize<'de> for ElementRef {
193 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
194 #[derive(Deserialize)]
195 struct Wire {
196 table: String,
197 #[serde(default)]
198 row: Option<usize>,
199 #[serde(default)]
200 source_uid: Option<String>,
201 }
202 let wire = Wire::deserialize(deserializer)?;
203 if wire.row.is_none() && wire.source_uid.is_none() {
204 return Err(serde::de::Error::custom(
205 "element ref needs `row` or `source_uid`",
206 ));
207 }
208 Ok(Self {
209 table: wire.table,
210 row: wire.row,
211 source_uid: wire.source_uid,
212 })
213 }
214}
215
216#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
218#[non_exhaustive]
219pub struct ElementUpdate {
220 pub element: ElementRef,
222 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
224 pub fields: BTreeMap<String, Value>,
225 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
227 pub metadata: BTreeMap<String, Value>,
228}
229
230impl ElementUpdate {
231 #[must_use]
232 pub fn new(element: ElementRef, fields: BTreeMap<String, Value>) -> Self {
233 Self {
234 element,
235 fields,
236 metadata: BTreeMap::new(),
237 }
238 }
239}
240
241pub(crate) fn goc3_operating_points_from_str(
242 text: &str,
243) -> serde_json::Result<Option<OperatingPointSeries>> {
244 let root: Value = serde_json::from_str(text)?;
245 let Some(root) = root.as_object() else {
246 return Ok(None);
247 };
248 let Some(network) = root.get("network").and_then(Value::as_object) else {
249 return Ok(None);
250 };
251 let Some(time_series) = root.get("time_series_input").and_then(Value::as_object) else {
252 return Ok(None);
253 };
254 let Some(general) = time_series.get("general").and_then(Value::as_object) else {
255 return Ok(None);
256 };
257 let periods = general
258 .get("time_periods")
259 .and_then(Value::as_u64)
260 .unwrap_or(0) as usize;
261 if periods == 0 {
262 return Ok(None);
263 }
264 let duration_hours = general
265 .get("interval_duration")
266 .and_then(Value::as_array)
267 .map(|values| values.iter().filter_map(Value::as_f64).collect::<Vec<_>>())
268 .unwrap_or_default();
269 let device_ts = uid_map(section(time_series, "simple_dispatchable_device")?);
270 let output = root.get("time_series_output").and_then(Value::as_object);
271
272 let mut points = (0..periods).map(OperatingPoint::new).collect::<Vec<_>>();
273
274 let base_mva = network
275 .get("general")
276 .and_then(Value::as_object)
277 .and_then(|general| number(general, "base_norm_mva"))
278 .unwrap_or(100.0);
279
280 add_goc3_device_updates(network, &device_ts, base_mva, &mut points)?;
281 add_goc3_status_updates(network, output, "ac_line", "branches", 0, &mut points)?;
282 let line_count = section(network, "ac_line")?.len();
283 add_goc3_status_updates(
284 network,
285 output,
286 "two_winding_transformer",
287 "branches",
288 line_count,
289 &mut points,
290 )?;
291 add_goc3_status_updates(network, output, "dc_line", "hvdc", 0, &mut points)?;
292
293 Ok(Some(OperatingPointSeries {
294 time_axis: TimeAxis {
295 periods,
296 duration_hours,
297 labels: (0..periods).map(|idx| (idx + 1).to_string()).collect(),
298 },
299 points,
300 metadata: BTreeMap::from([("source_format".to_owned(), json!("goc3-json"))]),
301 }))
302}
303
304fn add_goc3_device_updates(
305 network: &Map<String, Value>,
306 device_ts: &HashMap<String, &Value>,
307 base_mva: f64,
308 points: &mut [OperatingPoint],
309) -> serde_json::Result<()> {
310 for device in device_rows(network).map_err(|err| json_error(err.to_string()))? {
311 let Some(uid) = device.uid else {
312 continue;
313 };
314 let Some(ts_value) = device_ts.get(uid.as_str()) else {
315 continue;
316 };
317 let Some(ts) = ts_value.as_object() else {
318 continue;
319 };
320 match device.table {
321 DeviceTable::Generators => {
322 for point in points.iter_mut() {
323 let mut fields = BTreeMap::new();
324 insert_scaled_at(&mut fields, ts, "p_ub", "pmax", point.index, base_mva);
325 insert_scaled_at(&mut fields, ts, "p_lb", "pmin", point.index, base_mva);
326 insert_scaled_at(&mut fields, ts, "q_ub", "qmax", point.index, base_mva);
327 insert_scaled_at(&mut fields, ts, "q_lb", "qmin", point.index, base_mva);
328 if let Some(cost) = cost_at(device.obj, Some(ts_value), point.index, base_mva)
329 .map(serde_json::to_value)
330 .transpose()?
331 {
332 fields.insert("cost".to_owned(), cost);
333 }
334 if !fields.is_empty() {
335 let mut update = ElementUpdate::new(
336 ElementRef::new("generators", device.row).with_source_uid(uid.clone()),
337 fields,
338 );
339 update.metadata = per_period_metadata(ts, point.index);
340 point.updates.push(update);
341 }
342 }
343 }
344 DeviceTable::Loads => {
345 for point in points.iter_mut() {
346 let mut fields = BTreeMap::new();
347 insert_abs_scaled_at(&mut fields, ts, "p_ub", "p", point.index, base_mva);
348 insert_abs_scaled_at(&mut fields, ts, "q_ub", "q", point.index, base_mva);
349 if !fields.is_empty() {
350 let mut update = ElementUpdate::new(
351 ElementRef::new("loads", device.row).with_source_uid(uid.clone()),
352 fields,
353 );
354 update.metadata = per_period_metadata(ts, point.index);
355 point.updates.push(update);
356 }
357 }
358 }
359 }
360 }
361 Ok(())
362}
363
364fn add_goc3_status_updates(
365 network: &Map<String, Value>,
366 output: Option<&Map<String, Value>>,
367 source_section: &'static str,
368 target_table: &'static str,
369 row_offset: usize,
370 points: &mut [OperatingPoint],
371) -> serde_json::Result<()> {
372 let source_items = section(network, source_section)?;
373 let Some(output) = output else {
374 return Ok(());
375 };
376 let status_by_uid = uid_map(section(output, source_section)?);
377 for (row, item) in source_items.iter().enumerate() {
378 let Some(obj) = item.value.as_object() else {
379 continue;
380 };
381 let Some(uid) = item_uid(*item, obj) else {
382 continue;
383 };
384 let Some(status) = status_by_uid
385 .get(uid.as_str())
386 .and_then(|value| value.as_object())
387 else {
388 continue;
389 };
390 for point in points.iter_mut() {
391 if let Some(value) = array_number_at(status, "on_status", point.index) {
392 point.updates.push(ElementUpdate::new(
393 ElementRef::new(target_table, row_offset + row).with_source_uid(uid.clone()),
394 BTreeMap::from([("in_service".to_owned(), json!(value != 0.0))]),
395 ));
396 }
397 }
398 }
399 Ok(())
400}
401
402fn section<'a>(
403 parent: &'a Map<String, Value>,
404 name: &'static str,
405) -> serde_json::Result<Vec<SectionItem<'a>>> {
406 powerio::format::goc3_bridge::section(parent, name).map_err(|err| json_error(err.to_string()))
407}
408
409fn uid_map(items: Vec<SectionItem<'_>>) -> HashMap<String, &Value> {
410 let mut out = HashMap::new();
411 for item in items {
412 if let Some(obj) = item.value.as_object()
413 && let Some(uid) = item_uid(item, obj)
414 {
415 out.insert(uid, item.value);
416 }
417 }
418 out
419}
420
421fn insert_scaled_at(
422 fields: &mut BTreeMap<String, Value>,
423 obj: &Map<String, Value>,
424 source: &str,
425 target: &str,
426 index: usize,
427 scale: f64,
428) {
429 if let Some(value) = array_number_at(obj, source, index) {
430 fields.insert(target.to_owned(), json!(value * scale));
431 }
432}
433
434fn insert_abs_scaled_at(
435 fields: &mut BTreeMap<String, Value>,
436 obj: &Map<String, Value>,
437 source: &str,
438 target: &str,
439 index: usize,
440 scale: f64,
441) {
442 if let Some(value) = array_number_at(obj, source, index) {
443 fields.insert(target.to_owned(), json!(value.abs() * scale));
444 }
445}
446
447fn array_number_at(obj: &Map<String, Value>, key: &str, index: usize) -> Option<f64> {
448 obj.get(key)?.as_array()?.get(index)?.as_f64()
449}
450
451fn per_period_metadata(obj: &Map<String, Value>, index: usize) -> BTreeMap<String, Value> {
452 let mut metadata = BTreeMap::new();
453 for (key, value) in obj {
454 if key == "cost" || key.ends_with("_ub") || key.ends_with("_lb") {
455 continue;
456 }
457 if let Some(values) = value.as_array()
458 && let Some(value) = values.get(index)
459 {
460 metadata.insert(key.clone(), value.clone());
461 }
462 }
463 metadata
464}
465
466fn json_error(message: impl Into<String>) -> serde_json::Error {
467 <serde_json::Error as serde::de::Error>::custom(message.into())
468}
469
470pub(crate) fn apply_operating_point_to_model(
475 model: &ModelPayload,
476 point: &OperatingPoint,
477) -> serde_json::Result<(ModelPayload, BTreeSet<String>)> {
478 let mut value = serde_json::to_value(model)?;
479 let root = value.as_object_mut().ok_or_else(|| {
480 <serde_json::Error as serde::de::Error>::custom("model payload did not serialize to object")
481 })?;
482 let payload_key = payload_key(model);
483 let payload = root
484 .get_mut(payload_key)
485 .and_then(Value::as_object_mut)
486 .ok_or_else(|| {
487 <serde_json::Error as serde::de::Error>::custom(format!(
488 "model payload missing `{payload_key}` object"
489 ))
490 })?;
491
492 let mut indexes = HashMap::new();
493 let mut resolved_rows = Vec::with_capacity(point.updates.len());
494 for update in &point.updates {
495 let row = resolve_update(payload, &mut indexes, update).map_err(json_error)?;
496 apply_update_fields(payload, &update.element.table, row, &update.fields)?;
497 resolved_rows.push(row);
498 }
499
500 let updated_paths = point
501 .updates
502 .iter()
503 .zip(&resolved_rows)
504 .flat_map(|(update, row)| {
505 update.fields.keys().map(move |field| {
506 format!(
507 "/model/{payload_key}/{}/{row}/{}",
508 update.element.table, field
509 )
510 })
511 })
512 .collect();
513
514 let updated = serde_json::from_value(value)?;
515 validate_update_fields_survived(&updated, &point.updates, &resolved_rows)?;
516 Ok((updated, updated_paths))
517}
518
519pub(crate) fn check_series_identities(
524 model: &ModelPayload,
525 series: &OperatingPointSeries,
526) -> Vec<(usize, usize, String)> {
527 let payload_key = payload_key(model);
528 let payload = match serde_json::to_value(model) {
529 Ok(Value::Object(mut root)) => match root.remove(payload_key) {
530 Some(Value::Object(payload)) => payload,
531 _ => {
532 return vec![(
533 0,
534 0,
535 format!("model payload missing `{payload_key}` object"),
536 )];
537 }
538 },
539 _ => return vec![(0, 0, "model payload did not serialize to object".to_owned())],
540 };
541
542 let mut indexes = HashMap::new();
543 let mut findings = Vec::new();
544 for (point_pos, point) in series.points.iter().enumerate() {
545 for (update_pos, update) in point.updates.iter().enumerate() {
546 if let Err(message) = resolve_update(&payload, &mut indexes, update) {
547 findings.push((point_pos, update_pos, message));
548 }
549 }
550 }
551 findings
552}
553
554fn payload_key(model: &ModelPayload) -> &'static str {
555 match model {
556 ModelPayload::Balanced { .. } => "balanced_network",
557 ModelPayload::Multiconductor { .. } => "multiconductor_network",
558 }
559}
560
561struct IdentityIndex {
563 by_uid: HashMap<String, usize>,
564 duplicates: BTreeSet<String>,
566 has_uids: bool,
569}
570
571fn table_identity_index(table: &[Value]) -> IdentityIndex {
572 let mut by_uid = HashMap::with_capacity(table.len());
573 let mut duplicates = BTreeSet::new();
574 let mut has_uids = false;
575 for (row, value) in table.iter().enumerate() {
576 let Some(uid) = value.get("uid").and_then(Value::as_str) else {
577 continue;
578 };
579 has_uids = true;
580 if by_uid.insert(uid.to_owned(), row).is_some() {
581 duplicates.insert(uid.to_owned());
582 }
583 }
584 IdentityIndex {
585 by_uid,
586 duplicates,
587 has_uids,
588 }
589}
590
591fn resolve_update(
595 payload: &Map<String, Value>,
596 indexes: &mut HashMap<String, IdentityIndex>,
597 update: &ElementUpdate,
598) -> Result<usize, String> {
599 if update.fields.contains_key("uid") {
600 return Err(format!(
601 "operating point update on table `{}` must not overwrite `uid`",
602 update.element.table
603 ));
604 }
605 resolve_update_row(payload, indexes, &update.element)
606}
607
608fn resolve_update_row(
613 payload: &Map<String, Value>,
614 indexes: &mut HashMap<String, IdentityIndex>,
615 element: &ElementRef,
616) -> Result<usize, String> {
617 let table_name = element.table.as_str();
618 let Some(table) = payload.get(table_name).and_then(Value::as_array) else {
619 return Err(format!(
620 "operating point table `{table_name}` is not present or is not an array"
621 ));
622 };
623 let index = indexes
624 .entry(table_name.to_owned())
625 .or_insert_with(|| table_identity_index(table));
626 let resolved = match element.source_uid.as_deref() {
627 Some(uid) if index.duplicates.contains(uid) => {
628 return Err(format!(
629 "payload table `{table_name}` carries uid `{uid}` on more than one row; \
630 identity resolution is ambiguous"
631 ));
632 }
633 Some(uid) => match index.by_uid.get(uid) {
634 Some(&row) => {
635 if let Some(wire_row) = element.row
636 && wire_row != row
637 {
638 return Err(format!(
639 "update for table `{table_name}` names uid `{uid}` (row {row}) \
640 but carries row {wire_row}"
641 ));
642 }
643 row
644 }
645 None if index.has_uids => {
646 return Err(format!(
647 "unknown identity: table `{table_name}` has no row with uid `{uid}`"
648 ));
649 }
650 None => element.row.ok_or_else(|| {
651 format!(
652 "update for table `{table_name}` names uid `{uid}`, but the payload rows \
653 carry no uids and the update has no row to fall back on"
654 )
655 })?,
656 },
657 None => element.row.ok_or_else(|| {
658 format!("update for table `{table_name}` has neither row nor source_uid")
659 })?,
660 };
661 if resolved >= table.len() {
662 return Err(format!(
663 "operating point table `{table_name}` has no row {resolved}"
664 ));
665 }
666 Ok(resolved)
667}
668
669fn apply_update_fields(
670 payload: &mut serde_json::Map<String, Value>,
671 table_name: &str,
672 row: usize,
673 fields: &BTreeMap<String, Value>,
674) -> serde_json::Result<()> {
675 let row_object = payload
676 .get_mut(table_name)
677 .and_then(Value::as_array_mut)
678 .and_then(|table| table.get_mut(row))
679 .and_then(Value::as_object_mut)
680 .ok_or_else(|| {
681 json_error(format!(
682 "operating point table `{table_name}` has no object row {row}"
683 ))
684 })?;
685 for (field, value) in fields {
686 row_object.insert(field.clone(), value.clone());
687 }
688 Ok(())
689}
690
691fn validate_update_fields_survived(
692 model: &ModelPayload,
693 updates: &[ElementUpdate],
694 resolved_rows: &[usize],
695) -> serde_json::Result<()> {
696 let value = serde_json::to_value(model)?;
697 let root = value.as_object().ok_or_else(|| {
698 <serde_json::Error as serde::de::Error>::custom("model payload did not serialize to object")
699 })?;
700 let payload_key = payload_key(model);
701 let payload = root
702 .get(payload_key)
703 .and_then(Value::as_object)
704 .ok_or_else(|| {
705 <serde_json::Error as serde::de::Error>::custom(format!(
706 "model payload missing `{payload_key}` object"
707 ))
708 })?;
709
710 for (update, &resolved_row) in updates.iter().zip(resolved_rows) {
711 let table_name = update.element.table.as_str();
712 let row = payload
713 .get(table_name)
714 .and_then(Value::as_array)
715 .and_then(|table| table.get(resolved_row))
716 .and_then(Value::as_object)
717 .ok_or_else(|| {
718 json_error(format!(
719 "operating point table `{table_name}` has no object row {resolved_row} \
720 after typed materialization"
721 ))
722 })?;
723
724 for field in update.fields.keys() {
725 if !row.contains_key(field) {
726 return Err(json_error(format!(
727 "operating point field `{field}` is not present on table `{table_name}` \
728 row {resolved_row}"
729 )));
730 }
731 }
732 }
733 Ok(())
734}