oxigrid 0.1.2

Pure Rust Energy Systems Simulation & Optimization Library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
/// Export `PowerNetwork` to MATPOWER `.m` file format.
///
/// The exported file is compatible with MATPOWER 7.x and can be loaded
/// directly into MATLAB or Octave using `runpf`, `runopf`, etc.
///
/// # File structure
///
/// ```matlab
/// function mpc = <case_name>
/// mpc.version = '2';
/// mpc.baseMVA = 100;
///
/// %% bus data
/// %  bus_i  type  Pd  Qd  Gs  Bs  area  Vm  Va  baseKV  zone  Vmax  Vmin
/// mpc.bus = [...];
///
/// %% generator data
/// %  bus  Pg  Qg  Qmax  Qmin  Vg  mBase  status  Pmax  Pmin ...
/// mpc.gen = [...];
///
/// %% branch data
/// %  fbus  tbus  r  x  b  rateA  rateB  rateC  ratio  angle  status  angmin  angmax
/// mpc.branch = [...];
/// ```
///
/// # References
/// - MATPOWER User's Manual 7.1, Ray Zimmermann et al., PSERC 2020
/// - MATPOWER case format specification
use crate::network::{branch::Branch, bus::Bus, PowerNetwork};
use serde::{Deserialize, Serialize};

// ────────────────────────────────────────────────────────────────────────────
// Export configuration
// ────────────────────────────────────────────────────────────────────────────

/// Configuration for MATPOWER export.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatpowerExportConfig {
    /// Case name (used as function name in .m file)
    pub case_name: String,
    /// Base MVA
    pub base_mva: f64,
    /// Include generator cost data?
    pub include_gencost: bool,
    /// Include bus voltage results (Vm, Va from last solve)?
    pub include_results: bool,
    /// Comment header lines
    pub header_comment: Option<String>,
}

impl Default for MatpowerExportConfig {
    fn default() -> Self {
        Self {
            case_name: "oxigrid_export".to_string(),
            base_mva: 100.0,
            include_gencost: false,
            include_results: false,
            header_comment: None,
        }
    }
}

// ────────────────────────────────────────────────────────────────────────────
// Bus type mapping
// ────────────────────────────────────────────────────────────────────────────

/// MATPOWER bus type codes.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum MatpowerBusType {
    PQ = 1,
    PV = 2,
    Slack = 3,
    Isolated = 4,
}

impl MatpowerBusType {
    pub fn from_bus(bus: &Bus) -> Self {
        use crate::network::bus::BusType;
        match bus.bus_type {
            BusType::Slack => MatpowerBusType::Slack,
            BusType::PV => MatpowerBusType::PV,
            BusType::PQ => MatpowerBusType::PQ,
        }
    }
}

// ────────────────────────────────────────────────────────────────────────────
// Main export function
// ────────────────────────────────────────────────────────────────────────────

