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