cobre-sddp 0.6.2

Stochastic Dual Dynamic Programming (SDDP) for hydrothermal dispatch and energy planning
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
//! Scaling report data structures and computation for LP conditioning diagnostics.
//!
//! Captures the pre-scaling and post-scaling coefficient ranges of the stage template
//! LPs. Written once after template build as a JSON diagnostic artifact.

use cobre_solver::StageTemplate;
use serde::Serialize;

/// Complete scaling report for all stages.
#[derive(Debug, Clone, Serialize)]
pub struct ScalingReport {
    /// The cost scale factor applied to objective coefficients.
    pub cost_scale_factor: f64,

    /// Per-stage scaling reports.
    pub stages: Vec<StageScalingReport>,

    /// Cross-stage summary statistics.
    pub summary: ScalingReportSummary,
}

/// Scaling report for a single stage.
#[derive(Debug, Clone, Serialize)]
pub struct StageScalingReport {
    /// Stage index (0-based).
    pub stage_id: usize,

    /// LP dimensions.
    pub dimensions: LpDimensions,

    /// Coefficient ranges before column/row scaling.
    pub pre_scaling: CoefficientRange,

    /// Coefficient ranges after column/row scaling (and cost scaling).
    pub post_scaling: CoefficientRange,

    /// Column scale factor summary.
    pub col_scale: ScaleFactorSummary,

    /// Row scale factor summary.
    pub row_scale: ScaleFactorSummary,
}

/// LP matrix and objective dimensions.
//
// `clippy::struct_field_names` flags the common `num_` prefix; the field
// names are the canonical LP terminology (`num_cols`, `num_rows`, `num_nz`)
// matching what the solver layer uses, so we keep them as-is.
#[allow(clippy::struct_field_names)]
#[derive(Debug, Clone, Serialize)]
pub struct LpDimensions {
    /// Number of columns (decision variables).
    pub num_cols: usize,

    /// Number of structural rows (constraints).
    pub num_rows: usize,

    /// Number of nonzero entries in the constraint matrix.
    pub num_nz: usize,
}

/// Coefficient range for matrix and objective.
#[derive(Debug, Clone, Serialize)]
pub struct CoefficientRange {
    /// `[min|A_ij|, max|A_ij|]` over nonzero matrix entries.
    pub matrix_coeff_range: [f64; 2],

    /// `max / min` of absolute nonzero matrix values.
    pub matrix_coeff_ratio: f64,

    /// `[min|c_j|, max|c_j|]` over nonzero objective coefficients.
    pub objective_range: [f64; 2],

    /// `max / min` of absolute nonzero objective values.
    pub objective_ratio: f64,
}

/// Summary of a scale factor vector.
#[derive(Debug, Clone, Serialize)]
pub struct ScaleFactorSummary {
    /// Minimum scale factor.
    pub min: f64,

    /// Maximum scale factor.
    pub max: f64,

    /// Median scale factor.
    pub median: f64,

    /// Number of scale factors (equal to `num_cols` or `num_rows`).
    pub count: usize,
}

/// Cross-stage summary of scaling effectiveness.
#[derive(Debug, Clone, Serialize)]
pub struct ScalingReportSummary {
    /// Maximum pre-scaling matrix coefficient ratio across all stages.
    pub worst_pre_scaling_matrix_ratio: f64,

    /// Maximum post-scaling matrix coefficient ratio across all stages.
    pub worst_post_scaling_matrix_ratio: f64,

    /// Improvement factor: `worst_pre / worst_post`.
    pub improvement_factor: f64,

    /// Number of stages.
    pub num_stages: usize,
}

/// Compute the min and max absolute values over nonzero entries.
///
/// Returns `(min_abs, max_abs)`. For an empty or all-zero slice, returns
/// `(f64::INFINITY, 0.0)` which produces a ratio of 0.
fn compute_abs_range(values: &[f64]) -> (f64, f64) {
    let mut min_abs = f64::INFINITY;
    let mut max_abs = 0.0_f64;
    for &v in values {
        if v != 0.0 {
            let abs_v = v.abs();
            min_abs = min_abs.min(abs_v);
            max_abs = max_abs.max(abs_v);
        }
    }
    (min_abs, max_abs)
}

/// Compute the coefficient range for a stage template's matrix and objective.
#[must_use]
pub fn compute_coefficient_range(tmpl: &StageTemplate) -> CoefficientRange {
    let (mat_min, mat_max) = compute_abs_range(&tmpl.values);
    let (obj_min, obj_max) = compute_abs_range(&tmpl.objective);

    let matrix_ratio = if mat_min > 0.0 && mat_min.is_finite() {
        mat_max / mat_min
    } else {
        0.0
    };
    let objective_ratio = if obj_min > 0.0 && obj_min.is_finite() {
        obj_max / obj_min
    } else {
        0.0
    };

    CoefficientRange {
        matrix_coeff_range: [mat_min, mat_max],
        matrix_coeff_ratio: matrix_ratio,
        objective_range: [obj_min, obj_max],
        objective_ratio,
    }
}

