Skip to main content

cobre_core/system/
builder.rs

1//! `SystemBuilder` — canonical-order assembly and validation of a [`System`].
2//!
3//! `SystemBuilder::build()` sorts every entity collection into canonical
4//! [`EntityId`] order, runs the construction-time validation pass (duplicate-id,
5//! cross-reference, cascade-cycle, and filling-config checks) accumulating every
6//! error before returning, and assembles the immutable [`System`] via a direct
7//! struct literal. As a child module of `system`, this file reaches `System`'s
8//! ancestor-private fields without any field-visibility promotion.
9
10use std::collections::HashSet;
11
12use super::System;
13use super::validate::{
14    CrossRefEntities, build_index, build_stage_index, check_duplicate_stages, check_duplicates,
15    validate_cross_references, validate_filling_configs,
16};
17use crate::{
18    Bus, CascadeTopology, CorrelationModel, EnergyContract, EntityId, ExternalLoadRow,
19    ExternalNcsRow, ExternalScenarioRow, GenericConstraint, Hydro, InflowHistoryRow, InflowModel,
20    InitialConditions, Line, LoadModel, NcsModel, NetworkTopology, NonControllableSource,
21    PolicyGraph, PumpingStation, ResolvedBounds, ResolvedExchangeFactors,
22    ResolvedGenericConstraintBounds, ResolvedLoadFactors, ResolvedNcsBounds, ResolvedNcsFactors,
23    ResolvedPenalties, Stage, Thermal, ValidationError,
24};
25
26/// Builder for constructing a validated, immutable [`System`].
27///
28/// Accepts entity collections, sorts entities by ID, checks for duplicate IDs,
29/// builds topology, and returns the [`System`]. All entity collections default to
30/// empty; only supply the collections your test case requires.
31///
32/// # Examples
33///
34/// ```
35/// use cobre_core::{Bus, DeficitSegment, EntityId, SystemBuilder};
36///
37/// let system = SystemBuilder::new()
38///     .buses(vec![
39///         Bus { id: EntityId(2), name: "B".to_string(), deficit_segments: vec![], excess_cost: 0.0 },
40///         Bus { id: EntityId(1), name: "A".to_string(), deficit_segments: vec![], excess_cost: 0.0 },
41///     ])
42///     .build()
43///     .expect("valid system");
44///
45/// // Canonical ordering: id=1 comes before id=2.
46/// assert_eq!(system.buses()[0].id, EntityId(1));
47/// assert_eq!(system.buses()[1].id, EntityId(2));
48/// ```
49pub struct SystemBuilder {
50    buses: Vec<Bus>,
51    lines: Vec<Line>,
52    hydros: Vec<Hydro>,
53    thermals: Vec<Thermal>,
54    pumping_stations: Vec<PumpingStation>,
55    contracts: Vec<EnergyContract>,
56    non_controllable_sources: Vec<NonControllableSource>,
57    stages: Vec<Stage>,
58    policy_graph: PolicyGraph,
59    penalties: ResolvedPenalties,
60    bounds: ResolvedBounds,
61    resolved_generic_bounds: ResolvedGenericConstraintBounds,
62    resolved_load_factors: ResolvedLoadFactors,
63    resolved_exchange_factors: ResolvedExchangeFactors,
64    resolved_ncs_bounds: ResolvedNcsBounds,
65    resolved_ncs_factors: ResolvedNcsFactors,
66    inflow_models: Vec<InflowModel>,
67    load_models: Vec<LoadModel>,
68    ncs_models: Vec<NcsModel>,
69    correlation: CorrelationModel,
70    initial_conditions: InitialConditions,
71    generic_constraints: Vec<GenericConstraint>,
72    inflow_history: Vec<InflowHistoryRow>,
73    external_scenarios: Vec<ExternalScenarioRow>,
74    external_load_scenarios: Vec<ExternalLoadRow>,
75    external_ncs_scenarios: Vec<ExternalNcsRow>,
76}
77
78impl Default for SystemBuilder {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84impl SystemBuilder {
85    /// Create a new empty builder. All entity collections start empty.
86    ///
87    /// Omitting a setter leaves the corresponding field at its empty/default
88    /// value — never uninitialized.
89    #[must_use]
90    pub fn new() -> Self {
91        Self {
92            buses: Vec::new(),
93            lines: Vec::new(),
94            hydros: Vec::new(),
95            thermals: Vec::new(),
96            pumping_stations: Vec::new(),
97            contracts: Vec::new(),
98            non_controllable_sources: Vec::new(),
99            stages: Vec::new(),
100            policy_graph: PolicyGraph::default(),
101            penalties: ResolvedPenalties::empty(),
102            bounds: ResolvedBounds::empty(),
103            resolved_generic_bounds: ResolvedGenericConstraintBounds::empty(),
104            resolved_load_factors: ResolvedLoadFactors::empty(),
105            resolved_exchange_factors: ResolvedExchangeFactors::empty(),
106            resolved_ncs_bounds: ResolvedNcsBounds::empty(),
107            resolved_ncs_factors: ResolvedNcsFactors::empty(),
108            inflow_models: Vec::new(),
109            load_models: Vec::new(),
110            ncs_models: Vec::new(),
111            correlation: CorrelationModel::default(),
112            initial_conditions: InitialConditions::default(),
113            generic_constraints: Vec::new(),
114            inflow_history: Vec::new(),
115            external_scenarios: Vec::new(),
116            external_load_scenarios: Vec::new(),
117            external_ncs_scenarios: Vec::new(),
118        }
119    }
120
121    /// Set the bus collection.
122    #[must_use]
123    pub fn buses(mut self, buses: Vec<Bus>) -> Self {
124        self.buses = buses;
125        self
126    }
127
128    /// Set the line collection.
129    #[must_use]
130    pub fn lines(mut self, lines: Vec<Line>) -> Self {
131        self.lines = lines;
132        self
133    }
134
135    /// Set the hydro plant collection.
136    #[must_use]
137    pub fn hydros(mut self, hydros: Vec<Hydro>) -> Self {
138        self.hydros = hydros;
139        self
140    }
141
142    /// Set the thermal plant collection.
143    #[must_use]
144    pub fn thermals(mut self, thermals: Vec<Thermal>) -> Self {
145        self.thermals = thermals;
146        self
147    }
148
149    /// Set the pumping station collection.
150    #[must_use]
151    pub fn pumping_stations(mut self, stations: Vec<PumpingStation>) -> Self {
152        self.pumping_stations = stations;
153        self
154    }
155
156    /// Set the energy contract collection.
157    #[must_use]
158    pub fn contracts(mut self, contracts: Vec<EnergyContract>) -> Self {
159        self.contracts = contracts;
160        self
161    }
162
163    /// Set the non-controllable source collection.
164    #[must_use]
165    pub fn non_controllable_sources(mut self, sources: Vec<NonControllableSource>) -> Self {
166        self.non_controllable_sources = sources;
167        self
168    }
169
170    /// Set the stage collection (study and pre-study stages).
171    ///
172    /// Stages are sorted by `id` in [`build`](Self::build) to canonical order.
173    #[must_use]
174    pub fn stages(mut self, stages: Vec<Stage>) -> Self {
175        self.stages = stages;
176        self
177    }
178
179    /// Set the policy graph.
180    #[must_use]
181    pub fn policy_graph(mut self, policy_graph: PolicyGraph) -> Self {
182        self.policy_graph = policy_graph;
183        self
184    }
185
186    /// Set the pre-resolved penalty table.
187    ///
188    /// Populated by `cobre-io` after the three-tier penalty cascade is applied.
189    #[must_use]
190    pub fn penalties(mut self, penalties: ResolvedPenalties) -> Self {
191        self.penalties = penalties;
192        self
193    }
194
195    /// Set the pre-resolved bounds table.
196    ///
197    /// Populated by `cobre-io` after base bounds are overlaid with stage overrides.
198    #[must_use]
199    pub fn bounds(mut self, bounds: ResolvedBounds) -> Self {
200        self.bounds = bounds;
201        self
202    }
203
204    /// Set the pre-resolved generic constraint RHS bound table.
205    ///
206    /// Populated by `cobre-io` after converting raw parsed bound rows into
207    /// the indexed lookup structure.
208    #[must_use]
209    pub fn resolved_generic_bounds(
210        mut self,
211        resolved_generic_bounds: ResolvedGenericConstraintBounds,
212    ) -> Self {
213        self.resolved_generic_bounds = resolved_generic_bounds;
214        self
215    }
216
217    /// Set the pre-resolved per-block load scaling factors.
218    ///
219    /// Populated by `cobre-io` after resolving `load_factors.json` entries.
220    #[must_use]
221    pub fn resolved_load_factors(mut self, resolved_load_factors: ResolvedLoadFactors) -> Self {
222        self.resolved_load_factors = resolved_load_factors;
223        self
224    }
225
226    /// Set the pre-resolved per-block exchange capacity factors.
227    ///
228    /// Populated by `cobre-io` after resolving `exchange_factors.json` entries.
229    #[must_use]
230    pub fn resolved_exchange_factors(
231        mut self,
232        resolved_exchange_factors: ResolvedExchangeFactors,
233    ) -> Self {
234        self.resolved_exchange_factors = resolved_exchange_factors;
235        self
236    }
237
238    /// Set the pre-resolved per-stage NCS available generation bounds.
239    ///
240    /// Populated by `cobre-io` after resolving `ncs_bounds.parquet` entries.
241    #[must_use]
242    pub fn resolved_ncs_bounds(mut self, resolved_ncs_bounds: ResolvedNcsBounds) -> Self {
243        self.resolved_ncs_bounds = resolved_ncs_bounds;
244        self
245    }
246
247    /// Set the pre-resolved per-block NCS generation scaling factors.
248    ///
249    /// Populated by `cobre-io` after resolving `non_controllable_factors.json` entries.
250    #[must_use]
251    pub fn resolved_ncs_factors(mut self, resolved_ncs_factors: ResolvedNcsFactors) -> Self {
252        self.resolved_ncs_factors = resolved_ncs_factors;
253        self
254    }
255
256    /// Set the PAR(p) inflow model collection.
257    #[must_use]
258    pub fn inflow_models(mut self, inflow_models: Vec<InflowModel>) -> Self {
259        self.inflow_models = inflow_models;
260        self
261    }
262
263    /// Set the load model collection.
264    #[must_use]
265    pub fn load_models(mut self, load_models: Vec<LoadModel>) -> Self {
266        self.load_models = load_models;
267        self
268    }
269
270    /// Set the NCS availability noise model collection.
271    #[must_use]
272    pub fn ncs_models(mut self, ncs_models: Vec<NcsModel>) -> Self {
273        self.ncs_models = ncs_models;
274        self
275    }
276
277    /// Set the correlation model.
278    #[must_use]
279    pub fn correlation(mut self, correlation: CorrelationModel) -> Self {
280        self.correlation = correlation;
281        self
282    }
283
284    /// Set the initial conditions.
285    #[must_use]
286    pub fn initial_conditions(mut self, initial_conditions: InitialConditions) -> Self {
287        self.initial_conditions = initial_conditions;
288        self
289    }
290
291    /// Set the generic constraint collection.
292    ///
293    /// Constraints are sorted by `id` in [`build`](Self::build) to canonical order.
294    #[must_use]
295    pub fn generic_constraints(mut self, generic_constraints: Vec<GenericConstraint>) -> Self {
296        self.generic_constraints = generic_constraints;
297        self
298    }
299
300    /// Set the raw historical inflow observations.
301    ///
302    /// Rows should be sorted by `(hydro_id, date)` ascending. When the
303    /// `scenarios/inflow_history.parquet` file is absent, omit this call
304    /// and the field will default to an empty `Vec`.
305    #[must_use]
306    pub fn inflow_history(mut self, rows: Vec<InflowHistoryRow>) -> Self {
307        self.inflow_history = rows;
308        self
309    }
310
311    /// Set the raw external inflow scenario rows.
312    ///
313    /// Rows should be sorted by `(stage_id, scenario_id, hydro_id)` ascending.
314    /// When no external inflow scenario file is present, omit this call and the
315    /// field will default to an empty `Vec`.
316    #[must_use]
317    pub fn external_scenarios(mut self, rows: Vec<ExternalScenarioRow>) -> Self {
318        self.external_scenarios = rows;
319        self
320    }
321
322    /// Set the raw external load scenario rows.
323    ///
324    /// Rows should be sorted by `(stage_id, scenario_id, bus_id)` ascending.
325    /// When no external load scenario file is present, omit this call and the
326    /// field will default to an empty `Vec`.
327    #[must_use]
328    pub fn external_load_scenarios(mut self, rows: Vec<ExternalLoadRow>) -> Self {
329        self.external_load_scenarios = rows;
330        self
331    }
332
333    /// Set the raw external NCS scenario rows.
334    ///
335    /// Rows should be sorted by `(stage_id, scenario_id, ncs_id)` ascending.
336    /// When no external NCS scenario file is present, omit this call and the
337    /// field will default to an empty `Vec`.
338    #[must_use]
339    pub fn external_ncs_scenarios(mut self, rows: Vec<ExternalNcsRow>) -> Self {
340        self.external_ncs_scenarios = rows;
341        self
342    }
343
344    /// Build the [`System`].
345    ///
346    /// Sorts all entity collections by [`EntityId`] (canonical ordering).
347    /// Checks for duplicate IDs within each collection.
348    /// Validates all cross-reference fields (e.g., `bus_id`, `downstream_id`) against
349    /// the appropriate index to ensure every referenced entity exists.
350    /// Builds [`CascadeTopology`] and [`NetworkTopology`].
351    /// Validates the cascade graph for cycles and checks hydro filling configurations.
352    /// Constructs lookup indices.
353    ///
354    /// Returns `Err` with a list of all validation errors found across all collections.
355    /// All invalid references across all entity types are collected before returning —
356    /// no short-circuiting on first error.
357    ///
358    /// # Errors
359    ///
360    /// Returns `Err(Vec<ValidationError>)` if:
361    /// - Duplicate IDs are detected in any entity collection or in the stage collection.
362    /// - Any cross-reference field refers to an entity ID that does not exist.
363    /// - The hydro cascade graph contains a cycle.
364    /// - Any hydro filling configuration is invalid (non-positive inflow or missing
365    ///   `entry_stage_id`).
366    ///
367    /// All errors across all collections are reported together.
368    // Rationale: this is a single-pass, ordered validation and construction of the
369    // complete entity graph — sorting, duplicate checks, cross-reference validation,
370    // cascade-graph cycle detection, and final `System` assembly are all tightly
371    // coupled through the shared `errors` accumulator and the intermediate index
372    // maps. Splitting across helper functions would require threading those maps and
373    // the error vector through every call, obscuring the one-shot build contract and
374    // the fail-fast short-circuit that holds when duplicate IDs are found first.
375    #[allow(clippy::too_many_lines)]
376    pub fn build(mut self) -> Result<System, Vec<ValidationError>> {
377        self.buses.sort_by_key(|e| e.id.0);
378        self.lines.sort_by_key(|e| e.id.0);
379        self.hydros.sort_by_key(|e| e.id.0);
380        self.thermals.sort_by_key(|e| e.id.0);
381        self.pumping_stations.sort_by_key(|e| e.id.0);
382        self.contracts.sort_by_key(|e| e.id.0);
383        self.non_controllable_sources.sort_by_key(|e| e.id.0);
384        self.stages.sort_by_key(|s| s.id);
385        self.generic_constraints.sort_by_key(|c| c.id.0);
386
387        let mut errors: Vec<ValidationError> = Vec::new();
388        check_duplicates(&self.buses, "Bus", &mut errors);
389        check_duplicates(&self.lines, "Line", &mut errors);
390        check_duplicates(&self.hydros, "Hydro", &mut errors);
391        check_duplicates(&self.thermals, "Thermal", &mut errors);
392        check_duplicates(&self.pumping_stations, "PumpingStation", &mut errors);
393        check_duplicates(&self.contracts, "EnergyContract", &mut errors);
394        check_duplicates(
395            &self.non_controllable_sources,
396            "NonControllableSource",
397            &mut errors,
398        );
399        check_duplicate_stages(&self.stages, &mut errors);
400
401        if !errors.is_empty() {
402            return Err(errors);
403        }
404
405        let bus_index = build_index(&self.buses);
406        let line_index = build_index(&self.lines);
407        let hydro_index = build_index(&self.hydros);
408        let thermal_index = build_index(&self.thermals);
409        let pumping_station_index = build_index(&self.pumping_stations);
410        let contract_index = build_index(&self.contracts);
411        let non_controllable_source_index = build_index(&self.non_controllable_sources);
412
413        validate_cross_references(
414            &CrossRefEntities {
415                lines: &self.lines,
416                hydros: &self.hydros,
417                thermals: &self.thermals,
418                pumping_stations: &self.pumping_stations,
419                contracts: &self.contracts,
420                non_controllable_sources: &self.non_controllable_sources,
421            },
422            &bus_index,
423            &hydro_index,
424            &mut errors,
425        );
426
427        if !errors.is_empty() {
428            return Err(errors);
429        }
430
431        let cascade = CascadeTopology::build(&self.hydros);
432
433        if cascade.topological_order().len() < self.hydros.len() {
434            let in_topo: HashSet<EntityId> = cascade.topological_order().iter().copied().collect();
435            let mut cycle_ids: Vec<EntityId> = self
436                .hydros
437                .iter()
438                .map(|h| h.id)
439                .filter(|id| !in_topo.contains(id))
440                .collect();
441            cycle_ids.sort_by_key(|id| id.0);
442            errors.push(ValidationError::CascadeCycle { cycle_ids });
443        }
444
445        validate_filling_configs(&self.hydros, &mut errors);
446
447        if !errors.is_empty() {
448            return Err(errors);
449        }
450
451        let network = NetworkTopology::build(
452            &self.buses,
453            &self.lines,
454            &self.hydros,
455            &self.thermals,
456            &self.non_controllable_sources,
457            &self.contracts,
458            &self.pumping_stations,
459        );
460
461        let stage_index = build_stage_index(&self.stages);
462
463        Ok(System {
464            buses: self.buses,
465            lines: self.lines,
466            hydros: self.hydros,
467            thermals: self.thermals,
468            pumping_stations: self.pumping_stations,
469            contracts: self.contracts,
470            non_controllable_sources: self.non_controllable_sources,
471            bus_index,
472            line_index,
473            hydro_index,
474            thermal_index,
475            pumping_station_index,
476            contract_index,
477            non_controllable_source_index,
478            cascade,
479            network,
480            stages: self.stages,
481            policy_graph: self.policy_graph,
482            stage_index,
483            penalties: self.penalties,
484            bounds: self.bounds,
485            resolved_generic_bounds: self.resolved_generic_bounds,
486            resolved_load_factors: self.resolved_load_factors,
487            resolved_exchange_factors: self.resolved_exchange_factors,
488            resolved_ncs_bounds: self.resolved_ncs_bounds,
489            resolved_ncs_factors: self.resolved_ncs_factors,
490            inflow_models: self.inflow_models,
491            load_models: self.load_models,
492            ncs_models: self.ncs_models,
493            correlation: self.correlation,
494            initial_conditions: self.initial_conditions,
495            generic_constraints: self.generic_constraints,
496            inflow_history: self.inflow_history,
497            external_scenarios: self.external_scenarios,
498            external_load_scenarios: self.external_load_scenarios,
499            external_ncs_scenarios: self.external_ncs_scenarios,
500        })
501    }
502}