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, Deserialize)]
144#[non_exhaustive]
145pub struct ElementRef {
146 pub table: String,
148 pub row: usize,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub source_uid: Option<String>,
153}
154
155impl ElementRef {
156 #[must_use]
157 pub fn new(table: impl Into<String>, row: usize) -> Self {
158 Self {
159 table: table.into(),
160 row,
161 source_uid: None,
162 }
163 }
164
165 #[must_use]
166 pub fn with_source_uid(mut self, uid: impl Into<String>) -> Self {
167 self.source_uid = Some(uid.into());
168 self
169 }
170}
171
172#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
174#[non_exhaustive]
175pub struct ElementUpdate {
176 pub element: ElementRef,
178 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
180 pub fields: BTreeMap<String, Value>,
181 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
183 pub metadata: BTreeMap<String, Value>,
184}
185
186impl ElementUpdate {
187 #[must_use]
188 pub fn new(element: ElementRef, fields: BTreeMap<String, Value>) -> Self {
189 Self {
190 element,
191 fields,
192 metadata: BTreeMap::new(),
193 }
194 }
195}
196
197pub(crate) fn goc3_operating_points_from_str(
198 text: &str,
199) -> serde_json::Result<Option<OperatingPointSeries>> {
200 let root: Value = serde_json::from_str(text)?;
201 let Some(root) = root.as_object() else {
202 return Ok(None);
203 };
204 let Some(network) = root.get("network").and_then(Value::as_object) else {
205 return Ok(None);
206 };
207 let Some(time_series) = root.get("time_series_input").and_then(Value::as_object) else {
208 return Ok(None);
209 };
210 let Some(general) = time_series.get("general").and_then(Value::as_object) else {
211 return Ok(None);
212 };
213 let periods = general
214 .get("time_periods")
215 .and_then(Value::as_u64)
216 .unwrap_or(0) as usize;
217 if periods == 0 {
218 return Ok(None);
219 }
220 let duration_hours = general
221 .get("interval_duration")
222 .and_then(Value::as_array)
223 .map(|values| values.iter().filter_map(Value::as_f64).collect::<Vec<_>>())
224 .unwrap_or_default();
225 let device_ts = uid_map(section(time_series, "simple_dispatchable_device")?);
226 let output = root.get("time_series_output").and_then(Value::as_object);
227
228 let mut points = (0..periods).map(OperatingPoint::new).collect::<Vec<_>>();
229
230 let base_mva = network
231 .get("general")
232 .and_then(Value::as_object)
233 .and_then(|general| number(general, "base_norm_mva"))
234 .unwrap_or(100.0);
235
236 add_goc3_device_updates(network, &device_ts, base_mva, &mut points)?;
237 add_goc3_status_updates(network, output, "ac_line", "branches", 0, &mut points)?;
238 let line_count = section(network, "ac_line")?.len();
239 add_goc3_status_updates(
240 network,
241 output,
242 "two_winding_transformer",
243 "branches",
244 line_count,
245 &mut points,
246 )?;
247 add_goc3_status_updates(network, output, "dc_line", "hvdc", 0, &mut points)?;
248
249 Ok(Some(OperatingPointSeries {
250 time_axis: TimeAxis {
251 periods,
252 duration_hours,
253 labels: (0..periods).map(|idx| (idx + 1).to_string()).collect(),
254 },
255 points,
256 metadata: BTreeMap::from([("source_format".to_owned(), json!("goc3-json"))]),
257 }))
258}
259
260fn add_goc3_device_updates(
261 network: &Map<String, Value>,
262 device_ts: &HashMap<String, &Value>,
263 base_mva: f64,
264 points: &mut [OperatingPoint],
265) -> serde_json::Result<()> {
266 for device in device_rows(network).map_err(|err| json_error(err.to_string()))? {
267 let Some(uid) = device.uid else {
268 continue;
269 };
270 let Some(ts_value) = device_ts.get(uid.as_str()) else {
271 continue;
272 };
273 let Some(ts) = ts_value.as_object() else {
274 continue;
275 };
276 match device.table {
277 DeviceTable::Generators => {
278 for point in points.iter_mut() {
279 let mut fields = BTreeMap::new();
280 insert_scaled_at(&mut fields, ts, "p_ub", "pmax", point.index, base_mva);
281 insert_scaled_at(&mut fields, ts, "p_lb", "pmin", point.index, base_mva);
282 insert_scaled_at(&mut fields, ts, "q_ub", "qmax", point.index, base_mva);
283 insert_scaled_at(&mut fields, ts, "q_lb", "qmin", point.index, base_mva);
284 if let Some(cost) = cost_at(device.obj, Some(ts_value), point.index, base_mva)
285 .map(serde_json::to_value)
286 .transpose()?
287 {
288 fields.insert("cost".to_owned(), cost);
289 }
290 if !fields.is_empty() {
291 let mut update = ElementUpdate::new(
292 ElementRef::new("generators", device.row).with_source_uid(uid.clone()),
293 fields,
294 );
295 update.metadata = per_period_metadata(ts, point.index);
296 point.updates.push(update);
297 }
298 }
299 }
300 DeviceTable::Loads => {
301 for point in points.iter_mut() {
302 let mut fields = BTreeMap::new();
303 insert_abs_scaled_at(&mut fields, ts, "p_ub", "p", point.index, base_mva);
304 insert_abs_scaled_at(&mut fields, ts, "q_ub", "q", point.index, base_mva);
305 if !fields.is_empty() {
306 let mut update = ElementUpdate::new(
307 ElementRef::new("loads", device.row).with_source_uid(uid.clone()),
308 fields,
309 );
310 update.metadata = per_period_metadata(ts, point.index);
311 point.updates.push(update);
312 }
313 }
314 }
315 }
316 }
317 Ok(())
318}
319
320fn add_goc3_status_updates(
321 network: &Map<String, Value>,
322 output: Option<&Map<String, Value>>,
323 source_section: &'static str,
324 target_table: &'static str,
325 row_offset: usize,
326 points: &mut [OperatingPoint],
327) -> serde_json::Result<()> {
328 let source_items = section(network, source_section)?;
329 let Some(output) = output else {
330 return Ok(());
331 };
332 let status_by_uid = uid_map(section(output, source_section)?);
333 for (row, item) in source_items.iter().enumerate() {
334 let Some(obj) = item.value.as_object() else {
335 continue;
336 };
337 let Some(uid) = item_uid(*item, obj) else {
338 continue;
339 };
340 let Some(status) = status_by_uid
341 .get(uid.as_str())
342 .and_then(|value| value.as_object())
343 else {
344 continue;
345 };
346 for point in points.iter_mut() {
347 if let Some(value) = array_number_at(status, "on_status", point.index) {
348 point.updates.push(ElementUpdate::new(
349 ElementRef::new(target_table, row_offset + row).with_source_uid(uid.clone()),
350 BTreeMap::from([("in_service".to_owned(), json!(value != 0.0))]),
351 ));
352 }
353 }
354 }
355 Ok(())
356}
357
358fn section<'a>(
359 parent: &'a Map<String, Value>,
360 name: &'static str,
361) -> serde_json::Result<Vec<SectionItem<'a>>> {
362 powerio::format::goc3_bridge::section(parent, name).map_err(|err| json_error(err.to_string()))
363}
364
365fn uid_map(items: Vec<SectionItem<'_>>) -> HashMap<String, &Value> {
366 let mut out = HashMap::new();
367 for item in items {
368 if let Some(obj) = item.value.as_object()
369 && let Some(uid) = item_uid(item, obj)
370 {
371 out.insert(uid, item.value);
372 }
373 }
374 out
375}
376
377fn insert_scaled_at(
378 fields: &mut BTreeMap<String, Value>,
379 obj: &Map<String, Value>,
380 source: &str,
381 target: &str,
382 index: usize,
383 scale: f64,
384) {
385 if let Some(value) = array_number_at(obj, source, index) {
386 fields.insert(target.to_owned(), json!(value * scale));
387 }
388}
389
390fn insert_abs_scaled_at(
391 fields: &mut BTreeMap<String, Value>,
392 obj: &Map<String, Value>,
393 source: &str,
394 target: &str,
395 index: usize,
396 scale: f64,
397) {
398 if let Some(value) = array_number_at(obj, source, index) {
399 fields.insert(target.to_owned(), json!(value.abs() * scale));
400 }
401}
402
403fn array_number_at(obj: &Map<String, Value>, key: &str, index: usize) -> Option<f64> {
404 obj.get(key)?.as_array()?.get(index)?.as_f64()
405}
406
407fn per_period_metadata(obj: &Map<String, Value>, index: usize) -> BTreeMap<String, Value> {
408 let mut metadata = BTreeMap::new();
409 for (key, value) in obj {
410 if key == "cost" || key.ends_with("_ub") || key.ends_with("_lb") {
411 continue;
412 }
413 if let Some(values) = value.as_array()
414 && let Some(value) = values.get(index)
415 {
416 metadata.insert(key.clone(), value.clone());
417 }
418 }
419 metadata
420}
421
422fn json_error(message: impl Into<String>) -> serde_json::Error {
423 <serde_json::Error as serde::de::Error>::custom(message.into())
424}
425
426pub(crate) fn apply_operating_point_to_model(
427 model: &ModelPayload,
428 point: &OperatingPoint,
429) -> serde_json::Result<ModelPayload> {
430 let mut value = serde_json::to_value(model)?;
431 let root = value.as_object_mut().ok_or_else(|| {
432 <serde_json::Error as serde::de::Error>::custom("model payload did not serialize to object")
433 })?;
434 let payload_key = payload_key(model);
435 let payload = root
436 .get_mut(payload_key)
437 .and_then(Value::as_object_mut)
438 .ok_or_else(|| {
439 <serde_json::Error as serde::de::Error>::custom(format!(
440 "model payload missing `{payload_key}` object"
441 ))
442 })?;
443
444 for update in &point.updates {
445 apply_update(payload, update)?;
446 }
447
448 let updated = serde_json::from_value(value)?;
449 validate_update_fields_survived(&updated, &point.updates)?;
450 Ok(updated)
451}
452
453pub(crate) fn operating_point_update_paths(
454 model: &ModelPayload,
455 point: &OperatingPoint,
456) -> BTreeSet<String> {
457 let payload_key = payload_key(model);
458 point
459 .updates
460 .iter()
461 .flat_map(|update| {
462 update.fields.keys().map(move |field| {
463 format!(
464 "/model/{payload_key}/{}/{}/{}",
465 update.element.table, update.element.row, field
466 )
467 })
468 })
469 .collect()
470}
471
472fn payload_key(model: &ModelPayload) -> &'static str {
473 match model {
474 ModelPayload::Balanced { .. } => "balanced_network",
475 ModelPayload::Multiconductor { .. } => "multiconductor_network",
476 }
477}
478
479fn apply_update(
480 payload: &mut serde_json::Map<String, Value>,
481 update: &ElementUpdate,
482) -> serde_json::Result<()> {
483 let table_name = update.element.table.as_str();
484 let table = payload
485 .get_mut(table_name)
486 .and_then(Value::as_array_mut)
487 .ok_or_else(|| {
488 <serde_json::Error as serde::de::Error>::custom(format!(
489 "operating point table `{table_name}` is not present or is not an array"
490 ))
491 })?;
492 let row = table
493 .get_mut(update.element.row)
494 .and_then(Value::as_object_mut)
495 .ok_or_else(|| {
496 <serde_json::Error as serde::de::Error>::custom(format!(
497 "operating point table `{table_name}` has no object row {}",
498 update.element.row
499 ))
500 })?;
501
502 for (field, value) in &update.fields {
503 row.insert(field.clone(), value.clone());
504 }
505 Ok(())
506}
507
508fn validate_update_fields_survived(
509 model: &ModelPayload,
510 updates: &[ElementUpdate],
511) -> serde_json::Result<()> {
512 let value = serde_json::to_value(model)?;
513 let root = value.as_object().ok_or_else(|| {
514 <serde_json::Error as serde::de::Error>::custom("model payload did not serialize to object")
515 })?;
516 let payload_key = payload_key(model);
517 let payload = root
518 .get(payload_key)
519 .and_then(Value::as_object)
520 .ok_or_else(|| {
521 <serde_json::Error as serde::de::Error>::custom(format!(
522 "model payload missing `{payload_key}` object"
523 ))
524 })?;
525
526 for update in updates {
527 let table_name = update.element.table.as_str();
528 let table = payload
529 .get(table_name)
530 .and_then(Value::as_array)
531 .ok_or_else(|| {
532 <serde_json::Error as serde::de::Error>::custom(format!(
533 "operating point table `{table_name}` is not present after typed materialization"
534 ))
535 })?;
536 let row = table
537 .get(update.element.row)
538 .and_then(Value::as_object)
539 .ok_or_else(|| {
540 <serde_json::Error as serde::de::Error>::custom(format!(
541 "operating point table `{table_name}` has no object row {} after typed materialization",
542 update.element.row
543 ))
544 })?;
545
546 for field in update.fields.keys() {
547 if !row.contains_key(field) {
548 return Err(<serde_json::Error as serde::de::Error>::custom(format!(
549 "operating point field `{field}` is not present on table `{table_name}` row {}",
550 update.element.row
551 )));
552 }
553 }
554 }
555 Ok(())
556}