/// Compute the median of a slice by sorting a copy.
///
/// Returns 0.0 for an empty slice.
fn median(values: &[f64]) -> f64 {
    if values.is_empty() {
        return 0.0;
    }
    let mut sorted = values.to_vec();
    // `total_cmp` provides IEEE-754 total ordering on f64; required so that
    // NaN inputs land in a deterministic position and the sort is
    // declaration-order-invariant (Cobre hard rule).
    sorted.sort_by(f64::total_cmp);
    let n = sorted.len();
    if n % 2 == 0 {
        f64::midpoint(sorted[n / 2 - 1], sorted[n / 2])
    } else {
        sorted[n / 2]
    }
}

/// Summarize a scale factor vector.
///
/// Returns a summary with zeros for an empty vector.
#[must_use]
pub fn summarize_scale_factors(factors: &[f64]) -> ScaleFactorSummary {
    if factors.is_empty() {
        return ScaleFactorSummary {
            min: 0.0,
            max: 0.0,
            median: 0.0,
            count: 0,
        };
    }
    let mut min_val = f64::INFINITY;
    let mut max_val = f64::NEG_INFINITY;
    for &v in factors {
        min_val = min_val.min(v);
        max_val = max_val.max(v);
    }
    ScaleFactorSummary {
        min: min_val,
        max: max_val,
        median: median(factors),
        count: factors.len(),
    }
}

