-
Notifications
You must be signed in to change notification settings - Fork 48
Feat: Support shared storage (multi‑feed stock) #2001
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/multi-commodity
Are you sure you want to change the base?
Changes from 9 commits
4ad215d
65fc268
ef7cf60
55ded46
d740c0d
8bd859f
6658803
d500052
09e9780
d4a15eb
26a1993
c98b178
358afb8
4932cf9
b8ff719
29785fa
cefe507
118587b
74b665f
fbcf2e5
4259ffa
bc3991a
aefaf0d
63b6bd7
0be435f
123f543
f02e2ee
5fb576e
cb110a9
b5bb77e
816eda7
1d5433f
eeffbf3
d8cab12
ba7b433
ea96b53
a229502
8190044
177154c
a110f0e
c7679ef
53d27c7
69b5e27
b95a594
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -721,3 +721,205 @@ def test_mixed_gas_and_electricity_assets(app, db): | |
| f"This ensures optimizer prioritizes filling battery early over idling. " | ||
| f"Ratio: {costs_data['prefer charging device 0 sooner'] / costs_data['prefer curtailing device 0 later']:.1f}×" | ||
| ) | ||
|
|
||
|
|
||
| def test_two_devices_shared_stock(app, db): | ||
| """ | ||
| Test scheduling two batteries sharing a single shared stock. | ||
| Each battery: 20→80 kWh (60 kWh increase). | ||
| Combined SoC in shared stock cannot exceed 100 kWh at any time. | ||
| """ | ||
| # ---- time | ||
| start = pd.Timestamp("2024-01-01T00:00:00+01:00") | ||
| end = pd.Timestamp("2024-01-02T00:00:00+01:00") | ||
| power_sensor_resolution = pd.Timedelta("15m") | ||
| soc_sensor_resolution = pd.Timedelta(0) | ||
|
|
||
| # ---- assets | ||
| battery_type = get_or_create_model(GenericAssetType, name="battery") | ||
|
|
||
| b1 = GenericAsset(name="B1", generic_asset_type=battery_type) | ||
| b2 = GenericAsset(name="B2", generic_asset_type=battery_type) | ||
|
|
||
| db.session.add_all([b1, b2]) | ||
| db.session.commit() | ||
|
|
||
| s1 = Sensor( | ||
| name="power1", | ||
| unit="kW", | ||
| event_resolution=power_sensor_resolution, | ||
| generic_asset=b1, | ||
| ) | ||
| s2 = Sensor( | ||
| name="power2", | ||
| unit="kW", | ||
| event_resolution=power_sensor_resolution, | ||
| generic_asset=b2, | ||
| ) | ||
|
|
||
| soc1 = Sensor( | ||
| name="soc1", | ||
| unit="kWh", | ||
| event_resolution=soc_sensor_resolution, | ||
| generic_asset=b1, | ||
| ) | ||
|
|
||
| soc2 = Sensor( | ||
| name="soc2", | ||
| unit="kWh", | ||
| event_resolution=soc_sensor_resolution, | ||
| generic_asset=b2, | ||
| ) | ||
|
|
||
| db.session.add_all([soc1, soc2, s1, s2]) | ||
| db.session.commit() | ||
|
|
||
| # ---- shared stock (both batteries charge from same pool) | ||
| flex_model = [ | ||
| { | ||
| "sensor": s1.id, | ||
| "stock-id": "shared", | ||
| "state-of-charge": {"sensor": soc1.id}, | ||
| "soc-at-start": 20.0, | ||
| "soc-min": 0.0, | ||
| "soc-max": 100.0, | ||
| "soc-targets": [{"datetime": "2024-01-01T23:00:00+01:00", "value": 80.0}], | ||
| "power-capacity": "20 kW", | ||
| "charging-efficiency": 0.95, | ||
| "discharging-efficiency": 0.95, | ||
| }, | ||
| { | ||
| "sensor": s2.id, | ||
| "stock-id": "shared", | ||
| "state-of-charge": {"sensor": soc2.id}, | ||
| "soc-at-start": 20.0, | ||
| "soc-min": 0.0, | ||
| "soc-max": 100.0, | ||
| "soc-targets": [{"datetime": "2024-01-01T23:00:00+01:00", "value": 80.0}], | ||
| "power-capacity": "20 kW", | ||
| "charging-efficiency": 0.95, | ||
| "discharging-efficiency": 0.95, | ||
| }, | ||
| ] | ||
|
|
||
| flex_context = { | ||
| "consumption-price": "100 EUR/MWh", | ||
| "production-price": "100 EUR/MWh", | ||
| } | ||
|
|
||
| scheduler = StorageScheduler( | ||
| asset_or_sensor=b1, | ||
| start=start, | ||
| end=end, | ||
| resolution=power_sensor_resolution, | ||
| belief_time=start, | ||
| flex_model=flex_model, | ||
| flex_context=flex_context, | ||
| return_multiple=True, | ||
| ) | ||
|
|
||
| schedules = scheduler.compute(skip_validation=True) | ||
|
|
||
| # Extract schedules by type | ||
| storage_schedules = [ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The term "storage schedule", which is also used in the In any case, we should make a clearer distinction now between:
|
||
| entry for entry in schedules if entry.get("name") == "storage_schedule" | ||
| ] | ||
| soc_schedules = [ | ||
| entry for entry in schedules if entry.get("name") == "state_of_charge" | ||
| ] | ||
| commitment_costs = [ | ||
| entry for entry in schedules if entry.get("name") == "commitment_costs" | ||
| ] | ||
|
|
||
| assert len(storage_schedules) == 2 | ||
| assert len(soc_schedules) == 1 # single shared SoC schedule | ||
| assert len(commitment_costs) == 1 | ||
|
|
||
| # Get battery schedules | ||
| b1_schedule = next(entry for entry in storage_schedules if entry["sensor"] == s1) | ||
| b1_data = b1_schedule["data"] | ||
|
|
||
| b2_schedule = next(entry for entry in storage_schedules if entry["sensor"] == s2) | ||
| b2_data = b2_schedule["data"] | ||
|
|
||
| # Both devices should charge to meet their targets | ||
| assert (b1_data > 0).any(), "B1 should charge at some point" | ||
| assert (b2_data > 0).any(), "B2 should charge at some point" | ||
|
|
||
| costs_data = commitment_costs[0]["data"] | ||
|
|
||
| # B1: 60kWh Δ (20→80) / 0.95 eff × 100 EUR/MWh ≈ 6.32 EUR (charge) + discharge ≈ 4.32 EUR | ||
| assert costs_data["electricity energy 0"] == pytest.approx(4.32, rel=1e-2), ( | ||
| f"B1 electricity cost (60kWh @ 95% eff + discharge): " | ||
| f"60kWh/0.95 × (100 EUR/MWh) ≈ 4.32 EUR, " | ||
| f"got {costs_data['electricity energy 0']}" | ||
| ) | ||
|
|
||
| # B2: identical to B1 (same parameters and targets) | ||
| assert costs_data["electricity energy 1"] == pytest.approx(4.32, rel=1e-2), ( | ||
| f"B2 electricity cost (60kWh @ 95% eff + discharge, same as B1): " | ||
| f"60kWh/0.95 × (100 EUR/MWh) ≈ 4.32 EUR, " | ||
| f"got {costs_data['electricity energy 1']}" | ||
| ) | ||
|
|
||
| # Total electricity: B1 (4.32) + B2 (4.32) = 8.64 EUR | ||
| total_electricity_cost = sum( | ||
| v for k, v in costs_data.items() if k.startswith("electricity energy") | ||
| ) | ||
| assert total_electricity_cost == pytest.approx(8.64, rel=1e-2), ( | ||
| f"Total electricity cost (B1 4.32 + B2 4.32): " | ||
| f"≈ 8.64 EUR, got {total_electricity_cost}" | ||
| ) | ||
|
|
||
| # B1 charging preference: early charging in shared stock scenario ≈ 9.44e-6 EUR | ||
| assert costs_data["prefer charging device 0 sooner"] == pytest.approx( | ||
| 9.44e-6, rel=1e-2 | ||
| ), ( | ||
| f"B1 charging preference (shared stock: both compete for same resource): " | ||
| f"≈ 9.44e-6 EUR, got {costs_data['prefer charging device 0 sooner']}" | ||
| ) | ||
|
|
||
| # B1 curtailing preference (0.5× multiplier): ≈ 4.72e-6 EUR | ||
| assert costs_data["prefer curtailing device 0 later"] == pytest.approx( | ||
| 4.72e-6, rel=1e-2 | ||
| ), ( | ||
| f"B1 curtailing preference (0.5× idle multiplier): " | ||
| f"≈ 0.5 × 9.44e-6 = 4.72e-6 EUR, " | ||
| f"got {costs_data['prefer curtailing device 0 later']}" | ||
| ) | ||
|
|
||
| # B2 charging preference: same as B1 ≈ 9.44e-6 EUR | ||
| assert costs_data["prefer charging device 1 sooner"] == pytest.approx( | ||
| 9.44e-6, rel=1e-2 | ||
| ), ( | ||
| f"B2 charging preference (shared stock, same as B1): " | ||
| f"≈ 9.44e-6 EUR, got {costs_data['prefer charging device 1 sooner']}" | ||
| ) | ||
|
|
||
| # B2 curtailing preference: same as B1 ≈ 4.72e-6 EUR | ||
| assert costs_data["prefer curtailing device 1 later"] == pytest.approx( | ||
| 4.72e-6, rel=1e-2 | ||
| ), ( | ||
| f"B2 curtailing preference (0.5× idle multiplier, same as B1): " | ||
| f"≈ 4.72e-6 EUR, got {costs_data['prefer curtailing device 1 later']}" | ||
| ) | ||
|
|
||
| # Verify charging cost ~2× curtailing cost for B1 (due to 0.5× multiplier) | ||
| assert ( | ||
| costs_data["prefer charging device 0 sooner"] | ||
| > costs_data["prefer curtailing device 0 later"] | ||
| ), ( | ||
| f"B1 charging preference should cost ~2× more than curtailing " | ||
| f"due to 0.5× multiplier. " | ||
| f"Ratio: {costs_data['prefer charging device 0 sooner'] / costs_data['prefer curtailing device 0 later']:.1f}×" | ||
| ) | ||
|
|
||
| # Verify charging cost ~2× curtailing cost for B2 (due to 0.5× multiplier) | ||
| assert ( | ||
| costs_data["prefer charging device 1 sooner"] | ||
| > costs_data["prefer curtailing device 1 later"] | ||
| ), ( | ||
| f"B2 charging preference should cost ~2× more than curtailing " | ||
| f"due to 0.5× multiplier. " | ||
| f"Ratio: {costs_data['prefer charging device 1 sooner'] / costs_data['prefer curtailing device 1 later']:.1f}×" | ||
| ) | ||
Uh oh!
There was an error while loading. Please reload this page.