/// Export a `PowerNetwork` to a MATPOWER-format `.m` file string.
///
/// Returns the file content as a String. Write to disk with `std::fs::write`.
pub fn export_matpower(network: &PowerNetwork, config: &MatpowerExportConfig) -> String {
    let mut out = String::with_capacity(4096);

    // Header
    if let Some(ref comment) = config.header_comment {
        for line in comment.lines() {
            out.push_str(&format!("%% {line}\n"));
        }
        out.push('\n');
    }

    out.push_str(&format!("function mpc = {}\n", config.case_name));
    out.push_str("%% MATPOWER Case Format : Version 2\n");
    out.push_str("%% Generated by OxiGrid\n\n");

    out.push_str("mpc.version = '2';\n");
    out.push_str(&format!("mpc.baseMVA = {};\n\n", config.base_mva));

    // Bus data
    out.push_str("%% bus data\n");
    out.push_str("%  bus_i\ttype\tPd\tQd\tGs\tBs\tarea\tVm\tVa\tbaseKV\tzone\tVmax\tVmin\n");
    out.push_str("mpc.bus = [\n");

    for (idx, bus) in network.buses.iter().enumerate() {
        let bus_type = MatpowerBusType::from_bus(bus) as i32;
        // pd is in MW (Power newtype wrapping MW in this context)
        let pd_mw = bus.pd.0;
        let qd_mvar = bus.qd.0;
        let vm = 1.0_f64; // Default flat start
        let va = 0.0_f64;
        let base_kv = 132.0_f64; // Default; could be stored in Bus
        let v_max = 1.1_f64;
        let v_min = 0.9_f64;

        out.push_str(&format!(
            "\t{}\t{}\t{:.4}\t{:.4}\t0\t0\t1\t{:.4}\t{:.4}\t{:.1}\t1\t{:.4}\t{:.4};\n",
            idx + 1,
            bus_type,
            pd_mw,
            qd_mvar,
            vm,
            va,
            base_kv,
            v_max,
            v_min
        ));
    }
    out.push_str("];\n\n");

    // Generator data
    out.push_str("%% generator data\n");
    out.push_str("%  bus\tPg\tQg\tQmax\tQmin\tVg\tmBase\tstatus\tPmax\tPmin\n");
    out.push_str("mpc.gen = [\n");

    for gen in &network.generators {
        // Generator fields use MATPOWER MW/MVAr units (already in MW/MVAr in the struct)
        let pg_mw = gen.pg;
        let qg_mvar = gen.qg;
        let q_max = gen.qmax;
        let q_min = gen.qmin;
        let p_max = gen.pmax;
        let p_min = gen.pmin;
        let v_g = gen.vg;
        let bus_i = gen.bus_id; // bus_id is already 1-indexed (external bus ID)

        out.push_str(&format!(
            "\t{}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.1}\t1\t{:.4}\t{:.4};\n",
            bus_i, pg_mw, qg_mvar, q_max, q_min, v_g, config.base_mva, p_max, p_min
        ));
    }
    out.push_str("];\n\n");

    // Branch data
    out.push_str("%% branch data\n");
    out.push_str(
        "%  fbus\ttbus\tr\tx\tb\trateA\trateB\trateC\tratio\tangle\tstatus\tangmin\tangmax\n",
    );
    out.push_str("mpc.branch = [\n");

    for branch in &network.branches {
        let from = branch.from_bus + 1;
        let to = branch.to_bus + 1;
        let r = branch.r;
        let x = branch.x;
        let b = branch.b;
        let rate_a = if branch.rate_a > 0.0 {
            branch.rate_a
        } else {
            9999.0
        };
        let tap_ratio = branch.effective_tap();
        let angle = branch.shift;

        out.push_str(&format!(
            "\t{}\t{}\t{:.6}\t{:.6}\t{:.6}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t1\t-360\t360;\n",
            from, to, r, x, b, rate_a, rate_a, rate_a, tap_ratio, angle
        ));
    }
    out.push_str("];\n\n");

    // Generator cost (optional)
    if config.include_gencost {
        out.push_str("%% generator cost data\n");
        out.push_str("%  2\tstartup\tshutdown\tncost\tc(n-1)\t...\tc0\n");
        out.push_str("mpc.gencost = [\n");
        for _ in &network.generators {
            // Quadratic cost: c2=0, c1=30 $/MWh, c0=0 (defaults)
            out.push_str("\t2\t0\t0\t3\t0\t30.0\t0;\n");
        }
        out.push_str("];\n\n");
    }

    out
}

// ────────────────────────────────────────────────────────────────────────────
// Round-trip verification helpers
// ────────────────────────────────────────────────────────────────────────────

/// Minimal parser to verify round-trip: count buses, gens, branches in exported file.
pub struct MatpowerExportStats {
    pub n_buses: usize,
    pub n_gens: usize,
    pub n_branches: usize,
    pub base_mva: f64,
    pub case_name: String,
}

/// Parse basic statistics from an exported MATPOWER string (for round-trip tests).
pub fn parse_export_stats(content: &str) -> MatpowerExportStats {
    let mut n_buses = 0;
    let mut n_gens = 0;
    let mut n_branches = 0;
    let mut base_mva = 100.0;
    let mut case_name = String::new();

    let mut in_bus = false;
    let mut in_gen = false;
    let mut in_branch = false;

    for line in content.lines() {
        let line = line.trim();

        if line.starts_with("function mpc =") {
            case_name = line.split('=').nth(1).unwrap_or("").trim().to_string();
        } else if line.contains("baseMVA") {
            if let Some(val) = line.split('=').nth(1) {
                base_mva = val
                    .trim()
                    .trim_end_matches(';')
                    .trim()
                    .parse()
                    .unwrap_or(100.0);
            }
        } else if line.starts_with("mpc.bus = [") {
            in_bus = true;
            in_gen = false;
            in_branch = false;
        } else if line.starts_with("mpc.gen = [") {
            in_gen = true;
            in_bus = false;
            in_branch = false;
        } else if line.starts_with("mpc.branch = [") {
            in_branch = true;
            in_bus = false;
            in_gen = false;
        } else if line == "];" {
            in_bus = false;
            in_gen = false;
            in_branch = false;
        } else if in_bus && line.ends_with(';') && !line.starts_with('%') {
            n_buses += 1;
        } else if in_gen && line.ends_with(';') && !line.starts_with('%') {
            n_gens += 1;
        } else if in_branch && line.ends_with(';') && !line.starts_with('%') {
            n_branches += 1;
        }
    }

    MatpowerExportStats {
        n_buses,
        n_gens,
        n_branches,
        base_mva,
        case_name,
    }
}

// ────────────────────────────────────────────────────────────────────────────
// Branch / Bus helpers for export (access private fields via public API)
// ────────────────────────────────────────────────────────────────────────────

