samba-core 5.3.1__py3-none-any.whl
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.
- samba/__init__.py +69 -0
- samba/__main__.py +12 -0
- samba/_kpi_contract.py +29 -0
- samba/_pipeline.py +310 -0
- samba/_version.py +5 -0
- samba/batteries/__init__.py +25 -0
- samba/batteries/degradation.py +52 -0
- samba/batteries/factory.py +72 -0
- samba/batteries/kibam.py +364 -0
- samba/compiler/__init__.py +19 -0
- samba/compiler/annualize.py +113 -0
- samba/compiler/builders/__init__.py +24 -0
- samba/compiler/builders/battery.py +108 -0
- samba/compiler/builders/diesel.py +176 -0
- samba/compiler/builders/ev.py +192 -0
- samba/compiler/builders/gas_supply.py +128 -0
- samba/compiler/builders/grid.py +107 -0
- samba/compiler/builders/heat_pump.py +178 -0
- samba/compiler/builders/inverter.py +94 -0
- samba/compiler/builders/pv.py +87 -0
- samba/compiler/builders/thermal_load.py +151 -0
- samba/compiler/builders/thermal_storage.py +182 -0
- samba/compiler/builders/wind.py +218 -0
- samba/compiler/buses.py +116 -0
- samba/compiler/compiler.py +647 -0
- samba/compiler/constraints.py +436 -0
- samba/economics/__init__.py +58 -0
- samba/economics/cashflow.py +716 -0
- samba/economics/emissions.py +178 -0
- samba/economics/npc.py +151 -0
- samba/economics/replacement.py +111 -0
- samba/economics/salvage.py +103 -0
- samba/input_resolver.py +145 -0
- samba/load_profiles/__init__.py +31 -0
- samba/load_profiles/ev_presence.py +217 -0
- samba/load_profiles/expander.py +245 -0
- samba/load_profiles/generic.py +114 -0
- samba/load_profiles/templates.py +104 -0
- samba/load_profiles/thermal.py +238 -0
- samba/pareto/__init__.py +30 -0
- samba/pareto/sweep.py +400 -0
- samba/run_result/__init__.py +33 -0
- samba/run_result/contracts.py +205 -0
- samba/run_result/kpis.py +562 -0
- samba/run_result/reader.py +207 -0
- samba/run_result/writer.py +360 -0
- samba/scenario/__init__.py +15 -0
- samba/scenario/loader.py +131 -0
- samba/scenario/models/__init__.py +103 -0
- samba/scenario/models/_components.py +750 -0
- samba/scenario/models/_scenario.py +366 -0
- samba/scenario/models/_tariff.py +316 -0
- samba/solver/__init__.py +55 -0
- samba/solver/_extract_helpers.py +108 -0
- samba/solver/component_extractors/__init__.py +36 -0
- samba/solver/component_extractors/electrical.py +275 -0
- samba/solver/component_extractors/thermal.py +168 -0
- samba/solver/extract.py +289 -0
- samba/solver/runner.py +357 -0
- samba/tariff/__init__.py +26 -0
- samba/tariff/demand.py +121 -0
- samba/tariff/endogenous.py +359 -0
- samba/tariff/flat.py +19 -0
- samba/tariff/gas.py +78 -0
- samba/tariff/monthly.py +45 -0
- samba/tariff/monthly_tiered.py +63 -0
- samba/tariff/resolver.py +134 -0
- samba/tariff/seasonal.py +49 -0
- samba/tariff/seasonal_tiered.py +66 -0
- samba/tariff/service_charge.py +76 -0
- samba/tariff/tiered.py +70 -0
- samba/tariff/tou.py +91 -0
- samba/tariff/ultra_low_tou.py +87 -0
- samba/thermal/__init__.py +56 -0
- samba/thermal/buses.py +98 -0
- samba/thermal/constants.py +86 -0
- samba/thermal/cop.py +387 -0
- samba/thermal/cop_dataset.py +152 -0
- samba/thermal/cop_fetch.py +212 -0
- samba/thermal/gas_constants.py +58 -0
- samba/thermal/hp_catalog.py +113 -0
- samba/weather/__init__.py +19 -0
- samba/weather/fetch.py +140 -0
- samba/weather/models.py +90 -0
- samba/weather/nsrdb.py +116 -0
- samba/weather/poa.py +150 -0
- samba_cli/__init__.py +4 -0
- samba_cli/__main__.py +8 -0
- samba_cli/formatting.py +156 -0
- samba_cli/handlers.py +517 -0
- samba_cli/main.py +236 -0
- samba_core-5.3.1.dist-info/METADATA +496 -0
- samba_core-5.3.1.dist-info/RECORD +103 -0
- samba_core-5.3.1.dist-info/WHEEL +4 -0
- samba_core-5.3.1.dist-info/entry_points.txt +2 -0
- samba_core-5.3.1.dist-info/licenses/LICENSE +373 -0
- samba_service/__init__.py +4 -0
- samba_service/app.py +419 -0
- samba_service/auth.py +59 -0
- samba_service/config.py +114 -0
- samba_service/jobs.py +465 -0
- samba_service/models.py +177 -0
- samba_service/persistent_jobs.py +191 -0
samba/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
|
2
|
+
# If a copy of the MPL was not distributed with this file, You can obtain one at
|
|
3
|
+
# https://mozilla.org/MPL/2.0/.
|
|
4
|
+
"""SAMBA - Systems Advisor for Microgrids & Building Analysis.
|
|
5
|
+
|
|
6
|
+
The top-level :func:'run' convenience function executes the full solve-and-
|
|
7
|
+
post-process pipeline in a single call::
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import samba
|
|
11
|
+
|
|
12
|
+
result = samba.run(
|
|
13
|
+
scenario,
|
|
14
|
+
load_kw=my_load_array,
|
|
15
|
+
pv_per_kwp=my_pv_profile,
|
|
16
|
+
output_dir="outputs/",
|
|
17
|
+
)
|
|
18
|
+
print(result.npc, result.lcoe)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
from samba._version import __version__
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
from samba.run_result.reader import RunResult
|
|
32
|
+
from samba.scenario.models import Scenario
|
|
33
|
+
from samba.solver.runner import SolverConfig
|
|
34
|
+
from samba.tariff.resolver import TariffArrays
|
|
35
|
+
from samba.weather import WeatherData
|
|
36
|
+
|
|
37
|
+
__all__ = ["__version__", "run"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run(
|
|
41
|
+
scenario_or_path: Scenario | Path | str,
|
|
42
|
+
*,
|
|
43
|
+
load_kw: np.ndarray,
|
|
44
|
+
output_dir: Path | str | None = None,
|
|
45
|
+
config: SolverConfig | None = None,
|
|
46
|
+
pv_per_kwp: np.ndarray | None = None,
|
|
47
|
+
tariff_arrays: TariffArrays | None = None,
|
|
48
|
+
wind_power_kw: np.ndarray | None = None,
|
|
49
|
+
weather: WeatherData | None = None,
|
|
50
|
+
scenario_dir: Path | str | None = None,
|
|
51
|
+
) -> RunResult:
|
|
52
|
+
"""Run the full SAMBA pipeline and return a :class:'~samba.run_result.reader.RunResult'."""
|
|
53
|
+
# Deferred by design (audit M8): importing the pipeline pulls in the heavy
|
|
54
|
+
# oemof-solph / pyomo / pandas stack. Keeping it lazy means ``import samba``
|
|
55
|
+
# and ``samba --version`` stay fast for callers that don't run a solve.
|
|
56
|
+
# (There is no circular-import dependency; this is purely an import-cost choice.)
|
|
57
|
+
from samba._pipeline import run_pipeline
|
|
58
|
+
|
|
59
|
+
return run_pipeline(
|
|
60
|
+
scenario_or_path,
|
|
61
|
+
load_kw=load_kw,
|
|
62
|
+
output_dir=output_dir,
|
|
63
|
+
config=config,
|
|
64
|
+
pv_per_kwp=pv_per_kwp,
|
|
65
|
+
tariff_arrays=tariff_arrays,
|
|
66
|
+
wind_power_kw=wind_power_kw,
|
|
67
|
+
weather=weather,
|
|
68
|
+
scenario_dir=scenario_dir,
|
|
69
|
+
)
|
samba/__main__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
|
2
|
+
# If a copy of the MPL was not distributed with this file, You can obtain one at
|
|
3
|
+
# https://mozilla.org/MPL/2.0/.
|
|
4
|
+
from samba._version import __version__
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main() -> None:
|
|
8
|
+
print(f"samba {__version__}")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
if __name__ == "__main__":
|
|
12
|
+
main()
|
samba/_kpi_contract.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
|
2
|
+
# If a copy of the MPL was not distributed with this file, You can obtain one at
|
|
3
|
+
# https://mozilla.org/MPL/2.0/.
|
|
4
|
+
"""Single-source-of-truth for the KPI output contract version.
|
|
5
|
+
|
|
6
|
+
This constant is embedded as ''"kpi_contract_version"'' in every ''kpis.json''
|
|
7
|
+
artefact produced by :func:'samba.run_result.kpis.compute_kpis'. Downstream
|
|
8
|
+
tooling (dashboards, CI golden comparisons) should read this field to guard
|
|
9
|
+
against schema drift.
|
|
10
|
+
|
|
11
|
+
Version history
|
|
12
|
+
---------------
|
|
13
|
+
''"2.0"''
|
|
14
|
+
Initial versioned contract. All 28 core KPI fields defined in
|
|
15
|
+
''docs/developer/results-contract.md'' plus ''monthly_grid_kwh'' and
|
|
16
|
+
''monthly_grid_cost'' breakdowns.
|
|
17
|
+
''"2.1"''
|
|
18
|
+
Additive (v4 Phases 25/27): ''annual_demand_charge_usd'', ''annual_energy_net_usd'',
|
|
19
|
+
''peak_demand_kw_by_month'' (Phase 25); ''annual_throughput_cycles'',
|
|
20
|
+
''battery_eol_year'' (Phase 27 battery degradation). Backwards-compatible —
|
|
21
|
+
existing fields unchanged.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
__all__ = ["KPI_CONTRACT_VERSION"]
|
|
27
|
+
|
|
28
|
+
#: Bumped whenever the ''kpis.json'' schema changes (minor = additive).
|
|
29
|
+
KPI_CONTRACT_VERSION: str = "2.1"
|
samba/_pipeline.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
|
2
|
+
# If a copy of the MPL was not distributed with this file, You can obtain one at
|
|
3
|
+
# https://mozilla.org/MPL/2.0/.
|
|
4
|
+
"""Internal orchestration pipeline for :func:`samba.run`.
|
|
5
|
+
|
|
6
|
+
This module keeps the public package ``__init__`` lightweight while preserving
|
|
7
|
+
the same top-level ``samba.run(...)`` behavior.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
import numpy as np
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
from samba.run_result.reader import RunResult
|
|
22
|
+
from samba.scenario.models import Scenario
|
|
23
|
+
from samba.solver.runner import SolverConfig
|
|
24
|
+
from samba.tariff.resolver import TariffArrays
|
|
25
|
+
from samba.weather import WeatherData
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _load_scenario(
|
|
31
|
+
scenario_or_path: Scenario | Path | str,
|
|
32
|
+
scenario_dir: Path | str | None,
|
|
33
|
+
) -> tuple[Scenario, Path | None]:
|
|
34
|
+
"""Return validated scenario and resolved scenario directory."""
|
|
35
|
+
from samba.scenario import Scenario, load_scenario
|
|
36
|
+
|
|
37
|
+
resolved_dir: Path | None = Path(scenario_dir) if scenario_dir is not None else None
|
|
38
|
+
if isinstance(scenario_or_path, Scenario):
|
|
39
|
+
return scenario_or_path, resolved_dir
|
|
40
|
+
|
|
41
|
+
scenario_path = Path(scenario_or_path)
|
|
42
|
+
scenario = load_scenario(scenario_path)
|
|
43
|
+
if resolved_dir is None:
|
|
44
|
+
resolved_dir = scenario_path.parent
|
|
45
|
+
return scenario, resolved_dir
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _resolve_tariff_arrays(
|
|
49
|
+
scenario: Scenario,
|
|
50
|
+
load_kw: np.ndarray,
|
|
51
|
+
tariff_arrays: TariffArrays | None,
|
|
52
|
+
) -> TariffArrays:
|
|
53
|
+
"""Resolve tariff arrays from the scenario when not pre-provided."""
|
|
54
|
+
from samba.tariff import resolve_tariff
|
|
55
|
+
|
|
56
|
+
if tariff_arrays is None:
|
|
57
|
+
return resolve_tariff(scenario.tariff, load_kw, scenario.project.year)
|
|
58
|
+
return tariff_arrays
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resolve_weather(
|
|
62
|
+
scenario: Scenario,
|
|
63
|
+
scenario_dir: Path | None,
|
|
64
|
+
weather: WeatherData | None,
|
|
65
|
+
) -> WeatherData:
|
|
66
|
+
"""Resolve weather for thermal scenarios, with a safe fallback stub."""
|
|
67
|
+
from samba.weather import stub_weather
|
|
68
|
+
|
|
69
|
+
resolved_weather = weather
|
|
70
|
+
if resolved_weather is None and scenario_dir is not None:
|
|
71
|
+
hp = scenario.components.heat_pump
|
|
72
|
+
thermal_cfg = getattr(getattr(scenario, "load", None), "thermal", None)
|
|
73
|
+
needs_weather = (hp is not None and hp.enabled) or (
|
|
74
|
+
thermal_cfg is not None
|
|
75
|
+
and getattr(thermal_cfg, "enabled", False)
|
|
76
|
+
and getattr(thermal_cfg, "source", None) == "degree_day"
|
|
77
|
+
)
|
|
78
|
+
if needs_weather and scenario.weather.source == "csv" and scenario.weather.csv_path:
|
|
79
|
+
from samba.weather.nsrdb import read_nsrdb_csv
|
|
80
|
+
|
|
81
|
+
weather_path = Path(scenario.weather.csv_path)
|
|
82
|
+
if not weather_path.is_absolute():
|
|
83
|
+
weather_path = scenario_dir / weather_path
|
|
84
|
+
try:
|
|
85
|
+
resolved_weather = read_nsrdb_csv(weather_path)
|
|
86
|
+
log.debug("Auto-resolved weather from %s for HP/thermal scenario", weather_path)
|
|
87
|
+
except Exception as exc: # noqa: BLE001
|
|
88
|
+
log.warning("Could not auto-resolve weather for HP scenario: %s", exc)
|
|
89
|
+
|
|
90
|
+
return resolved_weather if resolved_weather is not None else stub_weather()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _compile_energy_system(
|
|
94
|
+
scenario: Scenario,
|
|
95
|
+
load_kw: np.ndarray,
|
|
96
|
+
tariff_arrays: TariffArrays,
|
|
97
|
+
weather: WeatherData,
|
|
98
|
+
pv_per_kwp: np.ndarray | None,
|
|
99
|
+
wind_power_kw: np.ndarray | None,
|
|
100
|
+
scenario_dir: Path | None,
|
|
101
|
+
) -> object:
|
|
102
|
+
"""Compile the oemof energy system from run inputs."""
|
|
103
|
+
from samba.compiler import CompilerInputs, compile_energy_system
|
|
104
|
+
|
|
105
|
+
inputs = CompilerInputs(
|
|
106
|
+
scenario=scenario,
|
|
107
|
+
load_kw=load_kw,
|
|
108
|
+
tariff_arrays=tariff_arrays,
|
|
109
|
+
weather=weather,
|
|
110
|
+
pv_per_kwp=pv_per_kwp,
|
|
111
|
+
wind_power_kw=wind_power_kw,
|
|
112
|
+
scenario_dir=scenario_dir,
|
|
113
|
+
)
|
|
114
|
+
return compile_energy_system(inputs)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _solve_energy_system(
|
|
118
|
+
energy_system: object,
|
|
119
|
+
scenario: Scenario,
|
|
120
|
+
config: SolverConfig | None,
|
|
121
|
+
) -> tuple[object, SolverConfig, float]:
|
|
122
|
+
"""Solve compiled system and return raw results + config + runtime."""
|
|
123
|
+
from samba.solver.runner import SolverConfig, solve
|
|
124
|
+
|
|
125
|
+
solver_config = config if config is not None else SolverConfig()
|
|
126
|
+
t0 = time.perf_counter()
|
|
127
|
+
raw_results = solve(energy_system, scenario, solver_config)
|
|
128
|
+
solve_time_s = time.perf_counter() - t0
|
|
129
|
+
log.info("Solve completed in %.2f s", solve_time_s)
|
|
130
|
+
return raw_results, solver_config, solve_time_s
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _validate_kibam_if_needed(
|
|
134
|
+
scenario: Scenario,
|
|
135
|
+
dispatch_df: pd.DataFrame,
|
|
136
|
+
config: SolverConfig,
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Run post-solve KiBaM feasibility validation when enabled."""
|
|
139
|
+
bat = scenario.components.battery
|
|
140
|
+
if not (
|
|
141
|
+
bat is not None
|
|
142
|
+
and bat.enabled
|
|
143
|
+
and bat.chemistry == "kibam"
|
|
144
|
+
and bat.kibam is not None
|
|
145
|
+
and config.kibam_validate
|
|
146
|
+
):
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
from samba.batteries.kibam import validate_kibam_dispatch
|
|
150
|
+
from samba.compiler.constraints import ConstraintViolationError
|
|
151
|
+
|
|
152
|
+
if "batt_discharge" not in dispatch_df or "batt_charge" not in dispatch_df:
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
batt_dch = dispatch_df["batt_discharge"].to_numpy()
|
|
156
|
+
batt_ch = dispatch_df["batt_charge"].to_numpy()
|
|
157
|
+
net_dispatch = batt_dch - batt_ch # positive = discharge
|
|
158
|
+
fixed_cap = bat.capacity_kwh if bat.capacity_kwh is not None else 0.0
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
kibam_result = validate_kibam_dispatch(
|
|
162
|
+
dispatch_kw=net_dispatch,
|
|
163
|
+
capacity_kwh=float(dispatch_df["battery_soc_kwh"].max()) / bat.soc_max
|
|
164
|
+
if "battery_soc_kwh" in dispatch_df
|
|
165
|
+
else max(fixed_cap, 1.0),
|
|
166
|
+
kibam=bat.kibam,
|
|
167
|
+
soc_initial=bat.soc_initial,
|
|
168
|
+
)
|
|
169
|
+
if not kibam_result.feasible:
|
|
170
|
+
log.warning(
|
|
171
|
+
"KiBaM post-validation: %d timestep violation(s) detected. "
|
|
172
|
+
"Worst Q1 deficit = %.4f kWh. "
|
|
173
|
+
"LP approximation may have allowed infeasible discharge near low SOC.",
|
|
174
|
+
kibam_result.n_violations,
|
|
175
|
+
kibam_result.worst_q1_deficit_kwh,
|
|
176
|
+
)
|
|
177
|
+
if config.strict_kibam:
|
|
178
|
+
raise ConstraintViolationError(
|
|
179
|
+
field="kibam_dispatch_feasibility",
|
|
180
|
+
value=float(kibam_result.n_violations),
|
|
181
|
+
limit=0.0,
|
|
182
|
+
deviation=float(kibam_result.n_violations),
|
|
183
|
+
message=(
|
|
184
|
+
f"KiBaM dispatch infeasible: {kibam_result.n_violations} timestep "
|
|
185
|
+
f"violation(s), worst Q1 deficit "
|
|
186
|
+
f"{kibam_result.worst_q1_deficit_kwh:.4f} kWh. The LP relaxation "
|
|
187
|
+
"allowed discharge the two-tank model cannot sustain near low SOC. "
|
|
188
|
+
"Set SolverConfig.strict_kibam=False to downgrade to a warning."
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
else:
|
|
192
|
+
log.debug("KiBaM post-validation: all timesteps feasible.")
|
|
193
|
+
except ConstraintViolationError:
|
|
194
|
+
raise # strict_kibam: propagate, do not swallow below
|
|
195
|
+
except Exception as exc: # pragma: no cover
|
|
196
|
+
log.debug("KiBaM post-validation skipped: %s", exc)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _write_run_artifacts(
|
|
200
|
+
output_dir: Path | str | None,
|
|
201
|
+
scenario: Scenario,
|
|
202
|
+
config: SolverConfig,
|
|
203
|
+
solve_time_s: float,
|
|
204
|
+
raw_results: Any,
|
|
205
|
+
dispatch_df: pd.DataFrame,
|
|
206
|
+
kpis: dict[str, Any],
|
|
207
|
+
economics: dict[str, Any],
|
|
208
|
+
sizing: pd.DataFrame,
|
|
209
|
+
tariff_arrays: TariffArrays,
|
|
210
|
+
) -> tuple[Path | None, dict[str, Any]]:
|
|
211
|
+
"""Persist run artifacts when output directory is provided."""
|
|
212
|
+
from samba.run_result.writer import (
|
|
213
|
+
build_metadata,
|
|
214
|
+
ensure_run_dir,
|
|
215
|
+
write_dispatch,
|
|
216
|
+
write_economics,
|
|
217
|
+
write_kpis,
|
|
218
|
+
write_metadata,
|
|
219
|
+
write_scenario,
|
|
220
|
+
write_sizing,
|
|
221
|
+
write_tariff,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if output_dir is None:
|
|
225
|
+
return (
|
|
226
|
+
None,
|
|
227
|
+
build_metadata(
|
|
228
|
+
scenario=scenario,
|
|
229
|
+
solver_config=config,
|
|
230
|
+
solve_time_s=solve_time_s,
|
|
231
|
+
solver_results=raw_results,
|
|
232
|
+
run_id="in_memory",
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
run_dir = ensure_run_dir(Path(output_dir), scenario.project.name)
|
|
237
|
+
write_dispatch(run_dir, dispatch_df)
|
|
238
|
+
metadata = write_metadata(run_dir, scenario, config, solve_time_s, raw_results)
|
|
239
|
+
write_kpis(run_dir, kpis)
|
|
240
|
+
write_economics(run_dir, economics)
|
|
241
|
+
write_sizing(run_dir, sizing)
|
|
242
|
+
write_tariff(run_dir, tariff_arrays)
|
|
243
|
+
write_scenario(run_dir, scenario)
|
|
244
|
+
log.info("Results written to %s", run_dir)
|
|
245
|
+
return run_dir, metadata
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def run_pipeline(
|
|
249
|
+
scenario_or_path: Scenario | Path | str,
|
|
250
|
+
*,
|
|
251
|
+
load_kw: np.ndarray,
|
|
252
|
+
output_dir: Path | str | None = None,
|
|
253
|
+
config: SolverConfig | None = None,
|
|
254
|
+
pv_per_kwp: np.ndarray | None = None,
|
|
255
|
+
tariff_arrays: TariffArrays | None = None,
|
|
256
|
+
wind_power_kw: np.ndarray | None = None,
|
|
257
|
+
weather: WeatherData | None = None,
|
|
258
|
+
scenario_dir: Path | str | None = None,
|
|
259
|
+
) -> RunResult:
|
|
260
|
+
"""Execute full solve-and-postprocess pipeline and return ``RunResult``."""
|
|
261
|
+
from samba.run_result.kpis import compute_kpis
|
|
262
|
+
from samba.run_result.reader import RunResult
|
|
263
|
+
from samba.solver.extract import extract_dispatch
|
|
264
|
+
|
|
265
|
+
scenario, resolved_scenario_dir = _load_scenario(scenario_or_path, scenario_dir)
|
|
266
|
+
resolved_tariff = _resolve_tariff_arrays(scenario, load_kw, tariff_arrays)
|
|
267
|
+
resolved_weather = _resolve_weather(scenario, resolved_scenario_dir, weather)
|
|
268
|
+
|
|
269
|
+
energy_system = _compile_energy_system(
|
|
270
|
+
scenario,
|
|
271
|
+
load_kw,
|
|
272
|
+
resolved_tariff,
|
|
273
|
+
resolved_weather,
|
|
274
|
+
pv_per_kwp,
|
|
275
|
+
wind_power_kw,
|
|
276
|
+
resolved_scenario_dir,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
raw_results, solver_config, solve_time_s = _solve_energy_system(energy_system, scenario, config)
|
|
280
|
+
dispatch_result = extract_dispatch(energy_system, raw_results)
|
|
281
|
+
|
|
282
|
+
_validate_kibam_if_needed(
|
|
283
|
+
scenario=scenario,
|
|
284
|
+
dispatch_df=dispatch_result.dispatch,
|
|
285
|
+
config=solver_config,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
kpis, economics, sizing = compute_kpis(scenario, dispatch_result, resolved_tariff)
|
|
289
|
+
run_dir, metadata = _write_run_artifacts(
|
|
290
|
+
output_dir=output_dir,
|
|
291
|
+
scenario=scenario,
|
|
292
|
+
config=solver_config,
|
|
293
|
+
solve_time_s=solve_time_s,
|
|
294
|
+
raw_results=raw_results,
|
|
295
|
+
dispatch_df=dispatch_result.dispatch,
|
|
296
|
+
kpis=kpis,
|
|
297
|
+
economics=economics,
|
|
298
|
+
sizing=sizing,
|
|
299
|
+
tariff_arrays=resolved_tariff,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
return RunResult(
|
|
303
|
+
run_dir=run_dir or Path("."),
|
|
304
|
+
metadata=metadata,
|
|
305
|
+
kpis=kpis,
|
|
306
|
+
dispatch=dispatch_result.dispatch,
|
|
307
|
+
economics=economics,
|
|
308
|
+
sizing=sizing,
|
|
309
|
+
scenario_raw=None,
|
|
310
|
+
)
|
samba/_version.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
|
2
|
+
# If a copy of the MPL was not distributed with this file, You can obtain one at
|
|
3
|
+
# https://mozilla.org/MPL/2.0/.
|
|
4
|
+
"""Battery chemistry implementations and factory for oemof-solph builders.
|
|
5
|
+
|
|
6
|
+
This package contains:
|
|
7
|
+
* ''kibam'' -- KiBaM (Kinetic Battery Model) LP approximation and post-solve
|
|
8
|
+
feasibility validator.
|
|
9
|
+
* ''factory'' -- dispatches to the correct storage builder based on
|
|
10
|
+
''battery.chemistry''.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from samba.batteries.factory import build_battery_storage
|
|
14
|
+
from samba.batteries.kibam import (
|
|
15
|
+
KiBaMValidationResult,
|
|
16
|
+
compute_kibam_limits,
|
|
17
|
+
validate_kibam_dispatch,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"build_battery_storage",
|
|
22
|
+
"KiBaMValidationResult",
|
|
23
|
+
"compute_kibam_limits",
|
|
24
|
+
"validate_kibam_dispatch",
|
|
25
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
|
2
|
+
# If a copy of the MPL was not distributed with this file, You can obtain one at
|
|
3
|
+
# https://mozilla.org/MPL/2.0/.
|
|
4
|
+
"""Battery capacity-fade / degradation model (v4).
|
|
5
|
+
|
|
6
|
+
Converts the solved annual discharge throughput into an effective battery
|
|
7
|
+
lifetime, so replacement economics reflect how hard the battery is cycled rather
|
|
8
|
+
than a fixed nameplate ``lifetime_years``. The fade model is intentionally simple
|
|
9
|
+
and linear (documented as an approximation in ``docs/known-limitations.md``):
|
|
10
|
+
|
|
11
|
+
annual_fade_pct = calendar_fade_pct_yr + cycle_fade_pct_per_efc x EFC_per_year
|
|
12
|
+
years_to_eol = (100 - end_of_life_capacity_pct) / annual_fade_pct
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from samba.scenario.models import BatteryDegradation
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"annual_equivalent_full_cycles",
|
|
24
|
+
"effective_battery_lifetime_years",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def annual_equivalent_full_cycles(annual_discharge_kwh: float, capacity_kwh: float) -> float:
|
|
29
|
+
"""Equivalent full cycles per year = annual discharge throughput / usable capacity."""
|
|
30
|
+
if capacity_kwh <= 0.0:
|
|
31
|
+
return 0.0
|
|
32
|
+
return float(annual_discharge_kwh) / float(capacity_kwh)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def effective_battery_lifetime_years(
|
|
36
|
+
degradation: BatteryDegradation,
|
|
37
|
+
annual_discharge_kwh: float,
|
|
38
|
+
capacity_kwh: float,
|
|
39
|
+
nameplate_lifetime_years: float,
|
|
40
|
+
) -> float:
|
|
41
|
+
"""Return the battery's degradation-derived effective lifetime [years].
|
|
42
|
+
|
|
43
|
+
With no modelled fade (both rates 0) the nameplate lifetime is returned
|
|
44
|
+
unchanged. Otherwise the lifetime is the number of years of linear fade until
|
|
45
|
+
capacity reaches ``end_of_life_capacity_pct`` of nameplate, floored at 1 year.
|
|
46
|
+
"""
|
|
47
|
+
efc = annual_equivalent_full_cycles(annual_discharge_kwh, capacity_kwh)
|
|
48
|
+
annual_fade = degradation.calendar_fade_pct_yr + degradation.cycle_fade_pct_per_efc * efc
|
|
49
|
+
if annual_fade <= 0.0:
|
|
50
|
+
return float(nameplate_lifetime_years)
|
|
51
|
+
allowable_loss = 100.0 - degradation.end_of_life_capacity_pct
|
|
52
|
+
return max(1.0, allowable_loss / annual_fade)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
|
2
|
+
# If a copy of the MPL was not distributed with this file, You can obtain one at
|
|
3
|
+
# https://mozilla.org/MPL/2.0/.
|
|
4
|
+
"""Battery storage factory -- dispatches to the correct builder by chemistry.
|
|
5
|
+
|
|
6
|
+
Layering rule
|
|
7
|
+
-------------
|
|
8
|
+
''samba.batteries.factory'' may import from ''samba.compiler.builders.battery''
|
|
9
|
+
(Li-ion builder lives there). The reverse import is forbidden.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
import oemof.solph as solph
|
|
18
|
+
|
|
19
|
+
from samba.batteries.kibam import build_kibam_storage
|
|
20
|
+
from samba.compiler.builders.battery import BatteryBuilder
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from samba.scenario.models import Scenario
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
__all__ = ["build_battery_storage"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_battery_storage(
|
|
31
|
+
scenario: Scenario,
|
|
32
|
+
dc_bus: solph.Bus,
|
|
33
|
+
ac_bus: solph.Bus,
|
|
34
|
+
) -> list[solph.network.Node]:
|
|
35
|
+
"""Build and return the battery storage node(s) for the given chemistry.
|
|
36
|
+
|
|
37
|
+
Dispatches to:
|
|
38
|
+
|
|
39
|
+
* ''BatteryBuilder'' (Li-ion, idealized GenericStorage) for
|
|
40
|
+
''chemistry == "li_ion"''.
|
|
41
|
+
* :func:'~samba.batteries.kibam.build_kibam_storage' (KiBaM LP
|
|
42
|
+
approximation) for ''chemistry == "kibam"''.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
scenario:
|
|
47
|
+
Validated scenario; ''scenario.components.battery'' must not be
|
|
48
|
+
''None''.
|
|
49
|
+
dc_bus:
|
|
50
|
+
DC system bus.
|
|
51
|
+
ac_bus:
|
|
52
|
+
AC system bus (passed through to the Li-ion builder signature;
|
|
53
|
+
unused by the KiBaM path).
|
|
54
|
+
|
|
55
|
+
Returns
|
|
56
|
+
-------
|
|
57
|
+
list[solph.network.Node]
|
|
58
|
+
One ''GenericStorage'' node for Li-ion or KiBaM.
|
|
59
|
+
"""
|
|
60
|
+
bat = scenario.components.battery
|
|
61
|
+
if bat is None:
|
|
62
|
+
raise ValueError("build_battery_storage called but scenario.components.battery is None")
|
|
63
|
+
|
|
64
|
+
match bat.chemistry:
|
|
65
|
+
case "li_ion":
|
|
66
|
+
log.debug("Battery factory: chemistry=li_ion -> BatteryBuilder")
|
|
67
|
+
return BatteryBuilder().build(scenario, dc_bus, ac_bus)
|
|
68
|
+
case "kibam":
|
|
69
|
+
log.debug("Battery factory: chemistry=kibam -> build_kibam_storage")
|
|
70
|
+
return [build_kibam_storage(scenario, dc_bus)]
|
|
71
|
+
case _: # pragma: no cover
|
|
72
|
+
raise ValueError(f"Unknown battery chemistry: {bat.chemistry!r}")
|