/// Build a complete [`ScalingReport`] from per-stage pre/post data.
///
/// `cost_scale_factor` is the factor that was applied to objective coefficients
/// during template building.
#[must_use]
pub fn build_scaling_report(
    cost_scale_factor: f64,
    stage_reports: Vec<StageScalingReport>,
) -> ScalingReport {
    let num_stages = stage_reports.len();
    let worst_pre = stage_reports
        .iter()
        .map(|s| s.pre_scaling.matrix_coeff_ratio)
        .fold(0.0_f64, f64::max);
    let worst_post = stage_reports
        .iter()
        .map(|s| s.post_scaling.matrix_coeff_ratio)
        .fold(0.0_f64, f64::max);
    let improvement = if worst_post > 0.0 {
        worst_pre / worst_post
    } else {
        0.0
    };

    ScalingReport {
        cost_scale_factor,
        stages: stage_reports,
        summary: ScalingReportSummary {
            worst_pre_scaling_matrix_ratio: worst_pre,
            worst_post_scaling_matrix_ratio: worst_post,
            improvement_factor: improvement,
            num_stages,
        },
    }
}

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

    #[test]
    fn test_compute_abs_range_basic() {
        let values = [1.0, -3.0, 0.0, 2.0, -0.5];
        let (min_abs, max_abs) = compute_abs_range(&values);
        assert!((min_abs - 0.5).abs() < 1e-15);
        assert!((max_abs - 3.0).abs() < 1e-15);
    }

    #[test]
    fn test_compute_abs_range_all_zero() {
        let values = [0.0, 0.0];
        let (min_abs, max_abs) = compute_abs_range(&values);
        assert!(min_abs.is_infinite());
        assert!((max_abs - 0.0).abs() < 1e-15);
    }

    #[test]
    fn test_compute_abs_range_empty() {
        let (min_abs, max_abs) = compute_abs_range(&[]);
        assert!(min_abs.is_infinite());
        assert!((max_abs - 0.0).abs() < 1e-15);
    }

    #[test]
    fn test_median_odd() {
        assert!((median(&[3.0, 1.0, 2.0]) - 2.0).abs() < 1e-15);
    }

    #[test]
    fn test_median_even() {
        assert!((median(&[4.0, 1.0, 3.0, 2.0]) - 2.5).abs() < 1e-15);
    }

    #[test]
    fn test_median_single() {
        assert!((median(&[7.0]) - 7.0).abs() < 1e-15);
    }

    #[test]
    fn test_median_empty() {
        assert!((median(&[]) - 0.0).abs() < 1e-15);
    }

    #[test]
    fn test_summarize_scale_factors() {
        let factors = [0.1, 1.0, 10.0, 1.0, 0.5];
        let summary = summarize_scale_factors(&factors);
        assert!((summary.min - 0.1).abs() < 1e-15);
        assert!((summary.max - 10.0).abs() < 1e-15);
        assert!((summary.median - 1.0).abs() < 1e-15);
        assert_eq!(summary.count, 5);
    }

    #[test]
    fn test_summarize_scale_factors_empty() {
        let summary = summarize_scale_factors(&[]);
        assert_eq!(summary.count, 0);
        assert!((summary.min - 0.0).abs() < 1e-15);
    }

    #[test]
    fn test_coefficient_range_from_template() {
        let tmpl = StageTemplate {
            num_cols: 3,
            num_rows: 2,
            num_nz: 3,
            col_starts: vec![0_i32, 2, 2, 3],
            row_indices: vec![0_i32, 1, 1],
            values: vec![1.0, 2.0, 0.5],
            col_lower: vec![0.0; 3],
            col_upper: vec![10.0; 3],
            objective: vec![0.0, 1.0, 50.0],
            row_lower: vec![0.0; 2],
            row_upper: vec![0.0; 2],
            n_state: 1,
            n_transfer: 0,
            n_dual_relevant: 1,
            n_hydro: 0,
            max_par_order: 0,
            col_scale: Vec::new(),
            row_scale: Vec::new(),
        };
        let range = compute_coefficient_range(&tmpl);
        assert!((range.matrix_coeff_range[0] - 0.5).abs() < 1e-15);
        assert!((range.matrix_coeff_range[1] - 2.0).abs() < 1e-15);
        assert!((range.matrix_coeff_ratio - 4.0).abs() < 1e-15);
        assert!((range.objective_range[0] - 1.0).abs() < 1e-15);
        assert!((range.objective_range[1] - 50.0).abs() < 1e-15);
        assert!((range.objective_ratio - 50.0).abs() < 1e-15);
    }

    #[test]
    fn test_build_scaling_report_summary() {
        let stage1 = StageScalingReport {
            stage_id: 0,
            dimensions: LpDimensions {
                num_cols: 10,
                num_rows: 5,
                num_nz: 20,
            },
            pre_scaling: CoefficientRange {
                matrix_coeff_range: [0.01, 1000.0],
                matrix_coeff_ratio: 100_000.0,
                objective_range: [1.0, 100.0],
                objective_ratio: 100.0,
            },
            post_scaling: CoefficientRange {
                matrix_coeff_range: [0.5, 5.0],
                matrix_coeff_ratio: 10.0,
                objective_range: [0.001, 0.1],
                objective_ratio: 100.0,
            },
            col_scale: ScaleFactorSummary {
                min: 0.01,
                max: 100.0,
                median: 1.0,
                count: 10,
            },
            row_scale: ScaleFactorSummary {
                min: 0.1,
                max: 10.0,
                median: 1.0,
                count: 5,
            },
        };
        let report = build_scaling_report(1000.0, vec![stage1]);
        assert_eq!(report.summary.num_stages, 1);
        assert!((report.summary.worst_pre_scaling_matrix_ratio - 100_000.0).abs() < 1e-6);
        assert!((report.summary.worst_post_scaling_matrix_ratio - 10.0).abs() < 1e-6);
        assert!((report.summary.improvement_factor - 10_000.0).abs() < 1e-6);
    }

    #[test]
    fn total_cmp_handles_nan_deterministically() {
        // Verify that sorting with total_cmp produces a stable, NaN-safe order.
        // NaN values must not cause a panic or non-deterministic ordering.
        let mut values = [f64::NAN, 3.0, 1.0, f64::NAN, 2.0];
        values.sort_by(f64::total_cmp);
        // total_cmp places NaN after all finite values (NaN > everything in
        // total order). The finite elements must be in ascending order.
        assert_eq!(values[0], 1.0);
        assert_eq!(values[1], 2.0);
        assert_eq!(values[2], 3.0);
        assert!(values[3].is_nan());
        assert!(values[4].is_nan());

        // Descending order (as used in compute_cvar / aggregation sort).
        // total_cmp places +NaN > +inf > finite, so reversing it (b.total_cmp(a))
        // puts +NaN at the front. The deterministic placement is what matters
        // for declaration-order-invariance; downstream code should not see NaN
        // in production, but if it does, the position is reproducible.
        let mut desc = [f64::NAN, 3.0, 1.0, f64::NAN, 2.0];
        desc.sort_by(|a, b| b.total_cmp(a));
        assert!(desc[0].is_nan());
        assert!(desc[1].is_nan());
        assert_eq!(desc[2], 3.0);
        assert_eq!(desc[3], 2.0);
        assert_eq!(desc[4], 1.0);

        // Determinism check: re-sorting the same input twice produces the
        // same permutation by-bits.
        let mut a = [f64::NAN, 3.0, 1.0, f64::NAN, 2.0];
        let mut b = [f64::NAN, 3.0, 1.0, f64::NAN, 2.0];
        a.sort_by(f64::total_cmp);
        b.sort_by(f64::total_cmp);
        for i in 0..a.len() {
            assert_eq!(a[i].to_bits(), b[i].to_bits());
        }
    }
}