FLToptim 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/__init__.py +1 -1
  2. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/csimplex/param_simplex.cpp +71 -14
  3. fltoptim-0.3.0/FLToptim/mr_decompose.py +307 -0
  4. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/param_simplex.py +66 -0
  5. fltoptim-0.3.0/FLToptim/tests/test_mr_decompose.py +190 -0
  6. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_param_simplex.py +24 -2
  7. {fltoptim-0.2.0 → fltoptim-0.3.0/FLToptim.egg-info}/PKG-INFO +1 -1
  8. {fltoptim-0.2.0/FLToptim.egg-info → fltoptim-0.3.0}/PKG-INFO +1 -1
  9. {fltoptim-0.2.0 → fltoptim-0.3.0}/pyproject.toml +1 -1
  10. fltoptim-0.2.0/FLToptim/mr_decompose.py +0 -183
  11. fltoptim-0.2.0/FLToptim/tests/test_mr_decompose.py +0 -63
  12. {fltoptim-0.2.0 → fltoptim-0.3.0}/CITATION.cff +0 -0
  13. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/Wrapper_FLToptim.cpp +0 -0
  14. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/Wrapper_FLToptim.pyx +0 -0
  15. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/convex_functions_tools.hpp +0 -0
  16. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/cplfunction.cpp +0 -0
  17. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/cplfunction.hpp +0 -0
  18. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/cplfunction_double.cpp +0 -0
  19. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/cplfunction_instantiations.cpp +0 -0
  20. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/cplfunction_instantiations.hpp +0 -0
  21. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/cpqfunction.cpp +0 -0
  22. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/cpqfunction.hpp +0 -0
  23. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/cpqfunction_double.cpp +0 -0
  24. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/cpqfunction_instantiations.cpp +0 -0
  25. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/cpqfunction_instantiations.hpp +0 -0
  26. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/csimplex/build.sh +0 -0
  27. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/curves.py +0 -0
  28. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/elec.py +0 -0
  29. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/elec_parallel.py +0 -0
  30. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/numeric_traits.hpp +0 -0
  31. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/parametric.py +0 -0
  32. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/__init__.py +0 -0
  33. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/cases.py +0 -0
  34. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/conftest.py +0 -0
  35. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_cpqfunction.py +0 -0
  36. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_curves.py +0 -0
  37. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_dispatch.py +0 -0
  38. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_elec_decompose.py +0 -0
  39. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_elec_parallel.py +0 -0
  40. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_highprec.py +0 -0
  41. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_known_bugs.py +0 -0
  42. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_optimisation.py +0 -0
  43. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_parametric.py +0 -0
  44. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_performance.py +0 -0
  45. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_pycpl_function.py +0 -0
  46. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_wrapper.py +0 -0
  47. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_wrappers_more.py +0 -0
  48. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/with_inf.hpp +0 -0
  49. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/wrappers.py +0 -0
  50. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim.egg-info/SOURCES.txt +0 -0
  51. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim.egg-info/dependency_links.txt +0 -0
  52. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim.egg-info/not-zip-safe +0 -0
  53. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim.egg-info/requires.txt +0 -0
  54. {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim.egg-info/top_level.txt +0 -0
  55. {fltoptim-0.2.0 → fltoptim-0.3.0}/LICENSE +0 -0
  56. {fltoptim-0.2.0 → fltoptim-0.3.0}/MANIFEST.in +0 -0
  57. {fltoptim-0.2.0 → fltoptim-0.3.0}/README.md +0 -0
  58. {fltoptim-0.2.0 → fltoptim-0.3.0}/pytest.ini +0 -0
  59. {fltoptim-0.2.0 → fltoptim-0.3.0}/setup.cfg +0 -0
  60. {fltoptim-0.2.0 → fltoptim-0.3.0}/setup.py +0 -0
@@ -42,4 +42,4 @@ __all__ = [
42
42
  "pmax",
43
43
  ]
44
44
 
45
- __version__ = "0.2.0"
45
+ __version__ = "0.3.0"
@@ -210,6 +210,8 @@ int reopt_and_trace(const double* A, const double* c, const double* lb, const do
210
210
  return np;
211
211
  }
212
212
 
213
+ } // extern "C" (reopened after the internal core below)
214
+
213
215
  // Voie 2 BATCHED: trace ALL T hourly injection curves for one node in a single call, carrying the
214
216
  // basis internally across hours (one HiGHS seed done by the caller). Per hour: update gen caps, dual
215
217
  // re-optimise at y=0, trace [lo,0] downward, merge to a Pycplfunction-ready (slopes, breaks) with
