feat(devices): add battery profiles

A battery profile describes a battery model: the charge and discharge limit
maps over temperature and SoC, the charge and discharge efficiency curves
over C-rate, the technical SoC bounds, the hard temperature cutoffs and,
for a model, its nominal capacity and power limits. EOS packages the
generic profile generic-lfp; users add their own profiles.

A battery or electric vehicle selects its profile by battery_profile and
describes how the installation differs from it in profile_adjustment
(capacity, power limits of the installation, periodic full charge). The
effective values are resolved from both at the measured operating point
(temperature, SoC, power) and shown as effective_* fields.

The GENETIC simulation uses the limits and efficiencies of the profile over
SoC, with precomputed tables as a fast path; GENETIC0 gets the values at the
operating point. Optimization parameters are always valid: a battery whose
profile is missing, unreadable or invalid uses the packaged generic-lfp
profile, and the former defaults only if that is not usable either; each is
logged as error, and an optimization run is never cancelled because of a
battery.

Additional changes
- Add a generic device profile store: user and packaged profiles, atomic
  saves, compiled profiles cached per file and checked again at most once a
  second, plain file names as profile names ('schema' is reserved). On
  Windows, a save or read that collides with the other is retried briefly.
- Add the REST API /v1/profile to list the profile kinds and to list, read
  (document and JSON schema), save and delete profiles.
- Check the hard temperature cutoffs on the exact temperature.
- Resolve inhibited charging or discharging to a power of 0, e.g. for a cold
  battery; give GENETIC0 its own discharge limit for that.
- Limit generic profiles without own power limits to the former 5000 W.
- Accept only the implemented units; current limits need the nominal
  voltage.
- Migrate legacy battery settings into the profile adjustment, keep the
  former charge power limit as discharge limit (also for a Home Assistant
  entity on it) and report dropped efficiencies as errors.
- Limit GENETIC battery discharge by the discharge power.
- Allow battery profile lookups while the configuration is created.
- Resolve a battery once per configuration dump.
- Package the bundled profiles, document battery profiles and regenerate
  the configuration and OpenAPI documentation.
- Ignore the report PDFs written by the visualization tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Assisted-by: Claude Code (claude-opus-5-5)
This commit is contained in:
Bobby Noelte
2026-11-11 22:08:18 +01:00
co-authored by Claude Opus 5.5
parent dd41e5d57c
commit e255f987bf
56 changed files with 11381 additions and 619 deletions
+115
View File
@@ -431,3 +431,118 @@ def test_simulation(genetic_simulation):
), "The sum of 'Home_appliance_wh_per_hour' should be 2000."
print("All tests passed successfully.")
def _profile_simulation(
battery_curves: dict, initial_soc_percentage: int = 50
) -> tuple[GeneticSimulation, Battery]:
"""48 slots with a battery that has the given profile curves, if any."""
hours = 48
battery = Battery(
SolarPanelBatteryParameters(
device_id="battery1",
capacity_wh=8000,
initial_soc_percentage=initial_soc_percentage,
min_soc_percentage=10,
max_charge_power_w=6000,
max_discharge_power_w=4000,
charging_efficiency=0.95,
discharging_efficiency=0.9,
levelized_cost_of_storage_kwh=0.05,
**battery_curves,
),
prediction_hours=hours,
)
inverter = Inverter(
InverterParameters(device_id="inverter1", max_power_wh=10000, battery_id="battery1"),
battery=battery,
)
rng = np.random.default_rng(7)
simulation = GeneticSimulation()
simulation.prepare(
GeneticEnergyManagementParameters.model_validate(
dict(
pv_prognose_wh=(
np.clip(np.sin(np.linspace(0, 4 * np.pi, hours)), 0, None) * 6000
).tolist(),
strompreis_euro_pro_wh=rng.uniform(0.0002, 0.0004, hours).tolist(),
einspeiseverguetung_euro_pro_wh=0.00007,
preis_euro_pro_wh_akku=0.0001,
gesamtlast=rng.uniform(300, 2500, hours).tolist(),
)
),
optimization_hours=24,
prediction_hours=hours,
inverter=inverter,
)
return simulation, battery
def _run_plans(simulation: GeneticSimulation, battery: Battery) -> list[dict]:
rng = np.random.default_rng(11)
hours = battery.prediction_hours
results = []
for _ in range(5):
ac = rng.choice([0.0, 0.5, 1.0], hours)
dc = rng.choice([0.0, 1.0], hours)
discharge = rng.choice([0, 1], hours)
battery.reset()
simulation.ac_charge_hours = ac
simulation.dc_charge_hours = dc
simulation.bat_discharge_hours = discharge
battery.set_charge_per_hour(dc)
battery.set_discharge_per_hour(discharge)
results.append(simulation.simulate(start_hour=0))
return results
def test_flat_profile_curves_reproduce_plain_simulation(config_eos):
"""Constant profile tables and curves give exactly the plain battery result.
The battery with curves takes the generic discharge path of the inverter
and its own energy bookkeeping (LCOS); both must match the inlined path.
"""
config_eos.merge_settings_from_dict(
{"prediction": {"hours": 48}, "optimization": {"hours": 24}}
)
plain = _run_plans(*_profile_simulation({}))
flat = _run_plans(
*_profile_simulation(
dict(
charge_power_limit_w_by_soc=[6000.0] * 101,
discharge_power_limit_w_by_soc=[4000.0] * 101,
charging_efficiency_curve=[(0.0, 0.95), (1.0, 0.95)],
discharging_efficiency_curve=[(0.0, 0.9), (1.0, 0.9)],
)
)
)
for plain_result, flat_result in zip(plain, flat):
for key in ("akku_soc_pro_stunde", "Netzbezug_Wh_pro_Stunde", "Kosten_Euro_pro_Stunde"):
assert flat_result[key] == pytest.approx(plain_result[key]), key
assert flat_result["Gesamt_Verluste"] == pytest.approx(plain_result["Gesamt_Verluste"])
assert flat_result["Gesamtbilanz_Euro"] == pytest.approx(plain_result["Gesamtbilanz_Euro"])
def test_soc_taper_slows_charging_near_full(config_eos):
"""Above 80 % SoC the tapered charge limit applies, below it does not."""
config_eos.merge_settings_from_dict(
{"prediction": {"hours": 48}, "optimization": {"hours": 24}}
)
taper = [6000.0] * 81 + [500.0] * 20
results = {}
for name, table in (("flat", [6000.0] * 101), ("taper", taper)):
simulation, battery = _profile_simulation(
dict(charge_power_limit_w_by_soc=table), initial_soc_percentage=82
)
hours = battery.prediction_hours
battery.reset()
simulation.ac_charge_hours = np.ones(hours)
simulation.dc_charge_hours = np.ones(hours)
simulation.bat_discharge_hours = np.zeros(hours)
battery.set_charge_per_hour(np.ones(hours))
battery.set_discharge_per_hour(np.zeros(hours))
results[name] = simulation.simulate(start_hour=0)["akku_soc_pro_stunde"]
# The tapered battery takes at most 500 W x 1 h (before efficiency) per slot.
assert results["taper"][1] < results["flat"][1]
assert results["taper"][1] - results["taper"][0] <= 500.0 * 0.95 / 8000 * 100 + 1e-9