Skip to main content

powerio_matrix/dcopf/
bundle.rs

1use std::path::{Path, PathBuf};
2
3use powerio_tx::{GenCostPolicyReport, MissingGenCostPolicy};
4
5use crate::Result;
6use crate::SparseMatrix;
7use serde::Serialize;
8
9use super::prep::{DcOpfPreparation, Units};
10use super::{DcOpfAssemblyOptions, matrices_from_preparation};
11
12const DCOPF_SCHEMA: &str = "powerio.dcopf";
13
14/// Cost policy information recorded in a bundle manifest.
15#[derive(Debug, Clone)]
16pub struct DcOpfBundleMetadata {
17    pub cost_policy: MissingGenCostPolicy,
18    pub cost_report: GenCostPolicyReport,
19}
20
21impl Default for DcOpfBundleMetadata {
22    fn default() -> Self {
23        Self {
24            cost_policy: MissingGenCostPolicy::Require,
25            cost_report: GenCostPolicyReport::default(),
26        }
27    }
28}
29
30/// Options that affect bundle output without changing the instance.
31#[derive(Debug, Clone, Default)]
32pub struct DcOpfBundleOptions {
33    /// The assembly choices behind the written arrays.
34    pub assembly: DcOpfAssemblyOptions,
35    pub metadata: DcOpfBundleMetadata,
36}
37
38#[derive(Debug, Clone)]
39#[non_exhaustive]
40pub struct DcOpfOutputs {
41    pub dir: PathBuf,
42    pub files: Vec<PathBuf>,
43}
44
45#[derive(Serialize)]
46struct DcOpfMeta<'a> {
47    schema: &'static str,
48    case_name: &'a str,
49    base_mva: f64,
50    dimensions: DcOpfDimensions,
51    index_base: IndexBaseMeta,
52    branch_susceptance_formula: &'static str,
53    build_options: BuildOptionsMeta,
54    zero_impedance: ZeroImpedanceMeta<'a>,
55    grounding: GroundingMeta<'a>,
56    operators: Vec<OperatorMeta>,
57    units: Units,
58    cost_policy: MissingGenCostPolicy,
59    synthesized_gen_costs: usize,
60    patched_gen_costs: usize,
61    files: Vec<String>,
62    powerio_version: &'static str,
63}
64
65#[derive(Serialize)]
66#[allow(clippy::struct_field_names)]
67struct DcOpfDimensions {
68    n_buses: usize,
69    n_source_branches: usize,
70    n_branch_columns: usize,
71    n_generators: usize,
72    n_reference_buses: usize,
73    n_grounded_buses: usize,
74}
75
76#[derive(Serialize)]
77struct IndexBaseMeta {
78    dense: usize,
79    matrix_market: usize,
80}
81
82#[derive(Serialize)]
83struct BuildOptionsMeta {
84    skip_zero_impedance: bool,
85    synthesize_unrated_limits: bool,
86}
87
88#[derive(Serialize)]
89struct ZeroImpedanceMeta<'a> {
90    skip: bool,
91    rule: &'static str,
92    skipped: ZeroImpedanceSkips<'a>,
93}
94
95#[derive(Serialize)]
96struct ZeroImpedanceSkips<'a> {
97    count: usize,
98    branch_indices: &'a [usize],
99}
100
101#[derive(Serialize)]
102struct GroundingMeta<'a> {
103    reference_buses: &'a [usize],
104    removed_rows_and_columns: &'a [usize],
105    grounded_operator: &'static str,
106    reference_selector: &'static str,
107}
108
109#[derive(Serialize)]
110struct OperatorMeta {
111    name: &'static str,
112    file: &'static str,
113    kind: &'static str,
114    rows: usize,
115    cols: usize,
116    index_space: &'static str,
117    units: &'static str,
118}
119
120/// Write matrix projections for a DC OPF instance.
121///
122/// The writer derives every cost, bound, mapping, and array privately from
123/// the instance's typed network under `options.assembly`, then writes the
124/// bundle directory.
125///
126/// # Errors
127/// A network the selected branch susceptance formula cannot assemble, or a filesystem
128/// refusal from the no-clobber output rules.
129pub fn emit_dcopf_bundle(
130    instance: &powerio_prob::DcOpfInstance,
131    out_dir: impl AsRef<Path>,
132    options: &DcOpfBundleOptions,
133) -> Result<DcOpfOutputs> {
134    emit_prepared(
135        &super::build_dc_opf_preparation(instance, &options.assembly)?,
136        out_dir,
137        options,
138    )
139}
140
141/// The writer body over the private preparation arrays.
142#[allow(clippy::too_many_lines)]
143fn emit_prepared(
144    instance: &DcOpfPreparation,
145    out_dir: impl AsRef<Path>,
146    options: &DcOpfBundleOptions,
147) -> Result<DcOpfOutputs> {
148    let matrices = matrices_from_preparation(instance);
149    let nodal = instance.calc_nodal_generator_data()?;
150    let fixed_withdrawal = instance.calc_fixed_nodal_withdrawal();
151    let flow_offset = instance.calc_branch_flow_offset();
152    // The case name comes from source file content, so it must not steer the
153    // output path. `sanitize_stem` reduces it to one safe component and
154    // disambiguates names that would otherwise sanitize alike, so a batch
155    // export cannot be steered into overwriting an earlier bundle.
156    let bundle_root = out_dir
157        .as_ref()
158        .join(format!("{}_dcopf", crate::sanitize_stem(&instance.name)));
159
160    let mut inventory: Vec<(&'static str, Vec<u8>)> = Vec::new();
161    put_mat(&mut inventory, "A.mtx", &matrices.bus_branch_incidence)?;
162    put_mat(&mut inventory, "L.mtx", &matrices.laplacian)?;
163    put_mat(
164        &mut inventory,
165        "L_grounded.mtx",
166        &matrices.grounded_laplacian,
167    )?;
168    put_mat(&mut inventory, "BAt.mtx", &matrices.branch_flow_matrix)?;
169    put_mat(&mut inventory, "Cg.mtx", &matrices.generator_bus)?;
170
171    put_vec(
172        &mut inventory,
173        "b.mtx",
174        &instance.branches.susceptance_magnitude,
175    )?;
176    put_vec(&mut inventory, "shift.mtx", &instance.branches.shift)?;
177    put_vec(&mut inventory, "flow_offset.mtx", &flow_offset)?;
178    put_vec(&mut inventory, "p_shift.mtx", &instance.p_shift)?;
179    put_vec(&mut inventory, "fixed_withdrawal.mtx", &fixed_withdrawal)?;
180    put_vec(&mut inventory, "e_r.mtx", &matrices.reference_selector)?;
181    put_vec(&mut inventory, "q.mtx", &nodal.q)?;
182    put_vec(&mut inventory, "c.mtx", &nodal.c)?;
183    put_vec(&mut inventory, "c0.mtx", &nodal.c0)?;
184    put_vec(&mut inventory, "pmax.mtx", &nodal.pmax)?;
185    put_vec(&mut inventory, "pmin.mtx", &nodal.pmin)?;
186    put_vec(&mut inventory, "fmax.mtx", &instance.branches.f_max)?;
187    put_vec(&mut inventory, "pd.mtx", &instance.p_d)?;
188    put_vec(&mut inventory, "gs.mtx", &instance.g_s)?;
189    put_vec(
190        &mut inventory,
191        "angle_min.mtx",
192        &instance.branches.angle_min,
193    )?;
194    put_vec(
195        &mut inventory,
196        "angle_max.mtx",
197        &instance.branches.angle_max,
198    )?;
199
200    put_vec(&mut inventory, "q_gen.mtx", &instance.generators.q)?;
201    put_vec(&mut inventory, "c_gen.mtx", &instance.generators.c)?;
202    put_vec(&mut inventory, "c0_gen.mtx", &instance.generators.c0)?;
203    put_vec(&mut inventory, "pmax_gen.mtx", &instance.generators.pmax)?;
204    put_vec(&mut inventory, "pmin_gen.mtx", &instance.generators.pmin)?;
205
206    let power_units = match instance.units {
207        Units::PerUnit => "per_unit_power",
208        Units::Native => "native_power",
209    };
210    let meta = DcOpfMeta {
211        schema: DCOPF_SCHEMA,
212        case_name: &instance.name,
213        base_mva: instance.base_mva,
214        dimensions: DcOpfDimensions {
215            n_buses: instance.n_buses,
216            n_source_branches: instance.n_source_branches,
217            n_branch_columns: instance.n_branches(),
218            n_generators: instance.n_generators(),
219            n_reference_buses: instance.reference_buses.len(),
220            n_grounded_buses: instance.n_buses - instance.reference_buses.len(),
221        },
222        index_base: IndexBaseMeta {
223            dense: 0,
224            matrix_market: 1,
225        },
226        branch_susceptance_formula: instance.formula.formula_name(),
227        build_options: BuildOptionsMeta {
228            skip_zero_impedance: instance.skip_zero_impedance,
229            synthesize_unrated_limits: instance.synthesize_unrated_limits,
230        },
231        zero_impedance: ZeroImpedanceMeta {
232            skip: instance.skip_zero_impedance,
233            rule: "Reactance",
234            skipped: ZeroImpedanceSkips {
235                count: instance.branches.skipped_zero_impedance.len(),
236                branch_indices: &instance.branches.skipped_zero_impedance,
237            },
238        },
239        grounding: GroundingMeta {
240            reference_buses: instance.reference_buses.as_ref(),
241            removed_rows_and_columns: instance.reference_buses.as_ref(),
242            grounded_operator: "L_grounded",
243            reference_selector: "e_r",
244        },
245        operators: operator_meta(
246            instance.n_buses,
247            instance.n_branches(),
248            instance.reference_buses.len(),
249            instance.n_generators(),
250            power_units,
251        ),
252        units: instance.units,
253        cost_policy: options.metadata.cost_policy,
254        synthesized_gen_costs: options.metadata.cost_report.synthesized,
255        patched_gen_costs: options.metadata.cost_report.patched,
256        // The manifest lists the operator files it describes; it does not
257        // list itself, matching the manifest shape consumers already read.
258        files: inventory
259            .iter()
260            .map(|(name, _)| (*name).to_string())
261            .collect(),
262        powerio_version: powerio_tx::VERSION,
263    };
264    let json = serde_json::to_string_pretty(&meta)
265        .map_err(|error| crate::Error::Mtx(error.to_string()))?;
266    inventory.push(("dcopf_meta.json", json.into_bytes()));
267
268    // The complete bundle commits at once through the no-replace destination:
269    // an existing bundle directory is refused rather than replaced.
270    let artifacts = inventory
271        .into_iter()
272        .map(|(name, bytes)| {
273            Ok(powerio_core::MemoryArtifact::new(
274                powerio_core::ArtifactPath::new(name)?,
275                bytes,
276            ))
277        })
278        .collect::<std::result::Result<Vec<_>, powerio_core::Error>>()
279        .map_err(crate::Error::from)?;
280    let committed = powerio_core::Destination::path(&bundle_root)
281        .__commit_artifacts(
282            true,
283            powerio_core::Fidelity::Canonical,
284            artifacts,
285            Vec::new(),
286        )
287        .map_err(crate::Error::from)?;
288    let powerio_core::EmittedOutput::Path { root, artifacts } = committed.into_output() else {
289        unreachable!("a path destination returns a path output")
290    };
291
292    Ok(DcOpfOutputs {
293        dir: root,
294        files: artifacts,
295    })
296}
297
298#[allow(clippy::too_many_lines)]
299fn operator_meta(
300    n: usize,
301    m: usize,
302    n_ref: usize,
303    n_gen: usize,
304    power_units: &'static str,
305) -> Vec<OperatorMeta> {
306    let n_grounded = n - n_ref;
307    vec![
308        op(
309            "signed_incidence",
310            "A.mtx",
311            "matrix",
312            n,
313            m,
314            "bus_by_branch",
315            "unitless",
316        ),
317        op(
318            "branch_susceptance",
319            "b.mtx",
320            "vector",
321            m,
322            1,
323            "branch",
324            power_units,
325        ),
326        op(
327            "branch_phase_shift",
328            "shift.mtx",
329            "vector",
330            m,
331            1,
332            "branch",
333            "radian",
334        ),
335        op(
336            "branch_flow_offset",
337            "flow_offset.mtx",
338            "vector",
339            m,
340            1,
341            "branch",
342            power_units,
343        ),
344        op(
345            "weighted_laplacian",
346            "L.mtx",
347            "matrix",
348            n,
349            n,
350            "bus_by_bus",
351            power_units,
352        ),
353        op(
354            "grounded_laplacian",
355            "L_grounded.mtx",
356            "matrix",
357            n_grounded,
358            n_grounded,
359            "grounded_bus_by_grounded_bus",
360            power_units,
361        ),
362        op(
363            "branch_flow_matrix",
364            "BAt.mtx",
365            "matrix",
366            m,
367            n,
368            "branch_by_bus",
369            power_units,
370        ),
371        op(
372            "generator_to_bus",
373            "Cg.mtx",
374            "matrix",
375            n,
376            n_gen,
377            "bus_by_generator",
378            "unitless",
379        ),
380        op(
381            "phase_shift_injection",
382            "p_shift.mtx",
383            "vector",
384            n,
385            1,
386            "bus",
387            power_units,
388        ),
389        op(
390            "fixed_nodal_withdrawal",
391            "fixed_withdrawal.mtx",
392            "vector",
393            n,
394            1,
395            "bus",
396            power_units,
397        ),
398        op(
399            "reference_selector",
400            "e_r.mtx",
401            "vector",
402            n,
403            1,
404            "bus",
405            "indicator",
406        ),
407        op(
408            "bus_cost_quadratic",
409            "q.mtx",
410            "vector",
411            n,
412            1,
413            "bus",
414            "selected_cost_units",
415        ),
416        op(
417            "bus_cost_linear",
418            "c.mtx",
419            "vector",
420            n,
421            1,
422            "bus",
423            "selected_cost_units",
424        ),
425        op(
426            "bus_cost_constant",
427            "c0.mtx",
428            "vector",
429            n,
430            1,
431            "bus",
432            "selected_cost_units",
433        ),
434        op(
435            "bus_generation_upper",
436            "pmax.mtx",
437            "vector",
438            n,
439            1,
440            "bus",
441            power_units,
442        ),
443        op(
444            "bus_generation_lower",
445            "pmin.mtx",
446            "vector",
447            n,
448            1,
449            "bus",
450            power_units,
451        ),
452        op(
453            "branch_flow_limit",
454            "fmax.mtx",
455            "vector",
456            m,
457            1,
458            "branch",
459            power_units,
460        ),
461        op("bus_load", "pd.mtx", "vector", n, 1, "bus", power_units),
462        op(
463            "bus_shunt_conductance",
464            "gs.mtx",
465            "vector",
466            n,
467            1,
468            "bus",
469            power_units,
470        ),
471        op(
472            "branch_angle_minimum",
473            "angle_min.mtx",
474            "vector",
475            m,
476            1,
477            "branch",
478            "radian",
479        ),
480        op(
481            "branch_angle_maximum",
482            "angle_max.mtx",
483            "vector",
484            m,
485            1,
486            "branch",
487            "radian",
488        ),
489        op(
490            "generator_cost_quadratic",
491            "q_gen.mtx",
492            "vector",
493            n_gen,
494            1,
495            "generator",
496            "selected_cost_units",
497        ),
498        op(
499            "generator_cost_linear",
500            "c_gen.mtx",
501            "vector",
502            n_gen,
503            1,
504            "generator",
505            "selected_cost_units",
506        ),
507        op(
508            "generator_cost_constant",
509            "c0_gen.mtx",
510            "vector",
511            n_gen,
512            1,
513            "generator",
514            "selected_cost_units",
515        ),
516        op(
517            "generator_upper",
518            "pmax_gen.mtx",
519            "vector",
520            n_gen,
521            1,
522            "generator",
523            power_units,
524        ),
525        op(
526            "generator_lower",
527            "pmin_gen.mtx",
528            "vector",
529            n_gen,
530            1,
531            "generator",
532            power_units,
533        ),
534    ]
535}
536
537fn op(
538    name: &'static str,
539    file: &'static str,
540    kind: &'static str,
541    rows: usize,
542    cols: usize,
543    index_space: &'static str,
544    units: &'static str,
545) -> OperatorMeta {
546    OperatorMeta {
547        name,
548        file,
549        kind,
550        rows,
551        cols,
552        index_space,
553        units,
554    }
555}
556
557fn put_mat(
558    inventory: &mut Vec<(&'static str, Vec<u8>)>,
559    name: &'static str,
560    matrix: &SparseMatrix,
561) -> Result<()> {
562    inventory.push((name, crate::io::to_mtx_bytes(matrix)?));
563    Ok(())
564}
565
566fn put_vec(
567    inventory: &mut Vec<(&'static str, Vec<u8>)>,
568    name: &'static str,
569    values: &[f64],
570) -> Result<()> {
571    inventory.push((name, crate::io::to_vector_mtx_bytes(values)?));
572    Ok(())
573}