/// Trait extension for Branch: extra fields needed for MATPOWER export.
pub trait BranchMatpowerExt {
    fn max_apparent_power_mva(&self) -> Option<f64>;
    fn tap_ratio(&self) -> f64;
    fn phase_shift_deg(&self) -> f64;
}

impl BranchMatpowerExt for Branch {
    fn max_apparent_power_mva(&self) -> Option<f64> {
        if self.rate_a > 0.0 {
            Some(self.rate_a)
        } else {
            None
        }
    }
    fn tap_ratio(&self) -> f64 {
        self.effective_tap()
    }
    fn phase_shift_deg(&self) -> f64 {
        self.shift
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::network::PowerNetwork;

    fn make_test_network() -> PowerNetwork {
        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/ieee14.m");
        PowerNetwork::from_matpower(path).expect("IEEE 14 load failed")
    }

    #[test]
    fn test_export_contains_function_header() {
        let net = make_test_network();
        let config = MatpowerExportConfig {
            case_name: "test_case".to_string(),
            ..Default::default()
        };
        let content = export_matpower(&net, &config);
        assert!(
            content.contains("function mpc = test_case"),
            "Should have function header"
        );
    }

    #[test]
    fn test_export_contains_base_mva() {
        let net = make_test_network();
        let config = MatpowerExportConfig::default();
        let content = export_matpower(&net, &config);
        assert!(content.contains("mpc.baseMVA"), "Should have baseMVA");
        assert!(content.contains("100"), "baseMVA should be 100");
    }

    #[test]
    fn test_export_bus_count_matches() {
        let net = make_test_network();
        let config = MatpowerExportConfig::default();
        let content = export_matpower(&net, &config);
        let stats = parse_export_stats(&content);
        assert_eq!(
            stats.n_buses,
            net.buses.len(),
            "Exported bus count should match: {} vs {}",
            stats.n_buses,
            net.buses.len()
        );
    }

    #[test]
    fn test_export_gen_count_matches() {
        let net = make_test_network();
        let config = MatpowerExportConfig::default();
        let content = export_matpower(&net, &config);
        let stats = parse_export_stats(&content);
        assert_eq!(
            stats.n_gens,
            net.generators.len(),
            "Exported gen count should match: {} vs {}",
            stats.n_gens,
            net.generators.len()
        );
    }

    #[test]
    fn test_export_branch_count_matches() {
        let net = make_test_network();
        let config = MatpowerExportConfig::default();
        let content = export_matpower(&net, &config);
        let stats = parse_export_stats(&content);
        assert_eq!(
            stats.n_branches,
            net.branches.len(),
            "Exported branch count: {} vs {}",
            stats.n_branches,
            net.branches.len()
        );
    }

    #[test]
    fn test_export_with_gencost() {
        let net = make_test_network();
        let config = MatpowerExportConfig {
            include_gencost: true,
            ..Default::default()
        };
        let content = export_matpower(&net, &config);
        assert!(
            content.contains("mpc.gencost"),
            "Should include gencost when requested"
        );
    }

    #[test]
    fn test_export_without_gencost() {
        let net = make_test_network();
        let config = MatpowerExportConfig {
            include_gencost: false,
            ..Default::default()
        };
        let content = export_matpower(&net, &config);
        assert!(
            !content.contains("mpc.gencost"),
            "Should not include gencost when not requested"
        );
    }

    #[test]
    fn test_export_valid_utf8() {
        let net = make_test_network();
        let config = MatpowerExportConfig::default();
        let content = export_matpower(&net, &config);
        assert!(
            std::str::from_utf8(content.as_bytes()).is_ok(),
            "Export should be valid UTF-8"
        );
    }

    #[test]
    fn test_export_with_header_comment() {
        let net = make_test_network();
        let config = MatpowerExportConfig {
            header_comment: Some("OxiGrid export\nTimestamp: 2026".to_string()),
            ..Default::default()
        };
        let content = export_matpower(&net, &config);
        assert!(content.contains("%% OxiGrid export"));
        assert!(content.contains("%% Timestamp: 2026"));
    }

    #[test]
    fn test_roundtrip_parse_stats() {
        let net = make_test_network();
        let config = MatpowerExportConfig {
            case_name: "ieee14_roundtrip".to_string(),
            base_mva: 100.0,
            ..Default::default()
        };
        let content = export_matpower(&net, &config);
        let stats = parse_export_stats(&content);
        assert!((stats.base_mva - 100.0).abs() < 1e-9);
        assert!(stats.case_name.contains("ieee14_roundtrip"));
    }

    #[test]
    fn test_branch_matpower_ext_defaults() {
        let net = make_test_network();
        for branch in &net.branches {
            let tap = branch.tap_ratio();
            let angle = branch.phase_shift_deg();
            assert!(tap > 0.0, "Tap ratio should be positive: {:.4}", tap);
            assert!(angle.is_finite());
        }
    }
}