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.
Files changed (103) hide show
  1. samba/__init__.py +69 -0
  2. samba/__main__.py +12 -0
  3. samba/_kpi_contract.py +29 -0
  4. samba/_pipeline.py +310 -0
  5. samba/_version.py +5 -0
  6. samba/batteries/__init__.py +25 -0
  7. samba/batteries/degradation.py +52 -0
  8. samba/batteries/factory.py +72 -0
  9. samba/batteries/kibam.py +364 -0
  10. samba/compiler/__init__.py +19 -0
  11. samba/compiler/annualize.py +113 -0
  12. samba/compiler/builders/__init__.py +24 -0
  13. samba/compiler/builders/battery.py +108 -0
  14. samba/compiler/builders/diesel.py +176 -0
  15. samba/compiler/builders/ev.py +192 -0
  16. samba/compiler/builders/gas_supply.py +128 -0
  17. samba/compiler/builders/grid.py +107 -0
  18. samba/compiler/builders/heat_pump.py +178 -0
  19. samba/compiler/builders/inverter.py +94 -0
  20. samba/compiler/builders/pv.py +87 -0
  21. samba/compiler/builders/thermal_load.py +151 -0
  22. samba/compiler/builders/thermal_storage.py +182 -0
  23. samba/compiler/builders/wind.py +218 -0
  24. samba/compiler/buses.py +116 -0
  25. samba/compiler/compiler.py +647 -0
  26. samba/compiler/constraints.py +436 -0
  27. samba/economics/__init__.py +58 -0
  28. samba/economics/cashflow.py +716 -0
  29. samba/economics/emissions.py +178 -0
  30. samba/economics/npc.py +151 -0
  31. samba/economics/replacement.py +111 -0
  32. samba/economics/salvage.py +103 -0
  33. samba/input_resolver.py +145 -0
  34. samba/load_profiles/__init__.py +31 -0
  35. samba/load_profiles/ev_presence.py +217 -0
  36. samba/load_profiles/expander.py +245 -0
  37. samba/load_profiles/generic.py +114 -0
  38. samba/load_profiles/templates.py +104 -0
  39. samba/load_profiles/thermal.py +238 -0
  40. samba/pareto/__init__.py +30 -0
  41. samba/pareto/sweep.py +400 -0
  42. samba/run_result/__init__.py +33 -0
  43. samba/run_result/contracts.py +205 -0
  44. samba/run_result/kpis.py +562 -0
  45. samba/run_result/reader.py +207 -0
  46. samba/run_result/writer.py +360 -0
  47. samba/scenario/__init__.py +15 -0
  48. samba/scenario/loader.py +131 -0
  49. samba/scenario/models/__init__.py +103 -0
  50. samba/scenario/models/_components.py +750 -0
  51. samba/scenario/models/_scenario.py +366 -0
  52. samba/scenario/models/_tariff.py +316 -0
  53. samba/solver/__init__.py +55 -0
  54. samba/solver/_extract_helpers.py +108 -0
  55. samba/solver/component_extractors/__init__.py +36 -0
  56. samba/solver/component_extractors/electrical.py +275 -0
  57. samba/solver/component_extractors/thermal.py +168 -0
  58. samba/solver/extract.py +289 -0
  59. samba/solver/runner.py +357 -0
  60. samba/tariff/__init__.py +26 -0
  61. samba/tariff/demand.py +121 -0
  62. samba/tariff/endogenous.py +359 -0
  63. samba/tariff/flat.py +19 -0
  64. samba/tariff/gas.py +78 -0
  65. samba/tariff/monthly.py +45 -0
  66. samba/tariff/monthly_tiered.py +63 -0
  67. samba/tariff/resolver.py +134 -0
  68. samba/tariff/seasonal.py +49 -0
  69. samba/tariff/seasonal_tiered.py +66 -0
  70. samba/tariff/service_charge.py +76 -0
  71. samba/tariff/tiered.py +70 -0
  72. samba/tariff/tou.py +91 -0
  73. samba/tariff/ultra_low_tou.py +87 -0
  74. samba/thermal/__init__.py +56 -0
  75. samba/thermal/buses.py +98 -0
  76. samba/thermal/constants.py +86 -0
  77. samba/thermal/cop.py +387 -0
  78. samba/thermal/cop_dataset.py +152 -0
  79. samba/thermal/cop_fetch.py +212 -0
  80. samba/thermal/gas_constants.py +58 -0
  81. samba/thermal/hp_catalog.py +113 -0
  82. samba/weather/__init__.py +19 -0
  83. samba/weather/fetch.py +140 -0
  84. samba/weather/models.py +90 -0
  85. samba/weather/nsrdb.py +116 -0
  86. samba/weather/poa.py +150 -0
  87. samba_cli/__init__.py +4 -0
  88. samba_cli/__main__.py +8 -0
  89. samba_cli/formatting.py +156 -0
  90. samba_cli/handlers.py +517 -0
  91. samba_cli/main.py +236 -0
  92. samba_core-5.3.1.dist-info/METADATA +496 -0
  93. samba_core-5.3.1.dist-info/RECORD +103 -0
  94. samba_core-5.3.1.dist-info/WHEEL +4 -0
  95. samba_core-5.3.1.dist-info/entry_points.txt +2 -0
  96. samba_core-5.3.1.dist-info/licenses/LICENSE +373 -0
  97. samba_service/__init__.py +4 -0
  98. samba_service/app.py +419 -0
  99. samba_service/auth.py +59 -0
  100. samba_service/config.py +114 -0
  101. samba_service/jobs.py +465 -0
  102. samba_service/models.py +177 -0
  103. 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,5 @@
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
+
5
+ __version__ = "5.3.1"
@@ -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}")