@@ -217,12 +219,29 @@ int reopt_and_trace(const double* A, const double* c, const double* lb, const do
217
219
  // and ubgen_all[T*G]. Outputs flat: nseg_out[T], phi0_out[T], and slopes_flat/breaks_flat concatenated
218
220
  // (offsets = prefix sums of nseg_out). Returns total #segments (>=T), or <0 on error.
219
221
  //
220
- // Efficiency kink (batteries): eff==1 traces the reservoir curve in the storage-control coordinate
221
- // directly (shift==0). For eff<1 the caller shifts the node base up by `shift` (= max charge power) and
222
- // widens `lo` to cover the full charge+discharge span, so the single downward trace spans injection
223
- // contributions u = theta+shift in [-eff*Pdis, +Pcha]; each segment is then reparametrised to the DP
224
- // control x by the kink map (x=u on charge u>=0, x=u/eff on discharge u<0; slope *= eff on discharge),
225
- // splitting the segment that straddles u=0. Values are preserved, so phi0 (firstBreakVal) is unchanged.
222
+ // DIRECTION generalisation (batch_core): the RHS is parametrised along an arbitrary direction vector,
223
+ // b(theta) = b0 + theta * dvec, phi'(theta) = y . dvec,
224
+ // which the multi-resource engine needs: an H2 storage shifts SEVERAL balance rows in fixed proportion
225
+ // (its own H2 balance by cin/fout, the elec balance by the parasitic charge draw), so its injection-cost
226
+ // curve is the LP objective along a direction, not along one row. The classic single-node trace is the
227
+ // special case dvec = e_node: the direction terms are then bit-identical to the old expressions
228
+ // (th*1.0 == th at the same position in the rhs sum; Binv . e_node == the Binv column; y . e_node ==
229
+ // y[node]), so batch_curves keeps its exact historical output. dvec is used through its nonzero index
230
+ // list (<= 3 entries in practice), so the per-pivot cost is unchanged. NOTE: with conversion columns
231
+ // (coefficients like -1.53) the balance matrix is no longer totally unimodular, so B^-1 is no longer
232
+ // exact-integer -- the rank-1 updates now genuinely accumulate roundoff; the periodic refactor
233
+ // (REFAC=32) and the small-pivot guard in updinv are what keep the trace at ~1e-12 relative accuracy.
234
+ //
235
+ // Efficiency kink (batteries; single-node path only): eff==1 traces the reservoir curve in the
236
+ // storage-control coordinate directly (shift==0). For eff<1 the caller shifts the node base up by
237
+ // `shift` (= max charge power) and widens `lo` to cover the full charge+discharge span, so the single
238
+ // downward trace spans injection contributions u = theta+shift in [-eff*Pdis, +Pcha]; each segment is
239
+ // then reparametrised to the DP control x by the kink map (x=u on charge u>=0, x=u/eff on discharge
240
+ // u<0; slope *= eff on discharge), splitting the segment that straddles u=0. Values are preserved, so
241
+ // phi0 (firstBreakVal) is unchanged. The multi-resource kink changes the DIRECTION itself (parasitic
242
+ // draw on charge only), which no shift can express -- the caller runs TWO half-traces (discharge along
243
+ // d_dis over [lo,0]; charge along -d_cha over [-hi,0], reflected) and stitches them at x=0.
244
+ //
226
245
  // nthreads>1 splits [0,T) into contiguous hour-chunks traced concurrently, each re-seeded from the same
227
246
  // shared basis `status_io` (the carried basis is only a warmstart -- every hour re-optimises to its own
228
247
  // exact optimum -- so chunking is exactness-preserving: nthreads=1 is bit-identical to the serial trace,
@@ -230,12 +249,15 @@ int reopt_and_trace(const double* A, const double* c, const double* lb, const do
230
249
  // the seed) is shared; each thread carries its own working simplex state and appends to local buffers that
231
250
  // are concatenated in hour order afterwards. status_io writeback is vestigial (the caller re-seeds from
232
251
  // HiGHS every node-sweep), so returning the last chunk's basis is fine.
233
- int batch_curves(const double* A, const double* c, const double* lb, const double* ub_base,
234
- const double* b0_all, const double* ubgen_all, int m, int nx, int G, int T,
235
- int* status_io, int node, double lo, double eff, double shift,
236
- double* slopes_flat, double* breaks_flat, double* phi0_out, int* nseg_out,
237
- int max_total, int* out_total_pivots, int nthreads) {
252
+ static int batch_core(const double* A, const double* c, const double* lb, const double* ub_base,
253
+ const double* b0_all, const double* ubgen_all, int m, int nx, int G, int T,
254
+ int* status_io, const double* dvec, double lo, double eff, double shift,
255
+ double* slopes_flat, double* breaks_flat, double* phi0_out, int* nseg_out,
256
+ int max_total, int* out_total_pivots, int nthreads) {
238
257
  const double tol = 1e-7;
258
+ vector<int> dnz; // nonzero direction entries (<= 3 in practice)
259
+ for (int k = 0; k < m; ++k) if (dvec[k] != 0.0) dnz.push_back(k);
260
+ if (dnz.empty()) return -7; // a zero direction traces nothing
239
261
  { int nb = 0; for (int j = 0; j < nx; ++j) if (status_io[j] == 0) ++nb; if (nb != m) return -2; }
240
262
  // Sparse structure of A (constant across hours/pivots -- read-only, shared across threads). A column
241
263
  // has <=2 nonzeros (a gen/shed/curt hits 1 node, a line 2), a row a handful -> pivot-row entries &
@@ -270,7 +292,7 @@ int batch_curves(const double* A, const double* c, const double* lb, const doubl
270
292
  return inv_mat(Bmat.data(), Binv.data(), m);
271
293
  };
272
294
  auto cxB = [&](double th) { // rhs via CSR (O(nnz)), then dense back-sub O(m^2)
273
- for (int k = 0; k < m; ++k) { double r = b0[k] + (k == node ? th : 0.0);
295
+ for (int k = 0; k < m; ++k) { double r = b0[k] + (dvec[k] != 0.0 ? th * dvec[k] : 0.0);
274
296
  for (int p = csr_ptr[k]; p < csr_ptr[k + 1]; ++p) { int j = csr_col[p]; if (status[j] != 0) r -= csr_val[p] * val(j); }
275
297
  rhs[k] = r; }
276
298
  for (int i = 0; i < m; ++i) { double s = 0; for (int k = 0; k < m; ++k) s += Binv[i * m + k] * rhs[k]; xB[i] = s; }
@@ -331,8 +353,14 @@ int batch_curves(const double* A, const double* c, const double* lb, const doubl
331
353
  vth.clear(); vph.clear(); vth.push_back(0.0); vph.push_back(phi);
332
354
  double theta = 0.0; guard = 0;
333
355
  while (theta > lo + tol && guard++ < 800) {
334
- cy(); double slope = y[node];
335
- for (int i = 0; i < m; ++i) d[i] = -Binv[i * m + node];
356
+ cy();
357
+ double slope = 0.0; // phi'(theta) = y . dvec (over the nonzeros)
358
+ for (int q : dnz) slope += y[q] * dvec[q];
359
+ for (int i = 0; i < m; ++i) { // march direction d = -Binv . dvec
360
+ double s = 0.0;
361
+ for (int q : dnz) s += Binv[i * m + q] * dvec[q];
362
+ d[i] = -s;
363
+ }
336
364
  double best = theta - lo; int leave = -1, hu = 0;
337
365
  for (int i = 0; i < m; ++i) {
338
366
  if (d[i] > tol && ub[basis[i]] < BIG) { double r = (ub[basis[i]] - xB[i]) / d[i]; if (r < best - tol) { best = r; leave = i; hu = 1; } }
@@ -422,4 +450,33 @@ int batch_curves(const double* A, const double* c, const double* lb, const doubl
422
450
  return off;
423
451
  }
424
452
 
453
+ extern "C" {
454
+
455
+ // Classic single-node batched trace: dvec = e_node (bit-identical to the historical implementation --
456
+ // see the direction note above batch_core). Keeps the efficiency-kink (eff, shift) reparametrisation.
457
+ int batch_curves(const double* A, const double* c, const double* lb, const double* ub_base,
458
+ const double* b0_all, const double* ubgen_all, int m, int nx, int G, int T,
459
+ int* status_io, int node, double lo, double eff, double shift,
460
+ double* slopes_flat, double* breaks_flat, double* phi0_out, int* nseg_out,
461
+ int max_total, int* out_total_pivots, int nthreads) {
462
+ vector<double> dvec(m, 0.0);
463
+ if (node < 0 || node >= m) return -8;
464
+ dvec[node] = 1.0;
465
+ return batch_core(A, c, lb, ub_base, b0_all, ubgen_all, m, nx, G, T, status_io,
466
+ dvec.data(), lo, eff, shift, slopes_flat, breaks_flat, phi0_out, nseg_out,
467
+ max_total, out_total_pivots, nthreads);
468
+ }
469
+
470
+ // DIRECTION batched trace (multi-resource): b(theta) = b0 + theta * dvec over theta in [lo, 0], slope
471
+ // = y . dvec. No kink reparametrisation here -- the caller composes charge/discharge half-traces.
472
+ int batch_curves_dir(const double* A, const double* c, const double* lb, const double* ub_base,
473
+ const double* b0_all, const double* ubdyn_all, int m, int nx, int G, int T,
474
+ int* status_io, const double* dvec, double lo,
475
+ double* slopes_flat, double* breaks_flat, double* phi0_out, int* nseg_out,
476
+ int max_total, int* out_total_pivots, int nthreads) {
477
+ return batch_core(A, c, lb, ub_base, b0_all, ubdyn_all, m, nx, G, T, status_io,
478
+ dvec, lo, 1.0, 0.0, slopes_flat, breaks_flat, phi0_out, nseg_out,
479
+ max_total, out_total_pivots, nthreads);
480
+ }
481
+
425
482
  } // extern "C"
@@ -0,0 +1,307 @@
1
+ """Multi-resource (elec + H2) spatial-LP <-> storage-DP decomposition -- the M2 generalisation of the
2
+ elec-only `elec.decompose`.
3
+
4
+ A "node" is an (area, resource) pair; a column may touch two balances (a conversion consumes one resource
5
+ and produces another); there are two transport layers. A storage's control x_s = its state-of-charge
6
+ increment (pin - pout); the grid LOAD it adds is
7
+ Ladd_s(x)[k] = cch_k * x (x >= 0, charging draws the grid)
8
+ = cdis_k * x (x < 0, discharging supplies the grid)
9
+ at each node k it touches, with (cch, cdis) per node:
10
+ * elec reservoir at node n : [(n, 1, 1)] (lossless, no pumping)
11
+ * H2 storage : [(h2, cin, fout), (elec, paras, 0)] (charge consumes cin MWh_H2 +
12
+ paras MWh_elec; discharge gives fout)
13
+ So an H2 storage shifts TWO balance RHS entries in fixed proportion -- which the sampling curve build
14
+ handles directly (set both entries, solve). This is the correctness-first engine; a parametric-simplex
15
+ speed lever (a direction trace through the coupled LP) is the later analogue of `csimplex` for elec.
16
+
17
+ `MrSpatial` is the persistent hourly coupled LP (cost_of); `decompose_mr` runs the damped Gauss-Seidel /
18
+ Jacobi sweeps and returns (best x[S,T], history). Validate against `mr.solve_mr_monolith`.
19
+ """
20
+ import time
21
+
22
+ import numpy as np
23
+
24
+ from . import Pycplfunctionvec
25
+ from .elec import _curve_cost_sum, _prof, _safe_pycpl, _soc_clamp
26
+
27
+ try:
28
+ import highspy
29
+ except Exception: # pragma: no cover
30
+ highspy = None
31
+
32
+
33
+ class MrSpatial:
34
+ """Persistent HiGHS hourly coupled elec+H2 network LP. Columns: p[C] (gens / conversions / imports),
35
+ shed[M], curtail[M] (elec only -- H2 spill forbidden), flow_e[Le], flow_h[Lh]. Rows: balance[M].
36
+ Storage enters only as a per-node net-load shift: RHS[m] = demand[m] + sum_s Ladd_s(x_s)[m]."""
37
+
38
+ def __init__(self, case):
39
+ self.M = M = len(case["nodes"]); self.T = case["T"]
40
+ cols = list(case["cols"]); self.C = C = len(cols)
41
+ self.voll = float(case["voll"])
42
+ self.costC = np.array([c["cost"] for c in cols]) if C else np.zeros(0)
43
+ self.capC = (np.vstack([np.broadcast_to(np.asarray(c["cap"], float), (self.T,)) for c in cols])
44
+ if C else np.zeros((0, self.T)))
45
+ le = list(case["lines_e"]); lh = list(case["lines_h"]); self.Le = Le = len(le); self.Lh = Lh = len(lh)
46
+ curt_ok = case["curt_ok"]
47
+ h = highspy.Highs(); h.setOptionValue("output_flag", False)
48
+ h.setOptionValue("presolve", "off"); h.setOptionValue("solver", "simplex")
49
+ nx = C + M + M + Le + Lh
50
+ self.op, self.osh, self.ocu, self.ofe, self.ofh = 0, C, C + M, C + 2 * M, C + 2 * M + Le
51
+ cost = np.concatenate([self.costC, np.full(M, self.voll), np.zeros(M), np.zeros(Le + Lh)])
52
+ lb = np.zeros(nx); ub = np.full(nx, highspy.kHighsInf)
53
+ for m in range(M):
54
+ if not bool(curt_ok[m]):
55
+ ub[self.ocu + m] = 0.0 # H2 (and any non-curtailable) node: no spill
56
+ for l, (a, b, f, rv) in enumerate(le):
57
+ lb[self.ofe + l] = -float(rv); ub[self.ofe + l] = float(f)
58
+ for l, (a, b, f, rv) in enumerate(lh):
59
+ lb[self.ofh + l] = -float(rv); ub[self.ofh + l] = float(f)
60
+ h.addVars(nx, lb, ub)
61
+ h.changeColsCost(nx, np.arange(nx, dtype=np.int32), cost)
62
+ per_node = [[] for _ in range(M)] # (col, coeff) touching each balance
63
+ for ci, c in enumerate(cols):
64
+ for nd, cf in c["coeffs"].items():
65
+ per_node[int(nd)].append((ci, float(cf)))
66
+ for m in range(M):
67
+ idx = [self.op + ci for ci, _cf in per_node[m]] + [self.osh + m, self.ocu + m]
68
+ val = [cf for _ci, cf in per_node[m]] + [1.0, -1.0]
69
+ for l, (a, b, f, rv) in enumerate(le):
70
+ if b == m: idx.append(self.ofe + l); val.append(1.0)
71
+ if a == m: idx.append(self.ofe + l); val.append(-1.0)
72
+ for l, (a, b, f, rv) in enumerate(lh):
73
+ if b == m: idx.append(self.ofh + l); val.append(1.0)
74
+ if a == m: idx.append(self.ofh + l); val.append(-1.0)
75
+ h.addRow(0.0, 0.0, len(idx), np.array(idx, np.int32), np.array(val, float))
76
+ self.h = h
77
+
78
+ def set_hour(self, tt):
79
+ if self.C:
80
+ cols = np.arange(self.op, self.op + self.C, dtype=np.int32)
81
+ self.h.changeColsBounds(self.C, cols, np.zeros(self.C), self.capC[:, tt].copy())
82
+
83
+ def set_rhs(self, net):
84
+ for m in range(self.M):
85
+ self.h.changeRowBounds(m, float(net[m]), float(net[m]))
86
+
87
+ def cost_of(self, net, tt):
88
+ self.set_hour(tt); self.set_rhs(net); self.h.run(); return float(self.h.getObjectiveValue())
89
+
90
+
91
+ def mr_stor_arrays(case):
92
+ stor = list(case["storages"]); S = len(stor); T = case["T"]
93
+ Pcha = np.array([s["Pcha"] for s in stor]); Pdis = np.array([s["Pdis"] for s in stor])
94
+ E = np.array([s["E"] for s in stor]); init = np.array([s["init"] for s in stor])
95
+ inflow = np.vstack([np.asarray(s["inflow"], float) for s in stor]) if S else np.zeros((0, T))
96
+ contrib = [_contrib(s) for s in stor]
97
+ return stor, S, Pcha, Pdis, E, init, inflow, contrib
98
+
99
+
100
+ def _contrib(s):
101
+ """The (node, charge_coeff, discharge_coeff) grid-load contributions of one storage."""
102
+ c = [(int(s["node"]), float(s["cin"]), float(s["fout"]))]
103
+ if float(s["paras"]) > 0 and int(s["node_e"]) >= 0:
104
+ c.append((int(s["node_e"]), float(s["paras"]), 0.0)) # parasitic elec on charge only
105
+ return c
106
+
107
+
108
+ def _ladd(cch, cdis, xv):
109
+ return cch * xv if xv >= 0 else cdis * xv
110
+
111
+
112
+ def levels_from_x(case, x):
113
+ """Reconstruct each storage's level trajectory: init + cumsum(inflow + x)."""
114
+ _, S, _, _, _, init, inflow, _ = mr_stor_arrays(case)
115
+ return init[:, None] + np.cumsum(inflow + x, axis=1) if S else np.zeros((0, case["T"]))
116
+
117
+
118
+ def decompose_mr(case, sweeps=20, theta=0.5, mono=None, nsamp=15, pad=1.03, prune_tol=1e-3,
119
+ jacobi=False, engine="sample", curve_threads=1, slope_merge=0.0, fast_cost=True,
120
+ _profile=None):
121
+ """Damped Gauss-Seidel (or Jacobi) between the coupled spatial LP and the per-storage DP.
122
+
123
+ engine='sample' builds each hour's injection-cost curve by nsamp warm HiGHS solves (setting every
124
+ balance the storage touches, reading the summed duals). engine='csimplex' traces the EXACT curve
125
+ with the C++ parametric simplex generalised to a RHS *direction* (batch_curves_dir): a storage's
126
+ control shifts several balances in fixed proportion, and the proportions DIFFER between charge and
127
+ discharge (parasitic elec on charge only), so per storage and sweep it runs TWO half-traces from
128
+ one HiGHS seed -- the discharge direction over x in [-Pdis*pad, 0], the negated charge direction
129
+ over theta = -x in [-Pcha*pad, 0], reflected back -- and stitches them at x=0 (the slopes must be
130
+ non-decreasing across the join, which convex duality guarantees up to roundoff; a genuinely
131
+ non-convex join falls back to the sampling build for that storage-update and is counted in
132
+ _profile['mr_kink_fallback']). curve_threads>1 hour-chunks each half-trace (exactness-preserving).
133
+
134
+ fast_cost (csimplex + Gauss-Seidel only): the LAST-updated storage's stitched curve IS the exact
135
+ system cost as a function of that storage's control with the others at their end-of-sweep values,
136
+ so the sweep cost is read off the curve with NO LP solves (same lever as elec.decompose; guarded
137
+ by curve truncation and the control leaving the traced domain, where it falls back to cost_of).
138
+ As in elec, skipping the T monitoring solves also stops them nudging the shared HiGHS basis, so a
139
+ fast_cost run can follow a DIFFERENT equal-cost degenerate best-iterate path than a cost_of run;
140
+ the read-off value itself is exact at the committed state (pinned by the package test).
141
+
142
+ Returns (best-cost x[S,T], history rows (sweep, cost, gap%, cumsec))."""
143
+ M = len(case["nodes"]); T = case["T"]; dem = case["demand"]
144
+ stor, S, Pcha, Pdis, E, init, inflow, contrib = mr_stor_arrays(case)
145
+ Acc = np.cumsum(inflow, axis=1) if S else np.zeros((0, T))
146
+ Clo = -init[:, None] - Acc; Chi = E[:, None] - init[:, None] - Acc
147
+ grids = [np.unique(np.concatenate([np.linspace(-Pdis[s] * pad, Pcha[s] * pad, nsamp), [0.0]]))
148
+ for s in range(S)]
149
+ _pmoins = [(-Pdis[s] * np.ones(T)).tolist() for s in range(S)]
150
+ _pplus = [(Pcha[s] * np.ones(T)).tolist() for s in range(S)]
151
+ _clo = [Clo[s].tolist() for s in range(S)]; _chi = [Chi[s].tolist() for s in range(S)]
152
+ if engine == "csimplex":
153
+ from .param_simplex import batch_curves_dir, build_hour_lp_mr, status_from_highs
154
+ _A0, _c0, _lb0, _ub0, _b00, _off = build_hour_lp_mr(case, 0)
155
+ _capC = np.vstack([np.broadcast_to(np.asarray(col["cap"], float), (T,)) for col in case["cols"]])
156
+ _ubdyn = np.ascontiguousarray(_capC.T, np.float64) # [T, C] per-hour caps of the C columns
157
+ _dc = np.zeros((S, M)); _dd = np.zeros((S, M)) # per-storage charge/discharge directions
158
+ for s in range(S):
159
+ for (nd, cch, cdis) in contrib[s]:
160
+ _dc[s, nd] += cch; _dd[s, nd] += cdis
161
+ sp = MrSpatial(case); h = sp.h
162
+ x = np.zeros((S, T)); hist = []; t0 = time.perf_counter(); best = (np.inf, None)
163
+ _cc = {"ok": False} # last-updated storage's stitched curve (fast_cost)
164
+ for s in range(S): # feasible baseline (see elec.decompose)
165
+ x[s], _ = _soc_clamp(x[s], Pdis[s], Pcha[s], Clo[s], Chi[s])
166
+
167
+ def net_col(tt, Xcol): # full net load at hour tt (all storages)
168
+ nl = dem[:, tt].copy()
169
+ for s in range(S):
170
+ for (nd, cch, cdis) in contrib[s]:
171
+ nl[nd] += _ladd(cch, cdis, Xcol[s])
172
+ return nl
173
+
174
+ def _curves_cs(s, X, capture=False):
175
+ """Exact curves for storage s: two direction half-traces stitched at the x=0 kink. Returns a
176
+ filled Pycplfunctionvec, or raises ValueError on a (roundoff-level rare) non-convex join.
177
+ capture=True stores the stitched (slopes, breaks, phi0, nseg) into _cc for fast_cost."""
178
+ node_load = dem.copy() # base: everyone except s, s at 0
179
+ for s2 in range(S):
180
+ if s2 == s:
181
+ continue
182
+ for (nd, cch, cdis) in contrib[s2]:
183
+ node_load[nd] = node_load[nd] + np.where(X[s2] >= 0, cch, cdis) * X[s2]
184
+ b0_all = np.ascontiguousarray(node_load.T, np.float64)
185
+ sp.set_hour(0); sp.set_rhs(b0_all[0])
186
+ _t = time.perf_counter(); h.run(); _prof(_profile, "seed", _t)
187
+ st = np.ascontiguousarray(status_from_highs(h), np.int32)
188
+ _t = time.perf_counter()
189
+ have_d = Pdis[s] > 0; have_c = Pcha[s] > 0
190
+ if have_d: # discharge: b(x) = b0 + x*dd, x in [lo,0]
191
+ sl_d, br_d, p0_d, ns_d, _ = batch_curves_dir(_A0, _c0, _lb0, _ub0, b0_all, _ubdyn,
192
+ st.copy(), _dd[s], float(-Pdis[s] * pad), T,
193
+ nthreads=curve_threads)
194
+ od = np.concatenate([[0], np.cumsum(ns_d)])
195
+ if have_c: # charge via theta=-x: b = b0 + theta*(-dc)
196
+ sl_c, br_c, p0_c, ns_c, _ = batch_curves_dir(_A0, _c0, _lb0, _ub0, b0_all, _ubdyn,
197
+ st.copy(), -_dc[s], float(-Pcha[s] * pad), T,
198
+ nthreads=curve_threads)
199
+ oc = np.concatenate([[0], np.cumsum(ns_c)])
200
+ _prof(_profile, "curve", _t)
201
+ _t = time.perf_counter()
202
+ SL = []; BR = []; P0 = np.empty(T); NS = np.empty(T, np.int32)
203
+ for tt in range(T):
204
+ if have_d:
205
+ sd = sl_d[od[tt]:od[tt + 1]]; bd = br_d[od[tt]:od[tt + 1]]
206
+ if have_c: # reflect theta pieces to x (reverse+negate)
207
+ sc = sl_c[oc[tt]:oc[tt + 1]]; bc = br_c[oc[tt]:oc[tt + 1]]
208
+ ss_c = -sc[::-1] # ascending in x
209
+ xs_c = np.concatenate([[0.0], -bc[:0:-1]]) # left ends: 0, -bc[-1], ..., -bc[1]
210
+ if have_d and have_c:
211
+ if ss_c[0] < sd[-1] - 1e-6: # non-convex join (should not happen)
212
+ raise ValueError(f"non-convex kink join at hour {tt}")
213
+ if ss_c[0] <= sd[-1] + 1e-9: # smooth join: merge the zero-jump piece
214
+ ss_c = ss_c[1:]; xs_c = xs_c[1:]
215
+ s_all = np.concatenate([sd, ss_c]); b_all = np.concatenate([bd, xs_c])
216
+ p0t = p0_d[tt]
217
+ elif have_d: # discharge-only (MR reservoir)
218
+ s_all, b_all, p0t = sd, bd, p0_d[tt]
219
+ else: # charge-only: anchor at phi(0)
220
+ widths = np.diff(np.concatenate([bc, [0.0]]))
221
+ p0t = p0_c[tt] + float(sc @ widths)
222
+ s_all, b_all = ss_c, xs_c
223
+ if slope_merge > 0 and len(s_all) > 1: # optional coarsening: merge pieces whose
224
+ keep = [0]; last = s_all[0] # slope rise is below slope_merge EUR/MWh
225
+ for i in range(1, len(s_all)): # (the sampled engine's implicit smoothing,
226
+ if s_all[i] > last + slope_merge: # here applied to EXACT anchor points)
227
+ keep.append(i); last = s_all[i]
228
+ if len(keep) < len(s_all):
229
+ s_all = s_all[keep]; b_all = b_all[keep]
230
+ SL.append(s_all); BR.append(b_all); P0[tt] = p0t; NS[tt] = len(s_all)
231
+ slf = np.ascontiguousarray(np.concatenate(SL), np.float64)
232
+ brf = np.ascontiguousarray(np.concatenate(BR), np.float64)
233
+ p0f = np.ascontiguousarray(P0, np.float64)
234
+ nsf = np.ascontiguousarray(NS, np.int32)
235
+ vec = Pycplfunctionvec()
236
+ vec.push_batched(slf, brf, p0f, nsf)
237
+ if capture: # the sweep-cost curve (fast_cost)
238
+ trunc = ((have_d and int(ns_d.max()) >= 31) or (have_c and int(ns_c.max()) >= 31))
239
+ _cc.update(ok=True, s=s, sl=slf, br=brf, phi0=p0f, nseg=nsf, trunc=trunc,
240
+ xlo=float(-Pdis[s] * pad), xhi=float(Pcha[s] * pad))
241
+ _prof(_profile, "build", _t)
242
+ return vec
243
+
244
+ def update(s, X):
245
+ vec = None
246
+ if engine == "csimplex":
247
+ try: # fast_cost: capture the LAST GS update's
248
+ cap = fast_cost and not jacobi and s == S - 1 # curve (others then at end-of-sweep x)
249
+ vec = _curves_cs(s, X, capture=cap)
250
+ except Exception: # NEVER silent: count + sample this update
251
+ if _profile is not None:
252
+ _profile["mr_kink_fallback"] = _profile.get("mr_kink_fallback", 0) + 1
253
+ vec = None
254
+ if vec is None:
255
+ vec = Pycplfunctionvec(); xg = grids[s]
256
+ for tt in range(T):
257
+ nl = dem[:, tt].copy() # base: everyone except s
258
+ for s2 in range(S):
259
+ if s2 == s:
260
+ continue
261
+ for (nd, cch, cdis) in contrib[s2]:
262
+ nl[nd] += _ladd(cch, cdis, X[s2, tt])
263
+ phis = []; slopes = []
264
+ for xv in xg:
265
+ nlx = nl.copy()
266
+ for (nd, cch, cdis) in contrib[s]:
267
+ nlx[nd] += _ladd(cch, cdis, xv)
268
+ sp.set_hour(tt); sp.set_rhs(nlx); h.run()
269
+ phis.append(float(h.getObjectiveValue()))
270
+ rd = h.getSolution().row_dual
271
+ slopes.append(sum(rd[nd] * (cch if xv >= 0 else cdis) for (nd, cch, cdis) in contrib[s]))
272
+ vec.push_back(_safe_pycpl(xg, np.asarray(phis), np.asarray(slopes)))
273
+ _t = time.perf_counter()
274
+ if prune_tol and prune_tol > 0:
275
+ xnew = np.asarray(vec.OptimMargIntPruned(_pmoins[s], _pplus[s], _clo[s], _chi[s], float(prune_tol)))
276
+ else:
277
+ xnew = np.asarray(vec.OptimMargInt(_pmoins[s], _pplus[s], _clo[s], _chi[s]))
278
+ _prof(_profile, "dp", _t)
279
+ xnew, _slack = _soc_clamp(xnew, Pdis[s], Pcha[s], Clo[s], Chi[s])
280
+ return xnew
281
+
282
+ for it in range(sweeps):
283
+ _cc["ok"] = False
284
+ if jacobi:
285
+ X = x.copy()
286
+ xns = [update(s, X) for s in range(S)]
287
+ for s in range(S):
288
+ x[s] = (1 - theta) * X[s] + theta * xns[s]
289
+ else:
290
+ for s in range(S):
291
+ x[s] = (1 - theta) * x[s] + theta * update(s, x)
292
+ _t = time.perf_counter()
293
+ use_curve = _cc["ok"] and not _cc.get("trunc", False)
294
+ if use_curve: # the traced curve only covers its own
295
+ _xs = x[_cc["s"]] # x-domain; outside it would extrapolate
296
+ if _xs.max() > _cc["xhi"] + 1.0 or _xs.min() < _cc["xlo"] - 1.0:
297
+ use_curve = False
298
+ if use_curve: # sweep cost off the exact curve, no LP
299
+ c = _curve_cost_sum(_cc["sl"], _cc["br"], _cc["phi0"], _cc["nseg"], x[_cc["s"]])
300
+ else:
301
+ c = sum(sp.cost_of(net_col(tt, x[:, tt]), tt) for tt in range(T))
302
+ _prof(_profile, "cost", _t)
303
+ if c < best[0]:
304
+ best = (float(c), x.copy())
305
+ gap = 100.0 * (c - mono) / mono if mono else np.nan
306
+ hist.append((it, float(c), gap, time.perf_counter() - t0))
307
+ return best[1], hist
@@ -27,6 +27,10 @@ _lib.batch_curves.restype = ctypes.c_int
27
27
  _lib.batch_curves.argtypes = [_dp, _dp, _dp, _dp, _dp, _dp, ctypes.c_int, ctypes.c_int, ctypes.c_int,
28
28
  ctypes.c_int, _ip, ctypes.c_int, ctypes.c_double, ctypes.c_double,
29
29
  ctypes.c_double, _dp, _dp, _dp, _ip, ctypes.c_int, _ip, ctypes.c_int]
30
+ _lib.batch_curves_dir.restype = ctypes.c_int
31
+ _lib.batch_curves_dir.argtypes = [_dp, _dp, _dp, _dp, _dp, _dp, ctypes.c_int, ctypes.c_int, ctypes.c_int,
32
+ ctypes.c_int, _ip, _dp, ctypes.c_double,
33
+ _dp, _dp, _dp, _ip, ctypes.c_int, _ip, ctypes.c_int]
30
34
  _BIG = 1e30
31
35
 
32
36
 
@@ -53,6 +57,38 @@ def build_hour_lp(case, tt):
53
57
  return A, c, lb, ub, case["demand"][:, tt].copy(), (og, os_, oc, of, G, N, L)
54
58
 
55
59
 
60
+ def build_hour_lp_mr(case, tt):
61
+ """Dense hourly COUPLED multi-resource LP (mirrors mr_decompose.MrSpatial): columns p[C]
62
+ (gens / conversions / imports; a conversion column touches TWO balances through its `coeffs`),
63
+ shed[M], curt[M] (bounded to 0 where curt_ok is False -- H2 spill forbidden), flow_e[Le],
64
+ flow_h[Lh]; rows = the M balances. Returns A[M,nx], c, lb, ub, b0 and offsets."""
65
+ M = len(case["nodes"]); T = case["T"]
66
+ cols = list(case["cols"]); C = len(cols)
67
+ le = list(case["lines_e"]); lh = list(case["lines_h"]); Le, Lh = len(le), len(lh)
68
+ voll = float(case["voll"]); curt_ok = case["curt_ok"]
69
+ nx = C + M + M + Le + Lh
70
+ op, osh, ocu, ofe, ofh = 0, C, C + M, C + 2 * M, C + 2 * M + Le
71
+ c = np.zeros(nx); lb = np.zeros(nx); ub = np.full(nx, np.inf)
72
+ A = np.zeros((M, nx))
73
+ for ci, col in enumerate(cols):
74
+ c[op + ci] = float(col["cost"])
75
+ ub[op + ci] = float(np.broadcast_to(np.asarray(col["cap"], float), (T,))[tt])
76
+ for nd, cf in col["coeffs"].items():
77
+ A[int(nd), op + ci] = float(cf)
78
+ for m_ in range(M):
79
+ c[osh + m_] = voll
80
+ A[m_, osh + m_] = 1.0; A[m_, ocu + m_] = -1.0
81
+ if not bool(curt_ok[m_]):
82
+ ub[ocu + m_] = 0.0
83
+ for l, (a, b, f, rv) in enumerate(le):
84
+ lb[ofe + l] = -float(rv); ub[ofe + l] = float(f)
85
+ A[b, ofe + l] += 1.0; A[a, ofe + l] -= 1.0
86
+ for l, (a, b, f, rv) in enumerate(lh):
87
+ lb[ofh + l] = -float(rv); ub[ofh + l] = float(f)
88
+ A[b, ofh + l] += 1.0; A[a, ofh + l] -= 1.0
89
+ return A, c, lb, ub, case["demand"][:, tt].copy(), (op, osh, ocu, ofe, ofh, C, M, Le, Lh)
90
+
91
+
56
92
  def status_from_highs(h):
57
93
  """Map HiGHS col_status -> {0 basic, 1 lower, 2 upper} for the structural columns."""
58
94
  import highspy
@@ -154,3 +190,33 @@ def batch_curves(A, c, lb, ub_base, b0_all, ubgen_all, status_io, node, lo, T, m
154
190
  if tot < 0:
155
191
  raise RuntimeError(f"batch_curves error {tot}")
156
192
  return slopes[:tot], breaks[:tot], phi0, nseg, tp.value
193
+
194
+
195
+ def batch_curves_dir(A, c, lb, ub_base, b0_all, ubdyn_all, status_io, dvec, lo, T,
196
+ max_seg_per_hour=32, nthreads=1):
197
+ """DIRECTION trace: all T hourly curves of phi(theta) = LP objective at b(theta) = b0 + theta*dvec,
198
+ theta in [lo, 0], in ONE C++ call carrying the basis (curve slope = y . dvec). The multi-resource
199
+ engine calls this twice per storage -- discharge direction over [-Pdis*pad, 0], NEGATED charge
200
+ direction over [-Pcha*pad, 0] (reflected by the caller) -- and stitches at x=0. b0_all: [T,m]
201
+ per-hour rhs at x=0; ubdyn_all: [T,G] per-hour caps of the G leading columns. Returns
202
+ (slopes_flat, breaks_flat, phi0[T], nseg[T], total_reopt_pivots); slice by prefix sums of nseg.
203
+ nthreads>1 traces contiguous hour-chunks concurrently (exactness-preserving, as batch_curves)."""
204
+ m, nx = A.shape; G = ubdyn_all.shape[1]
205
+ A = np.ascontiguousarray(A, np.float64); c = np.ascontiguousarray(c, np.float64)
206
+ lb = np.ascontiguousarray(lb, np.float64)
207
+ ub_base = np.ascontiguousarray(np.where(np.isinf(ub_base), _BIG, ub_base), np.float64)
208
+ b0_all = np.ascontiguousarray(b0_all, np.float64); ubdyn_all = np.ascontiguousarray(ubdyn_all, np.float64)
209
+ dvec = np.ascontiguousarray(dvec, np.float64)
210
+ assert dvec.shape == (m,)
211
+ assert status_io.dtype == np.int32 and status_io.flags["C_CONTIGUOUS"]
212
+ max_total = T * max_seg_per_hour
213
+ slopes = np.zeros(max_total); breaks = np.zeros(max_total)
214
+ phi0 = np.zeros(T); nseg = np.zeros(T, np.int32); tp = ctypes.c_int()
215
+ P = lambda a: a.ctypes.data_as(_dp)
216
+ tot = _lib.batch_curves_dir(P(A), P(c), P(lb), P(ub_base), P(b0_all), P(ubdyn_all), m, nx, G, T,
217
+ status_io.ctypes.data_as(_ip), P(dvec), float(lo),
218
+ P(slopes), P(breaks), P(phi0), nseg.ctypes.data_as(_ip), max_total,
219
+ ctypes.byref(tp), int(nthreads))
220
+ if tot < 0:
221
+ raise RuntimeError(f"batch_curves_dir error {tot}")
222
+ return slopes[:tot], breaks[:tot], phi0, nseg, tp.value
@@ -0,0 +1,190 @@
1
+ """Engine tests for the multi-resource (elec + H2) decomposition (FLToptim.mr_decompose), against a
2
+ self-contained HiGHS coupled monolith (tests.cases.solve_mr_monolith).
3
+
4
+ The synthetic case couples the two balances the way the real TYNDP one does: H2 demand is served by
5
+ electrolysis (consumes 1.53 MWh elec / MWh H2), H2 nodes forbid spill, and the H2 store's charge draws
6
+ a parasitic electricity load -- so one storage shifts TWO balance RHS entries in fixed proportion.
7
+ The sampling engine prices that through the summed duals; the csimplex engine traces the exact curve
8
+ along the storage's RHS DIRECTIONS (batch_curves_dir), one half-trace per side of the charge/discharge
9
+ kink, stitched at x=0.
10
+ """
11
+ import numpy as np
12
+ import pytest
13
+
14
+ pytest.importorskip("highspy")
15
+
16
+ from FLToptim.mr_decompose import MrSpatial, decompose_mr, levels_from_x, mr_stor_arrays
17
+ from FLToptim.param_simplex import batch_curves_dir, build_hour_lp_mr, status_from_highs
18
+ from FLToptim.tests.cases import mr_case, solve_mr_monolith
19
+
20
+
21
+ @pytest.fixture(scope="module")
22
+ def case():
23
+ return mr_case(T=72)
24
+
25
+
26
+ @pytest.fixture(scope="module")
27
+ def mono(case):
28
+ return solve_mr_monolith(case)
29
+
30
+
31
+ def test_spatial_matches_monolith_without_storage():
32
+ """Exactness anchor: with no storage the coupled hourly LP sums to the coupled monolith."""
33
+ c = mr_case(T=48)
34
+ c = dict(c); c["storages"] = []
35
+ mono0 = solve_mr_monolith(c)
36
+ sp = MrSpatial(c)
37
+ tot = sum(sp.cost_of(c["demand"][:, tt], tt) for tt in range(c["T"]))
38
+ assert tot == pytest.approx(mono0, rel=1e-9)
39
+
40
+
41
+ def test_case_is_discriminating(case, mono):
42
+ """Anti-vacuity guard: the storages must be worth something in the coupled case (here ~13%), so a
43
+ broken engine cannot pass the convergence tests by leaving them idle."""
44
+ c0 = dict(case); c0["storages"] = []
45
+ m0 = solve_mr_monolith(c0)
46
+ assert 100.0 * (m0 - mono) / mono > 2.0
47
+
48
+
49
+ def test_mr_decompose_matches_monolith(case, mono):
50
+ """Gauss-Seidel sampling decomposition converges to the coupled monolith and stays feasible."""
51
+ x, hist = decompose_mr(case, sweeps=12, theta=0.5, mono=mono, nsamp=11, prune_tol=1e-3)
52
+ gap = min(g for _, _, g, _ in hist)
53
+ assert gap < 0.05, f"MR decomposition gap too large: {gap:+.4f}%"
54
+
55
+ stor, S, Pcha, Pdis, E, init, inflow, contrib = mr_stor_arrays(case)
56
+ lev = levels_from_x(case, x)
57
+ for s in range(S):
58
+ assert x[s].max() <= Pcha[s] + 1e-6 and x[s].min() >= -Pdis[s] - 1e-6, f"storage {s} power bound"
59
+ assert lev[s].max() <= E[s] + 1e-6 and lev[s].min() >= -1e-6, f"storage {s} level bound"
60
+
61
+
62
+ def test_mr_decompose_jacobi_converges(case, mono):
63
+ """The Jacobi variant (frozen-snapshot updates) converges under damping too."""
64
+ x, hist = decompose_mr(case, sweeps=14, theta=0.5, mono=mono, nsamp=11, jacobi=True)
65
+ gap = min(g for _, _, g, _ in hist)
66
+ assert gap < 0.2, f"MR Jacobi gap too large: {gap:+.4f}%"
67
+
68
+
69
+ def _pwl_value(sl, br, p0, off, t, x):
70
+ """Hour t's traced PWL at x (left-end breaks, value p0 at br[0], last piece to +inf)."""
71
+ o0, o1 = off[t], off[t + 1]
72
+ s, b = sl[o0:o1], br[o0:o1]
73
+ v = p0[t]; prev = b[0]
74
+ for i in range(len(s)):
75
+ hi = b[i + 1] if i + 1 < len(s) else np.inf
76
+ top = min(max(x, prev), hi)
77
+ v += s[i] * (top - prev); prev = max(prev, top)
78
+ if x <= hi:
79
+ break
80
+ return v
81
+
82
+
83
+ def test_mr_direction_trace_exact_vs_lp(case):
84
+ """The direction trace equals a direct LP solve on BOTH sides of every storage's kink, and the
85
+ two half-traces agree at x=0 -- including the H2 store whose charge direction couples the H2 AND
86
+ elec balances (the total-unimodularity-breaking case the periodic refactor guards)."""
87
+ sp = MrSpatial(case); h = sp.h; T = case["T"]; M = len(case["nodes"])
88
+ stor, S, Pcha, Pdis, E, init, inflow, contrib = mr_stor_arrays(case)
89
+ A0, c0, lb0, ub0, _b0, _off = build_hour_lp_mr(case, 0)
90
+ capC = np.vstack([np.broadcast_to(np.asarray(col["cap"], float), (T,)) for col in case["cols"]])
91
+ ubdyn = np.ascontiguousarray(capC.T, np.float64)
92
+ dem = case["demand"]; PAD = 1.03
93
+ rng = np.random.default_rng(0)
94
+ for s in range(S):
95
+ dc = np.zeros(M); dd = np.zeros(M)
96
+ for (nd, cch, cdis) in contrib[s]:
97
+ dc[nd] += cch; dd[nd] += cdis
98
+ b0_all = np.ascontiguousarray(dem.T, np.float64) # every storage at x=0
99
+ sp.set_hour(0); sp.set_rhs(b0_all[0]); h.run()
100
+ st = np.ascontiguousarray(status_from_highs(h), np.int32)
101
+ tr = {}
102
+ if Pdis[s] > 0:
103
+ sl, br, p0, ns, _ = batch_curves_dir(A0, c0, lb0, ub0, b0_all, ubdyn, st.copy(), dd,
104
+ -Pdis[s] * PAD, T)
105
+ tr["d"] = (sl, br, p0, np.concatenate([[0], np.cumsum(ns)]))
106
+ if Pcha[s] > 0:
107
+ sl, br, p0, ns, _ = batch_curves_dir(A0, c0, lb0, ub0, b0_all, ubdyn, st.copy(), -dc,
108
+ -Pcha[s] * PAD, T)
109
+ tr["c"] = (sl, br, p0, np.concatenate([[0], np.cumsum(ns)]))
110
+ worst = join = 0.0
111
+ for t in rng.choice(T, size=12, replace=False):
112
+ t = int(t)
113
+ for _ in range(4):
114
+ x = (float(rng.uniform(-Pdis[s] * 0.95, Pcha[s] * 0.95)) if Pcha[s] > 0
115
+ else -float(rng.uniform(0.0, Pdis[s] * 0.95)))
116
+ nlx = dem[:, t].copy()
117
+ for (nd, cch, cdis) in contrib[s]:
118
+ nlx[nd] += (cch if x >= 0 else cdis) * x
119
+ ref = sp.cost_of(nlx, t)
120
+ got = (_pwl_value(*tr["d"], t, x) if x <= 0 else _pwl_value(*tr["c"], t, -x))
121
+ worst = max(worst, abs(got - ref) / max(abs(ref), 1.0))
122
+ if "d" in tr and "c" in tr:
123
+ join = max(join, abs(_pwl_value(*tr["d"], t, 0.0) - _pwl_value(*tr["c"], t, 0.0)))
124
+ assert worst < 1e-9, f"storage {s}: direction trace vs LP, worst rel err {worst:.2e}"
125
+ assert join < 1e-6, f"storage {s}: half-traces disagree at x=0 by {join:.2e}"
126
+
127
+
128
+ def test_mr_decompose_csimplex_matches_monolith(case, mono):
129
+ """The exact-curve (direction-trace) engine converges to the coupled monolith, never falls back
130
+ to sampling, and stays feasible. It needs MORE damping than the sampling engine (theta~0.35 vs
131
+ 0.5): its curves carry every true breakpoint, so the per-storage best response is sharper.
132
+ Tolerance note: near the floor the GS trajectory is rounding-path dependent (macOS reaches
133
+ +0.003%, Linux +0.07% at the same sweep count -- same class as the elec degeneracy findings), so
134
+ the assertion uses the cross-platform envelope; the anti-vacuity guard keeps it discriminating
135
+ (idle storages would sit at ~13%)."""
136
+ prof = {}
137
+ x, hist = decompose_mr(case, sweeps=24, theta=0.35, mono=mono, engine="csimplex", _profile=prof)
138
+ gap = min(g for _, _, g, _ in hist)
139
+ assert gap < 0.1, f"MR csimplex gap too large: {gap:+.4f}%"
140
+ assert prof.get("mr_kink_fallback", 0) == 0, "csimplex engine fell back to sampling"
141
+ assert prof.get("curve", 0.0) > 0.0
142
+
143
+ stor, S, Pcha, Pdis, E, init, inflow, contrib = mr_stor_arrays(case)
144
+ lev = levels_from_x(case, x)
145
+ for s in range(S):
146
+ assert x[s].max() <= Pcha[s] + 1e-6 and x[s].min() >= -Pdis[s] - 1e-6, f"storage {s} power bound"
147
+ assert lev[s].max() <= E[s] + 1e-6 and lev[s].min() >= -1e-6, f"storage {s} level bound"
148
+
149
+
150
+ def test_mr_decompose_csimplex_jacobi_smoke(case, mono):
151
+ """csimplex + Jacobi (frozen snapshot) also converges under damping."""
152
+ x, hist = decompose_mr(case, sweeps=24, theta=0.3, mono=mono, engine="csimplex", jacobi=True)
153
+ gap = min(g for _, _, g, _ in hist)
154
+ assert gap < 0.2, f"MR csimplex Jacobi gap too large: {gap:+.4f}%"
155
+
156
+
157
+ def test_mr_fast_cost_reads_the_true_cost(case, mono, monkeypatch):
158
+ """The sweep cost read off the last stitched curve (fast_cost) equals a direct LP evaluation OF
159
+ THE SAME COMMITTED STATE, sweep by sweep. (Comparing two runs fast_cost on/off is NOT the right
160
+ invariant: the LP cost loop nudges the shared HiGHS basis between sweeps, so the two runs follow
161
+ different equal-cost degenerate paths -- the same artifact documented for elec.) The check
162
+ recomputes the LP total inside the cost call, reading the engine's committed x from the caller's
163
+ frame (CPython)."""
164
+ import inspect
165
+
166
+ import FLToptim.mr_decompose as mrd
167
+ from FLToptim.elec import _curve_cost_sum as real_ccs
168
+
169
+ sp = MrSpatial(case)
170
+ stor, S, Pcha, Pdis, E, init, inflow, contrib = mr_stor_arrays(case)
171
+ dem = case["demand"]; T = case["T"]
172
+ checked = []
173
+
174
+ def spy(sl, br, phi0, nseg, xvals):
175
+ got = real_ccs(sl, br, phi0, nseg, xvals)
176
+ x = inspect.currentframe().f_back.f_locals["x"]
177
+ lp = 0.0
178
+ for t in range(T):
179
+ nl = dem[:, t].copy()
180
+ for s2 in range(S):
181
+ for (nd, cch, cdis) in contrib[s2]:
182
+ nl[nd] += (cch if x[s2, t] >= 0 else cdis) * x[s2, t]
183
+ lp += sp.cost_of(nl, t)
184
+ assert got == pytest.approx(lp, rel=1e-9)
185
+ checked.append(1)
186
+ return got
187
+
188
+ monkeypatch.setattr(mrd, "_curve_cost_sum", spy)
189
+ decompose_mr(case, sweeps=4, theta=0.35, mono=mono, engine="csimplex", fast_cost=True)
190
+ assert len(checked) >= 3, "fast_cost curve path was not exercised"
@@ -20,8 +20,8 @@ import pytest
20
20
  pytest.importorskip("highspy")
21
21
 
22
22
  from FLToptim.elec import ElecSpatial, _ladd, _stor_arrays
23
- from FLToptim.param_simplex import (batch_curves, build_hour_lp, reopt_trace_data,
24
- status_from_highs, trace_curve_data)
23
+ from FLToptim.param_simplex import (batch_curves, batch_curves_dir, build_hour_lp,
24
+ reopt_trace_data, status_from_highs, trace_curve_data)
25
25
  from FLToptim.tests.cases import elec_case
26
26
 
27
27
  PAD = 1.03
@@ -170,6 +170,28 @@ def test_reopt_trace_data_carries_its_own_basis(case_res):
170
170
  assert _pwl_from_data(sl, br, phi0, float(x)) == pytest.approx(r, rel=1e-9), f"hour {tt}"
171
171
 
172
172
 
173
+ def test_batch_curves_dir_reproduces_the_node_trace(case_res):
174
+ """The DIRECTION trace with dvec = e_node must reproduce the classic single-node trace
175
+ BIT-FOR-BIT (same segments, breaks, values): batch_curves is the dvec=e_node special case of the
176
+ shared core, and the elec engine's historical output is pinned to it."""
177
+ case = case_res
178
+ sp = ElecSpatial(case); h = sp.h; T = case["T"]
179
+ _, S, Pcha, Pdis, E, eff, init, snode, inflow = _stor_arrays(case)
180
+ n = snode[0]
181
+ A0, c0, lb0, ub0, _b0, _off = build_hour_lp(case, 0)
182
+ capG = np.vstack([np.asarray(g[2], float) for g in case["gens"]])
183
+ ubgen = np.ascontiguousarray(capG.T, np.float64)
184
+ b0_all = np.ascontiguousarray(case["demand"].T, np.float64)
185
+ sp.set_hour(0); sp.set_rhs(b0_all[0]); h.run()
186
+ st = np.ascontiguousarray(status_from_highs(h), np.int32)
187
+ lo = -Pdis[0] * PAD
188
+ ref = batch_curves(A0, c0, lb0, ub0, b0_all, ubgen, st.copy(), n, lo, T, eff=1.0, shift=0.0)
189
+ dvec = np.zeros(case["N"]); dvec[n] = 1.0
190
+ got = batch_curves_dir(A0, c0, lb0, ub0, b0_all, ubgen, st.copy(), dvec, lo, T)
191
+ for a, b, name in zip(got, ref, ("slopes", "breaks", "phi0", "nseg", "pivots")):
192
+ np.testing.assert_array_equal(a, b, err_msg=f"direction trace differs on {name}")
193
+
194
+
173
195
  def test_batch_curves_battery_kink_exact_vs_lp(case_bat):
174
196
  """For an efficiency storage (eff<1) the trace spans charge AND discharge through the ladd kink at
175
197
  x=0; the reparametrised curve must match direct LP solves on both sides."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: FLToptim
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Fast Legendre-transform convex optimisation for energy-storage operation and spatial economic dispatch
5
5
  Author-email: Robin Girard <robin.girard@minesparis.psl.eu>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: FLToptim
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Fast Legendre-transform convex optimisation for energy-storage operation and spatial economic dispatch
5
5
  Author-email: Robin Girard <robin.girard@minesparis.psl.eu>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "FLToptim"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Fast Legendre-transform convex optimisation for energy-storage operation and spatial economic dispatch"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,183 +0,0 @@
1
- """Multi-resource (elec + H2) spatial-LP <-> storage-DP decomposition -- the M2 generalisation of the
2
- elec-only `elec.decompose`.
3
-
4
- A "node" is an (area, resource) pair; a column may touch two balances (a conversion consumes one resource
5
- and produces another); there are two transport layers. A storage's control x_s = its state-of-charge
6
- increment (pin - pout); the grid LOAD it adds is
7
- Ladd_s(x)[k] = cch_k * x (x >= 0, charging draws the grid)
8
- = cdis_k * x (x < 0, discharging supplies the grid)
9
- at each node k it touches, with (cch, cdis) per node:
10
- * elec reservoir at node n : [(n, 1, 1)] (lossless, no pumping)
11
- * H2 storage : [(h2, cin, fout), (elec, paras, 0)] (charge consumes cin MWh_H2 +
12
- paras MWh_elec; discharge gives fout)
13
- So an H2 storage shifts TWO balance RHS entries in fixed proportion -- which the sampling curve build
14
- handles directly (set both entries, solve). This is the correctness-first engine; a parametric-simplex
15
- speed lever (a direction trace through the coupled LP) is the later analogue of `csimplex` for elec.
16
-
17
- `MrSpatial` is the persistent hourly coupled LP (cost_of); `decompose_mr` runs the damped Gauss-Seidel /
18
- Jacobi sweeps and returns (best x[S,T], history). Validate against `mr.solve_mr_monolith`.
19
- """
20
- import time
21
-
22
- import numpy as np
23
-
24
- from . import Pycplfunctionvec
25
- from .elec import _safe_pycpl, _soc_clamp
26
-
27
- try:
28
- import highspy
29
- except Exception: # pragma: no cover
30
- highspy = None
31
-
32
-
33
- class MrSpatial:
34
- """Persistent HiGHS hourly coupled elec+H2 network LP. Columns: p[C] (gens / conversions / imports),
35
- shed[M], curtail[M] (elec only -- H2 spill forbidden), flow_e[Le], flow_h[Lh]. Rows: balance[M].
36
- Storage enters only as a per-node net-load shift: RHS[m] = demand[m] + sum_s Ladd_s(x_s)[m]."""
37
-
38
- def __init__(self, case):
39
- self.M = M = len(case["nodes"]); self.T = case["T"]
40
- cols = list(case["cols"]); self.C = C = len(cols)
41
- self.voll = float(case["voll"])
42
- self.costC = np.array([c["cost"] for c in cols]) if C else np.zeros(0)
43
- self.capC = (np.vstack([np.broadcast_to(np.asarray(c["cap"], float), (self.T,)) for c in cols])
44
- if C else np.zeros((0, self.T)))
45
- le = list(case["lines_e"]); lh = list(case["lines_h"]); self.Le = Le = len(le); self.Lh = Lh = len(lh)
46
- curt_ok = case["curt_ok"]
47
- h = highspy.Highs(); h.setOptionValue("output_flag", False)
48
- h.setOptionValue("presolve", "off"); h.setOptionValue("solver", "simplex")
49
- nx = C + M + M + Le + Lh
50
- self.op, self.osh, self.ocu, self.ofe, self.ofh = 0, C, C + M, C + 2 * M, C + 2 * M + Le
51
- cost = np.concatenate([self.costC, np.full(M, self.voll), np.zeros(M), np.zeros(Le + Lh)])
52
- lb = np.zeros(nx); ub = np.full(nx, highspy.kHighsInf)
53
- for m in range(M):
54
- if not bool(curt_ok[m]):
55
- ub[self.ocu + m] = 0.0 # H2 (and any non-curtailable) node: no spill
56
- for l, (a, b, f, rv) in enumerate(le):
57
- lb[self.ofe + l] = -float(rv); ub[self.ofe + l] = float(f)
58
- for l, (a, b, f, rv) in enumerate(lh):
59
- lb[self.ofh + l] = -float(rv); ub[self.ofh + l] = float(f)
60
- h.addVars(nx, lb, ub)
61
- h.changeColsCost(nx, np.arange(nx, dtype=np.int32), cost)
62
- per_node = [[] for _ in range(M)] # (col, coeff) touching each balance
63
- for ci, c in enumerate(cols):
64
- for nd, cf in c["coeffs"].items():
65
- per_node[int(nd)].append((ci, float(cf)))
66
- for m in range(M):
67
- idx = [self.op + ci for ci, _cf in per_node[m]] + [self.osh + m, self.ocu + m]
68
- val = [cf for _ci, cf in per_node[m]] + [1.0, -1.0]
69
- for l, (a, b, f, rv) in enumerate(le):
70
- if b == m: idx.append(self.ofe + l); val.append(1.0)
71
- if a == m: idx.append(self.ofe + l); val.append(-1.0)
72
- for l, (a, b, f, rv) in enumerate(lh):
73
- if b == m: idx.append(self.ofh + l); val.append(1.0)
74
- if a == m: idx.append(self.ofh + l); val.append(-1.0)
75
- h.addRow(0.0, 0.0, len(idx), np.array(idx, np.int32), np.array(val, float))
76
- self.h = h
77
-
78
- def set_hour(self, tt):
79
- if self.C:
80
- cols = np.arange(self.op, self.op + self.C, dtype=np.int32)
81
- self.h.changeColsBounds(self.C, cols, np.zeros(self.C), self.capC[:, tt].copy())
82
-
83
- def set_rhs(self, net):
84
- for m in range(self.M):
85
- self.h.changeRowBounds(m, float(net[m]), float(net[m]))
86
-
87
- def cost_of(self, net, tt):
88
- self.set_hour(tt); self.set_rhs(net); self.h.run(); return float(self.h.getObjectiveValue())
89
-
90
-
91
- def mr_stor_arrays(case):
92
- stor = list(case["storages"]); S = len(stor); T = case["T"]
93
- Pcha = np.array([s["Pcha"] for s in stor]); Pdis = np.array([s["Pdis"] for s in stor])
94
- E = np.array([s["E"] for s in stor]); init = np.array([s["init"] for s in stor])
95
- inflow = np.vstack([np.asarray(s["inflow"], float) for s in stor]) if S else np.zeros((0, T))
96
- contrib = [_contrib(s) for s in stor]
97
- return stor, S, Pcha, Pdis, E, init, inflow, contrib
98
-
99
-
100
- def _contrib(s):
101
- """The (node, charge_coeff, discharge_coeff) grid-load contributions of one storage."""
102
- c = [(int(s["node"]), float(s["cin"]), float(s["fout"]))]
103
- if float(s["paras"]) > 0 and int(s["node_e"]) >= 0:
104
- c.append((int(s["node_e"]), float(s["paras"]), 0.0)) # parasitic elec on charge only
105
- return c
106
-
107
-
108
- def _ladd(cch, cdis, xv):
109
- return cch * xv if xv >= 0 else cdis * xv
110
-
111
-
112
- def levels_from_x(case, x):
113
- """Reconstruct each storage's level trajectory: init + cumsum(inflow + x)."""
114
- _, S, _, _, _, init, inflow, _ = mr_stor_arrays(case)
115
- return init[:, None] + np.cumsum(inflow + x, axis=1) if S else np.zeros((0, case["T"]))
116
-
117
-
118
- def decompose_mr(case, sweeps=20, theta=0.5, mono=None, nsamp=15, pad=1.03, prune_tol=1e-3,
119
- jacobi=False, _profile=None):
120
- """Damped Gauss-Seidel (or Jacobi) between the coupled spatial LP and the per-storage DP, sampling
121
- engine. Returns (best-cost x[S,T], history rows (sweep, cost, gap%, cumsec))."""
122
- M = len(case["nodes"]); T = case["T"]; dem = case["demand"]
123
- stor, S, Pcha, Pdis, E, init, inflow, contrib = mr_stor_arrays(case)
124
- Acc = np.cumsum(inflow, axis=1) if S else np.zeros((0, T))
125
- Clo = -init[:, None] - Acc; Chi = E[:, None] - init[:, None] - Acc
126
- grids = [np.unique(np.concatenate([np.linspace(-Pdis[s] * pad, Pcha[s] * pad, nsamp), [0.0]]))
127
- for s in range(S)]
128
- _pmoins = [(-Pdis[s] * np.ones(T)).tolist() for s in range(S)]
129
- _pplus = [(Pcha[s] * np.ones(T)).tolist() for s in range(S)]
130
- _clo = [Clo[s].tolist() for s in range(S)]; _chi = [Chi[s].tolist() for s in range(S)]
131
- sp = MrSpatial(case); h = sp.h
132
- x = np.zeros((S, T)); hist = []; t0 = time.perf_counter(); best = (np.inf, None)
133
- for s in range(S): # feasible baseline (see elec.decompose)
134
- x[s], _ = _soc_clamp(x[s], Pdis[s], Pcha[s], Clo[s], Chi[s])
135
-
136
- def net_col(tt, Xcol): # full net load at hour tt (all storages)
137
- nl = dem[:, tt].copy()
138
- for s in range(S):
139
- for (nd, cch, cdis) in contrib[s]:
140
- nl[nd] += _ladd(cch, cdis, Xcol[s])
141
- return nl
142
-
143
- def update(s, X):
144
- vec = Pycplfunctionvec(); xg = grids[s]
145
- for tt in range(T):
146
- nl = dem[:, tt].copy() # base: everyone except s
147
- for s2 in range(S):
148
- if s2 == s:
149
- continue
150
- for (nd, cch, cdis) in contrib[s2]:
151
- nl[nd] += _ladd(cch, cdis, X[s2, tt])
152
- phis = []; slopes = []
153
- for xv in xg:
154
- nlx = nl.copy()
155
- for (nd, cch, cdis) in contrib[s]:
156
- nlx[nd] += _ladd(cch, cdis, xv)
157
- sp.set_hour(tt); sp.set_rhs(nlx); h.run()
158
- phis.append(float(h.getObjectiveValue()))
159
- rd = h.getSolution().row_dual
160
- slopes.append(sum(rd[nd] * (cch if xv >= 0 else cdis) for (nd, cch, cdis) in contrib[s]))
161
- vec.push_back(_safe_pycpl(xg, np.asarray(phis), np.asarray(slopes)))
162
- if prune_tol and prune_tol > 0:
163
- xnew = np.asarray(vec.OptimMargIntPruned(_pmoins[s], _pplus[s], _clo[s], _chi[s], float(prune_tol)))
164
- else:
165
- xnew = np.asarray(vec.OptimMargInt(_pmoins[s], _pplus[s], _clo[s], _chi[s]))
166
- xnew, _slack = _soc_clamp(xnew, Pdis[s], Pcha[s], Clo[s], Chi[s])
167
- return xnew
168
-
169
- for it in range(sweeps):
170
- if jacobi:
171
- X = x.copy()
172
- xns = [update(s, X) for s in range(S)]
173
- for s in range(S):
174
- x[s] = (1 - theta) * X[s] + theta * xns[s]
175
- else:
176
- for s in range(S):
177
- x[s] = (1 - theta) * x[s] + theta * update(s, x)
178
- c = sum(sp.cost_of(net_col(tt, x[:, tt]), tt) for tt in range(T))
179
- if c < best[0]:
180
- best = (float(c), x.copy())
181
- gap = 100.0 * (c - mono) / mono if mono else np.nan
182
- hist.append((it, float(c), gap, time.perf_counter() - t0))
183
- return best[1], hist
@@ -1,63 +0,0 @@
1
- """Engine tests for the multi-resource (elec + H2) decomposition (FLToptim.mr_decompose), against a
2
- self-contained HiGHS coupled monolith (tests.cases.solve_mr_monolith).
3
-
4
- The synthetic case couples the two balances the way the real TYNDP one does: H2 demand is served by
5
- electrolysis (consumes 1.53 MWh elec / MWh H2), H2 nodes forbid spill, and the H2 store's charge draws
6
- a parasitic electricity load -- so one storage shifts TWO balance RHS entries in fixed proportion,
7
- which is exactly what the sampling curve build must price through the summed duals.
8
- """
9
- import numpy as np
10
- import pytest
11
-
12
- pytest.importorskip("highspy")
13
-
14
- from FLToptim.mr_decompose import MrSpatial, decompose_mr, levels_from_x, mr_stor_arrays
15
- from FLToptim.tests.cases import mr_case, solve_mr_monolith
16
-
17
-
18
- @pytest.fixture(scope="module")
19
- def case():
20
- return mr_case(T=72)
21
-
22
-
23
- @pytest.fixture(scope="module")
24
- def mono(case):
25
- return solve_mr_monolith(case)
26
-
27
-
28
- def test_spatial_matches_monolith_without_storage():
29
- """Exactness anchor: with no storage the coupled hourly LP sums to the coupled monolith."""
30
- c = mr_case(T=48)
31
- c = dict(c); c["storages"] = []
32
- mono0 = solve_mr_monolith(c)
33
- sp = MrSpatial(c)
34
- tot = sum(sp.cost_of(c["demand"][:, tt], tt) for tt in range(c["T"]))
35
- assert tot == pytest.approx(mono0, rel=1e-9)
36
-
37
-
38
- def test_case_is_discriminating(case, mono):
39
- """Anti-vacuity guard: the storages must be worth something in the coupled case (here ~13%), so a
40
- broken engine cannot pass the convergence tests by leaving them idle."""
41
- c0 = dict(case); c0["storages"] = []
42
- m0 = solve_mr_monolith(c0)
43
- assert 100.0 * (m0 - mono) / mono > 2.0
44
-
45
-
46
- def test_mr_decompose_matches_monolith(case, mono):
47
- """Gauss-Seidel sampling decomposition converges to the coupled monolith and stays feasible."""
48
- x, hist = decompose_mr(case, sweeps=12, theta=0.5, mono=mono, nsamp=11, prune_tol=1e-3)
49
- gap = min(g for _, _, g, _ in hist)
50
- assert gap < 0.05, f"MR decomposition gap too large: {gap:+.4f}%"
51
-
52
- stor, S, Pcha, Pdis, E, init, inflow, contrib = mr_stor_arrays(case)
53
- lev = levels_from_x(case, x)
54
- for s in range(S):
55
- assert x[s].max() <= Pcha[s] + 1e-6 and x[s].min() >= -Pdis[s] - 1e-6, f"storage {s} power bound"
56
- assert lev[s].max() <= E[s] + 1e-6 and lev[s].min() >= -1e-6, f"storage {s} level bound"
57
-
58
-
59
- def test_mr_decompose_jacobi_converges(case, mono):
60
- """The Jacobi variant (frozen-snapshot updates) converges under damping too."""
61
- x, hist = decompose_mr(case, sweeps=14, theta=0.5, mono=mono, nsamp=11, jacobi=True)
62
- gap = min(g for _, _, g, _ in hist)
63
- assert gap < 0.2, f"MR Jacobi gap too large: {gap:+.4f}%"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes