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.
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/__init__.py +1 -1
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/csimplex/param_simplex.cpp +71 -14
- fltoptim-0.3.0/FLToptim/mr_decompose.py +307 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/param_simplex.py +66 -0
- fltoptim-0.3.0/FLToptim/tests/test_mr_decompose.py +190 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_param_simplex.py +24 -2
- {fltoptim-0.2.0 → fltoptim-0.3.0/FLToptim.egg-info}/PKG-INFO +1 -1
- {fltoptim-0.2.0/FLToptim.egg-info → fltoptim-0.3.0}/PKG-INFO +1 -1
- {fltoptim-0.2.0 → fltoptim-0.3.0}/pyproject.toml +1 -1
- fltoptim-0.2.0/FLToptim/mr_decompose.py +0 -183
- fltoptim-0.2.0/FLToptim/tests/test_mr_decompose.py +0 -63
- {fltoptim-0.2.0 → fltoptim-0.3.0}/CITATION.cff +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/Wrapper_FLToptim.cpp +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/Wrapper_FLToptim.pyx +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/convex_functions_tools.hpp +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/cplfunction.cpp +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/cplfunction.hpp +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/cplfunction_double.cpp +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/cplfunction_instantiations.cpp +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/cplfunction_instantiations.hpp +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/cpqfunction.cpp +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/cpqfunction.hpp +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/cpqfunction_double.cpp +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/cpqfunction_instantiations.cpp +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/cpqfunction_instantiations.hpp +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/csimplex/build.sh +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/curves.py +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/elec.py +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/elec_parallel.py +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/numeric_traits.hpp +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/parametric.py +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/__init__.py +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/cases.py +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/conftest.py +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_cpqfunction.py +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_curves.py +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_dispatch.py +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_elec_decompose.py +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_elec_parallel.py +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_highprec.py +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_known_bugs.py +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_optimisation.py +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_parametric.py +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_performance.py +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_pycpl_function.py +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_wrapper.py +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/tests/test_wrappers_more.py +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/with_inf.hpp +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim/wrappers.py +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim.egg-info/SOURCES.txt +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim.egg-info/dependency_links.txt +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim.egg-info/not-zip-safe +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim.egg-info/requires.txt +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/FLToptim.egg-info/top_level.txt +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/LICENSE +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/MANIFEST.in +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/README.md +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/pytest.ini +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/setup.cfg +0 -0
- {fltoptim-0.2.0 → fltoptim-0.3.0}/setup.py +0 -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
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
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
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
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();
|
|
335
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|