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