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