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