diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f8b9edc..64d8b789 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,7 +65,8 @@ compatibility changes and a short test procedure. These changes are not yet a ta cores, default on). For a fixed seed the plan is identical to a single-process run. - EOSdash setup assistant: the new **Setup** page explains the basic settings step by step, gives examples, reports what is still missing and shows the configuration cards of the - current step. First steps: location, horizons and energy management, save the configuration. + current step: location, horizons and energy management, PV system, weather, prices and fees, + load, devices, measurements, Home Assistant, save the configuration. ### Changed / compatibility diff --git a/src/akkudoktoreos/server/dash/configuration.py b/src/akkudoktoreos/server/dash/configuration.py index cf215259..fd80daaa 100644 --- a/src/akkudoktoreos/server/dash/configuration.py +++ b/src/akkudoktoreos/server/dash/configuration.py @@ -457,6 +457,12 @@ def config_options( # ----------------------------------- +def remember_eos_server(eos_host: str, eos_port: Union[str, int]) -> None: + """Remember the address of the EOS server for the lazily loaded options of config values.""" + global eos_server_address + eos_server_address = (eos_host, int(eos_port)) + + def config_update(eos_host: str, eos_port: Union[str, int], data: dict) -> None: """Set a configuration value on the EOS server. @@ -586,11 +592,11 @@ def Configuration( Returns: rows: Rows of configuration details. """ - global config_visible, eos_server_address + global config_visible dark = False # Remember for usage - eos_server_address = (eos_host, int(eos_port)) + remember_eos_server(eos_host, eos_port) if data and data.get("action", None): if data.get("dark", None) == "true": diff --git a/src/akkudoktoreos/server/dash/wizard.py b/src/akkudoktoreos/server/dash/wizard.py index c0603ada..6344cfe1 100644 --- a/src/akkudoktoreos/server/dash/wizard.py +++ b/src/akkudoktoreos/server/dash/wizard.py @@ -32,6 +32,7 @@ from monsterui.franken import ( ) from akkudoktoreos.config.config import ConfigEOS +from akkudoktoreos.config.configabc import is_home_assistant_addon from akkudoktoreos.server.dash.components import ( Badge, ConfigButton, @@ -43,6 +44,7 @@ from akkudoktoreos.server.dash.configuration import ( config_update, create_config_details, get_config, + remember_eos_server, ) from akkudoktoreos.server.dash.context import request_url_for from akkudoktoreos.server.dash.markdown import Markdown @@ -55,6 +57,23 @@ WIZARD_PATH = "/eosdash/wizard" # --------------------------------------------------------------------------- +@dataclass(frozen=True) +class WizardChoice: + """Configuration values a step shows depending on the value of another one. + + Typically the settings of the selected provider. + + Attributes: + name: Dotted name of the configuration value that decides, e.g. ``load.provider``. + Its value may also be a list of values. + config_names: Dotted names of the configuration values to show, by value of ``name``. + Values that need no further settings are left out. + """ + + name: str + config_names: dict[str, tuple[str, ...]] + + @dataclass(frozen=True) class WizardStep: """A step of the setup wizard. @@ -64,8 +83,9 @@ class WizardStep: title: Title of the step. intro: Explanation of the step (Markdown). example: Example for the settings of the step (Markdown). - config_names: Dotted names of the configuration values to show. ``{algorithm}`` is - replaced by the lower case name of the configured optimization algorithm. + config_names: Dotted names of the configuration values to show. + choices: Further configuration values to show depending on the value of another one. + They are shown right after the configuration value that decides. check: Function that takes the configuration details and returns what is still to do in this step, one sentence each. An empty list means the step is done. """ @@ -75,6 +95,7 @@ class WizardStep: intro: str example: str = "" config_names: tuple[str, ...] = () + choices: tuple[WizardChoice, ...] = () check: Optional[Callable[[dict[str, dict]], list[str]]] = None @@ -94,9 +115,17 @@ def config_value(config_details: dict[str, dict], name: str, default: Any = None def step_config_names(step: WizardStep, config_details: dict[str, dict]) -> list[str]: """Return the names of the configuration values a step shows for this configuration.""" - algorithm = str(config_value(config_details, "optimization.algorithm", "")).lower() - names = [name.format(algorithm=algorithm) for name in step.config_names] - return [name for name in names if name in config_details] + names: list[str] = [] + for name in step.config_names: + names.append(name) + for choice in step.choices: + if choice.name != name: + continue + value = config_value(config_details, name) + for chosen in value if isinstance(value, list) else [value]: + names.extend(choice.config_names.get(str(chosen), ())) + # A configuration value is shown once, even if several choices name it + return [name for name in dict.fromkeys(names) if name in config_details] # --------------------------------------------------------------------------- @@ -152,6 +181,199 @@ def check_horizon(config_details: dict[str, dict]) -> list[str]: return todo +# PV forecast providers that calculate the forecast from the planes of the PV system +PV_PLANES_PROVIDERS = ( + "PVForecastAkkudoktor", + "PVForecastAdaptiveAkkudoktor", + "PVForecastForecastSolar", + "PVForecastPVLib", +) + + +def _is_empty(value: Any) -> bool: + """Whether a configuration value is not set.""" + return value is None or value == "" or value == [] or value == {} + + +def _missing(config_details: dict[str, dict], *names: str) -> list[str]: + """Report the configuration values of the given names that are not set.""" + return [ + f"{name} is not set." for name in names if _is_empty(config_value(config_details, name)) + ] + + +def check_pv(config_details: dict[str, dict]) -> list[str]: + """Report what is still to do for the PV system.""" + provider = config_value(config_details, "pvforecast.provider") + if not provider: + return [ + "No PV forecast provider is selected, so EOS plans without PV yield. " + "Leave it like this only if you have no PV system." + ] + if provider in PV_PLANES_PROVIDERS: + planes = config_value(config_details, "pvforecast.planes") or [] + if not any(isinstance(plane, dict) and plane.get("peakpower") for plane in planes): + return [f"{provider} needs at least one plane with a peakpower in pvforecast.planes."] + elif provider == "PVForecastSolcast": + return _missing(config_details, "pvforecast.solcast.api_key", "pvforecast.solcast.site_id") + elif provider == "PVForecastPVNode": + return _missing(config_details, "pvforecast.pvnode.api_key") + return [] + + +def check_weather(config_details: dict[str, dict]) -> list[str]: + """Report what is still to do for the weather forecast.""" + if not config_value(config_details, "weather.provider"): + return ["No weather provider is selected."] + return [] + + +def check_prices(config_details: dict[str, dict]) -> list[str]: + """Report what is still to do for prices, fees and the feed-in tariff.""" + todo = [] + provider = config_value(config_details, "elecprice.provider") + if not provider: + todo.append( + "No electricity price provider is selected. EOS needs the price of electricity " + "from the grid to optimize." + ) + elif provider == "ElecPriceFixed": + todo += _missing( + config_details, "elecprice.elecpricefixed.elecprice_marketprice_amt_kwh.windows" + ) + elif provider == "ElecPriceTibber": + todo += _missing(config_details, "elecprice.tibber.access_token") + + if config_value(config_details, "elecfee.provider") == "ElecFeeFixed" and all( + _is_empty(config_value(config_details, f"elecfee.elecfeefixed.{name}.windows")) + for name in ( + "consumption_amt_kwh", + "consumption_percent_amt", + "feedin_amt_kwh", + "feedin_percent_amt", + ) + ): + todo.append("ElecFeeFixed is selected, but no fee is set.") + + provider = config_value(config_details, "feedintariff.provider") + if not provider: + todo.append( + "No feed-in tariff provider is selected. Select one if you are paid for the " + "energy you feed into the grid." + ) + elif provider == "FeedInTariffFixed": + todo += _missing( + config_details, "feedintariff.feedintarifffixed.feed_in_tariff_amt_kwh.windows" + ) + return todo + + +def check_load(config_details: dict[str, dict]) -> list[str]: + """Report what is still to do for the load forecast.""" + provider = config_value(config_details, "load.provider") + if not provider: + return [ + "No load provider is selected. EOS needs a forecast of your consumption to optimize." + ] + if provider in ("LoadAkkudoktor", "LoadAkkudoktorAdjusted"): + todo = _missing(config_details, "load.loadakkudoktor.loadakkudoktor_year_energy_kwh") + if provider == "LoadAkkudoktorAdjusted": + todo += _missing(config_details, "measurement.load_emr_keys") + return todo + return [] + + +def _device_ids(config_details: dict[str, dict], kind: str) -> list[str]: + """Return the ids of the configured devices of a kind, e.g. ``batteries``.""" + devices = config_value(config_details, f"devices.{kind}") + return list(devices) if isinstance(devices, dict) else [] + + +def check_devices(config_details: dict[str, dict]) -> list[str]: + """Report what is still to do for the devices.""" + if not any( + _device_ids(config_details, kind) + for kind in ("batteries", "electric_vehicles", "home_appliances") + ): + return [ + "No battery, electric vehicle or home appliance is configured, so there is " + "nothing EOS can schedule." + ] + # The packaged profiles are generic: the unit has to give its size + todo = [] + for kind, setting in ( + ("batteries", "nominal_capacity_wh"), + ("electric_vehicles", "nominal_capacity_wh"), + ("inverters", "nominal_power_w"), + ): + devices = config_value(config_details, f"devices.{kind}") or {} + for device_id, device in devices.items(): + if isinstance(device, dict) and device.get(f"effective_{setting}") is None: + todo.append( + f"{device_id} has no {setting}: its profile does not give one. Set " + f"profile_adjustment.{setting}, or select a profile that has it." + ) + return todo + + +def check_measurements(config_details: dict[str, dict]) -> list[str]: + """Report what is still to do for measurements and the home automation.""" + providers = config_value(config_details, "adapter.provider") or [] + device_ids = [ + device_id + for kind in ("batteries", "electric_vehicles") + for device_id in _device_ids(config_details, kind) + ] + if not providers: + devices = f" of {', '.join(device_ids)}" if device_ids else "" + return [ + "No adapter is selected, so no source for the state of charge" + f"{devices} is known. Select an adapter, or make sure your own system sends the " + "state of charge with PUT /v1/measurement/value." + ] + # The mapping of the Home Assistant adapter is checked in the Home Assistant step + return [] + + +def check_homeassistant(config_details: dict[str, dict]) -> list[str]: + """Report what is still to do for the Home Assistant adapter. + + Nothing is to do if the Home Assistant adapter is not selected. + """ + providers = config_value(config_details, "adapter.provider") or [] + if "HomeAssistant" not in providers: + return [] + + todo = [] + if not is_home_assistant_addon(): + # EOSdash is started by the EOS server and inherits its environment + todo.append( + "EOS does not run as a Home Assistant add-on, so the Home Assistant adapter " + "cannot reach Home Assistant. Run EOS as an add-on, or remove HomeAssistant " + "from adapter.provider and send the measurements with PUT /v1/measurement/value." + ) + + entity_ids = config_value(config_details, "adapter.homeassistant.device_measurement_entity_ids") + entity_ids = entity_ids if isinstance(entity_ids, dict) else {} + for kind in ("batteries", "electric_vehicles"): + for device_id in _device_ids(config_details, kind): + key = f"{device_id}-soc-factor" + if not entity_ids.get(key): + todo.append( + f"No Home Assistant entity is set for the state of charge of {device_id} " + f"({key}) in adapter.homeassistant.device_measurement_entity_ids." + ) + + if _is_empty( + config_value(config_details, "adapter.homeassistant.device_instruction_entity_ids") + ): + todo.append( + "adapter.homeassistant.device_instruction_entity_ids is not set, so EOS writes " + "no instructions to Home Assistant and your automations get nothing to act on." + ) + return todo + + # --------------------------------------------------------------------------- # Steps # --------------------------------------------------------------------------- @@ -230,12 +452,447 @@ Use `PREDICTION` while you are still setting up the forecasts and want to look a config_names=( "prediction.hours", "optimization.algorithm", - "optimization.{algorithm}.horizon_hours", "ems.mode", "ems.interval", ), + choices=( + WizardChoice( + "optimization.algorithm", + { + "GENETIC": ("optimization.genetic.horizon_hours",), + "GENETIC0": ("optimization.genetic0.horizon_hours",), + }, + ), + ), check=check_horizon, ), + WizardStep( + id="pv", + title="PV system", + intro=""" +EOS predicts the yield of your PV system. Select who makes the forecast with +`pvforecast.provider`; the settings the selected provider needs appear below it. + +- `PVForecastAkkudoktor`, `PVForecastAdaptiveAkkudoktor`, `PVForecastForecastSolar` and + `PVForecastPVLib` calculate the forecast from your location and the **planes** of your PV + system. A plane is a group of modules with the same orientation, e.g. one side of the roof. +- `PVForecastSolcast`, `PVForecastPVNode`, `PVForecastVrm` and `PVForecastHomeAssistant` take + the forecast from a service or system that already knows your PV system. They need the + access data of that service. +- `PVForecastImport` takes a forecast you provide as a file or as JSON. + +The most important settings of a plane: + +- `peakpower` - nominal power of the modules of the plane in kW. +- `surface_azimuth` - orientation, clockwise from north: north = 0, east = 90, south = 180, + west = 270. +- `surface_tilt` - tilt from the horizontal in degrees: 0 is flat, 90 is vertical. +- `inverter_paco` - AC power rating of the inverter in W. + +Skip this step if you have no PV system. +""", + example=""" +A south-facing roof with 8 kWp, 30° tilt and an 8 kW inverter: select +`PVForecastAkkudoktor`, open `pvforecast.planes`, add a plane and set `peakpower` = `8`, +`surface_azimuth` = `180`, `surface_tilt` = `30` and `inverter_paco` = `8000`. + +An east/west roof is two planes, one with `surface_azimuth` = `90` and one with `270`, each +with the peak power of its side. +""", + config_names=("pvforecast.provider",), + choices=( + WizardChoice( + "pvforecast.provider", + { + "PVForecastAkkudoktor": ("pvforecast.planes",), + "PVForecastAdaptiveAkkudoktor": ("pvforecast.planes",), + "PVForecastForecastSolar": ( + "pvforecast.planes", + "pvforecast.forecastsolar.api_key", + ), + "PVForecastPVLib": ("pvforecast.planes",), + "PVForecastPVNode": ( + "pvforecast.pvnode.api_key", + "pvforecast.pvnode.site_id", + "pvforecast.planes", + ), + "PVForecastSolcast": ( + "pvforecast.solcast.api_key", + "pvforecast.solcast.site_id", + ), + "PVForecastVrm": ("pvforecast.vrm.site_id", "pvforecast.vrm.token"), + "PVForecastHomeAssistant": ( + "pvforecast.homeassistant.entity_id", + "pvforecast.homeassistant.base_url", + "pvforecast.homeassistant.token", + ), + "PVForecastImport": ( + "pvforecast.pvforecastimport.import_file_path", + "pvforecast.pvforecastimport.import_json", + ), + }, + ), + ), + check=check_pv, + ), + WizardStep( + id="weather", + title="Weather", + intro=""" +The weather forecast gives EOS the outside temperature, the irradiance and the cloud cover +at your location. You see it on the **Prediction** page, and `PVForecastPVLib` calculates +the PV forecast from it. + +Select the service with `weather.provider`: `BrightSky`, `ClearOutside` or `OpenMeteo` +retrieve the forecast for your location, `WeatherImport` takes a forecast you provide. +""", + example=""" +To use Open-Meteo set `weather.provider` to `OpenMeteo`. The provider needs no further +settings; it takes the location from the **Location** step. +""", + config_names=("weather.provider",), + choices=( + WizardChoice( + "weather.provider", + { + "WeatherImport": ( + "weather.weatherimport.import_file_path", + "weather.weatherimport.import_json", + ), + }, + ), + ), + check=check_weather, + ), + WizardStep( + id="prices", + title="Prices and fees", + intro=""" +EOS optimizes costs, so it needs to know what electricity from the grid costs and what you +get for the energy you feed in. Three predictions work together: + +- `elecprice.provider` - the electricity price. The market price providers + (`ElecPriceAkkudoktor`, `ElecPriceEnergyCharts`, `ElecPriceSMARD`, `ElecPriceTibber`) + deliver the raw market price **without** fees. `ElecPriceFixed` uses prices you set for + time windows of the day. +- `elecfee.provider` - what is added to the raw price. With `ElecFeeFixed` you set a fee per + kWh (grid fee, levies, the margin of your supplier) and a surcharge in percent (VAT). The + price EOS uses is `(raw price + fee per kWh) * (100 + percent) / 100`. +- `feedintariff.provider` - what you get per kWh fed into the grid. `FeedInTariffFixed` uses + the tariff you set; the other providers use market prices, for direct marketing. + +Prices and fees are amounts of your currency per kWh, e.g. `0.30` for 30 ct/kWh. +""", + example=""" +A dynamic tariff in Germany: `elecprice.provider` = `ElecPriceEnergyCharts` with bidding zone +`DE-LU`, `elecfee.provider` = `ElecFeeFixed` with `consumption_amt_kwh` = `0.15` (all fees +per kWh without VAT) and `consumption_percent_amt` = `19` (VAT), each in one time window +that covers the whole day. + +A fixed tariff of 32 ct/kWh including everything: `elecprice.provider` = `ElecPriceFixed` +with one time window of `0.32`, and no `elecfee.provider`. + +A fixed feed-in tariff of 7.86 ct/kWh: `feedintariff.provider` = `FeedInTariffFixed` with one +time window of `0.0786`. +""", + config_names=("elecprice.provider", "elecfee.provider", "feedintariff.provider"), + choices=( + WizardChoice( + "elecprice.provider", + { + "ElecPriceEnergyCharts": ("elecprice.energycharts.bidding_zone",), + "ElecPriceFixed": ( + "elecprice.elecpricefixed.elecprice_marketprice_amt_kwh.windows", + ), + "ElecPriceImport": ( + "elecprice.elecpriceimport.import_file_path", + "elecprice.elecpriceimport.import_json", + ), + "ElecPriceSMARD": ("elecprice.smard.region", "elecprice.smard.filter_id"), + "ElecPriceTibber": ( + "elecprice.tibber.access_token", + "elecprice.tibber.home_id", + ), + }, + ), + WizardChoice( + "elecfee.provider", + { + "ElecFeeFixed": ( + "elecfee.elecfeefixed.consumption_amt_kwh.windows", + "elecfee.elecfeefixed.consumption_percent_amt.windows", + "elecfee.elecfeefixed.feedin_amt_kwh.windows", + "elecfee.elecfeefixed.feedin_percent_amt.windows", + ), + "ElecFeeImport": ( + "elecfee.elecfeeimport.import_file_path", + "elecfee.elecfeeimport.import_json", + ), + }, + ), + WizardChoice( + "feedintariff.provider", + { + "FeedInTariffDvhubOnline": ("feedintariff.dvhubonline.zone",), + "FeedInTariffEnergyCharts": ("feedintariff.energycharts.bidding_zone",), + "FeedInTariffFixed": ( + "feedintariff.feedintarifffixed.feed_in_tariff_amt_kwh.windows", + ), + "FeedInTariffImport": ( + "feedintariff.feedintariffimport.import_file_path", + "feedintariff.feedintariffimport.import_json", + ), + }, + ), + ), + check=check_prices, + ), + WizardStep( + id="load", + title="Load", + intro=""" +EOS needs a forecast of the electricity your household consumes. Select it with +`load.provider`: + +- `LoadAkkudoktor` - a generic load profile, scaled to your yearly consumption. +- `LoadAkkudoktorAdjusted` - the same profile, refined with your measured consumption. It + needs load measurements, see the **Measurements** step. +- `LoadVrm` - the forecast of the Victron Remote Management (VRM) portal. +- `LoadImport` - a forecast you provide as a file or as JSON. +""", + example=""" +A household that consumes 4500 kWh per year: `load.provider` = `LoadAkkudoktor` and +`load.loadakkudoktor.loadakkudoktor_year_energy_kwh` = `4500`. You find the yearly +consumption on your electricity bill. +""", + config_names=("load.provider",), + choices=( + WizardChoice( + "load.provider", + { + "LoadAkkudoktor": ("load.loadakkudoktor.loadakkudoktor_year_energy_kwh",), + "LoadAkkudoktorAdjusted": ( + "load.loadakkudoktor.loadakkudoktor_year_energy_kwh", + ), + "LoadVrm": ("load.vrm.site_id", "load.vrm.token"), + "LoadImport": ( + "load.loadimport.import_file_path", + "load.loadimport.import_json", + ), + }, + ), + ), + check=check_load, + ), + WizardStep( + id="devices", + title="Devices", + intro=""" +Tell EOS which devices it plans for. Each kind of device has its own card; open it to add a +device or to change one. + +- `devices.batteries` - stationary batteries. +- `devices.inverters` - inverters that connect PV and battery to the house. +- `devices.electric_vehicles` - the batteries of electric vehicles. +- `devices.home_appliances` - appliances EOS may shift in time, e.g. a dishwasher. + +Every device has a `device_id`. Choose a short name and keep it: EOS names the measurements +of the device after it, e.g. `battery1-soc-factor` for the state of charge of `battery1`. + +Batteries, electric vehicles and inverters take their technical data from a **profile**: +`battery_profile` (default `generic-lfp`) and `inverter_profile` (default `generic-hybrid`). +A profile holds the efficiency curves and the power limits of a model; the **Profiles** page +shows and edits them. Whatever differs for your unit goes into `profile_adjustment`. The +packaged profiles are generic and know no size, so set it there: + +- battery and electric vehicle: `profile_adjustment.nominal_capacity_wh` - capacity in Wh, + and optionally `profile_adjustment.max_charge_power_w` / + `profile_adjustment.max_discharge_power_w` - the limits of your installation in W. +- inverter: `profile_adjustment.nominal_power_w` - nominal AC power in W. + +Also important for a battery: `min_soc_percentage` and `max_soc_percentage` - the range of +the state of charge EOS may use. +""", + example=""" +**Battery** - a 10 kWh battery that charges with up to 5 kW and shall never go below 10 %: +add a battery with `device_id` = `battery1`, in `profile_adjustment` +`nominal_capacity_wh` = `10000` and `max_charge_power_w` = `5000`, and +`min_soc_percentage` = `10`, `max_soc_percentage` = `100`. + +**Inverter** - a 10 kW inverter the battery is connected to: add an inverter with +`device_id` = `inverter1`, `profile_adjustment.nominal_power_w` = `10000` and +`battery_id` = `battery1`. + +**Electric vehicle** - a car with a 60 kWh battery at an 11 kW wallbox that shall be charged +to 80 % by 7 o'clock in the morning: add an electric vehicle with `device_id` = `ev1`, in +`profile_adjustment` `nominal_capacity_wh` = `60000` and `max_charge_power_w` = `11000`, and +`min_soc_percentage` = `80` (for +an electric vehicle this is the state of charge to reach) and +`min_soc_deadline_datetime` = `2026-07-16T07:00:00` (the departure). + +**Home appliance** - a dishwasher that needs 1.5 kWh in a run of 3 hours and may run once +between 10:00 and 16:00: add a home appliance with `device_id` = `dishwasher`, +`consumption_wh` = `1500`, `duration_h` = `3` and +`time_windows` = `{"windows": [{"start_time": "10:00", "duration": "6 hours"}]}`. +""", + config_names=( + "devices.batteries", + "devices.inverters", + "devices.electric_vehicles", + "devices.home_appliances", + ), + check=check_devices, + ), + WizardStep( + id="measurements", + title="Measurements", + intro=""" +To plan a battery or an electric vehicle EOS must know its **current state of charge**. The +measurement is named `-soc-factor` and is a factor from `0.0` (empty) to `1.0` +(full) - not a percentage. It must be fresh: the GENETIC optimization does not use a state +of charge older than `optimization.genetic.measurement_max_age_seconds` and then assumes an +**empty** battery (0 %). + +There are two ways to get measurements into EOS: + +- An adapter, selected with `adapter.provider`. With `HomeAssistant` EOS reads the + measurements from Home Assistant entities before each energy management run and writes + its instructions back to Home Assistant - this needs EOS to run as a Home Assistant + add-on, the next step explains it. With `NodeRED` EOS sends its instructions to a + Node-RED flow; the measurements still have to come from your flow. +- Your own system sends each value to the EOS server with `PUT /v1/measurement/value`. + +Energy meter readings are optional. They are meter readings in kWh that only count up, not +the current power. With `LoadAkkudoktorAdjusted` EOS uses the load meter readings to adapt +the load forecast to your household; list their measurement keys in +`measurement.load_emr_keys`. The Home Assistant adapter does this on its own for the +entities it reads. +""", + example=""" +Your own system, e.g. a Node-RED flow or a script, at least every five minutes: +`PUT /v1/measurement/value?datetime=2026-01-31T12:00:00&key=battery1-soc-factor&value=0.55` + +Home Assistant: select `HomeAssistant` in `adapter.provider` and continue with the next +step. +""", + config_names=( + "adapter.provider", + "measurement.load_emr_keys", + "optimization.genetic.measurement_max_age_seconds", + ), + choices=( + WizardChoice( + "adapter.provider", + { + "NodeRED": ("adapter.nodered.host", "adapter.nodered.port"), + }, + ), + ), + check=check_measurements, + ), + WizardStep( + id="homeassistant", + title="Home Assistant", + intro=""" +Skip this step if you do not use Home Assistant. + +**EOS must run as a Home Assistant add-on.** The Home Assistant adapter talks to Home +Assistant through the API of the add-on system, with the access Home Assistant grants the +add-on. There is no setting for an address or a token. If EOS runs somewhere else, remove +`HomeAssistant` from `adapter.provider` and let Home Assistant send the measurements with +`PUT /v1/measurement/value`, e.g. with a `rest_command`. + +Select `HomeAssistant` in `adapter.provider` (step **Measurements**). Before each energy +management run EOS then **reads** from Home Assistant: + +- `device_measurement_entity_ids` - the current state of your devices, above all the state + of charge: map `-soc-factor` to an entity whose state is a factor from `0.0` to + `1.0`. Without it EOS plans with an empty battery. +- `load_emr_entity_ids`, `pv_production_emr_entity_ids`, `grid_import_emr_entity_ids`, + `grid_export_emr_entity_ids` - optional energy meter readings in **kWh** that only count + up (`state_class: total_increasing`), not power sensors in W. EOS uses the entity IDs as + measurement keys and adds them to `measurement.*_emr_keys` on its own. +- `config_entity_ids` - optional: configuration values taken from entities, e.g. a battery + capacity an integration reports. The key is the path of the setting with `/`, and devices + are addressed by their `device_id`: + `devices/batteries/battery1/profile_adjustment/nominal_capacity_wh`. + +After each run EOS **writes** to Home Assistant: + +- `device_instruction_entity_ids` - one entity per device, named `sensor.eos_`, + e.g. `sensor.eos_battery1`. The state is the operation mode EOS wants right now in lower + case, e.g. `self_consumption`, `forced_charge` or `idle`; the attribute + `operation_mode_factor` says how much, from `0.0` to `1.0`. +- `solution_entity_ids` - optional values of the current plan for dashboards and debugging, + named `sensor.eos_`, e.g. `sensor.eos_battery1_soc_factor`. + +EOS only writes the entities you list. The **Config** page shows read-only lists to pick +from: `adapter.homeassistant.homeassistant_entity_ids` has the entities of Home Assistant, +`adapter.homeassistant.eos_device_instruction_entity_ids` and +`adapter.homeassistant.eos_solution_entity_ids` have the names EOS can write. The +instruction names appear after the first optimization. + +**EOS does not switch anything itself.** Write an automation in Home Assistant that reacts to +`sensor.eos_` and controls your inverter, wallbox or appliance. The entities EOS +writes have no unique ID: to use them in dashboards or the history, wrap them in template +sensors. +""", + example=""" +A battery `battery1` whose integration reports the state of charge in percent as +`sensor.battery_soc`. Create a template sensor in Home Assistant that converts it: + +```yaml +template: + - sensor: + - name: "Battery1 SoC Factor" + unique_id: "battery1_soc_factor" + state: "{{ states('sensor.battery_soc') | float(0) / 100 }}" +``` + +In EOS set: + +- `device_measurement_entity_ids` = `{"battery1-soc-factor": "sensor.battery1_soc_factor"}` +- `load_emr_entity_ids` = `["sensor.house_consumption_total_kwh"]` +- `device_instruction_entity_ids` = `["sensor.eos_battery1"]` + +Then let Home Assistant follow the instruction, e.g. charge the battery from the grid while +EOS asks for it (replace the actions with the ones of your inverter integration): + +```yaml +automation: + - alias: "EOS battery1 instruction" + triggers: + - trigger: state + entity_id: sensor.eos_battery1 + actions: + - choose: + - conditions: "{{ is_state('sensor.eos_battery1', 'forced_charge') }}" + sequence: + - action: select.select_option + target: + entity_id: select.inverter_battery_mode + data: + option: "Charge from grid" + default: + - action: select.select_option + target: + entity_id: select.inverter_battery_mode + data: + option: "Self consumption" +``` +""", + config_names=( + "adapter.homeassistant.device_measurement_entity_ids", + "adapter.homeassistant.load_emr_entity_ids", + "adapter.homeassistant.pv_production_emr_entity_ids", + "adapter.homeassistant.grid_import_emr_entity_ids", + "adapter.homeassistant.grid_export_emr_entity_ids", + "adapter.homeassistant.config_entity_ids", + "adapter.homeassistant.device_instruction_entity_ids", + "adapter.homeassistant.solution_entity_ids", + ), + check=check_homeassistant, + ), WizardStep( id="finish", title="Finish", @@ -247,14 +904,7 @@ EOS saves changes of the configuration to the configuration file on its own if are lost when EOS restarts unless you save them. **Save configuration** writes the configuration file right now in both modes. -This assistant does not cover everything yet. Set up the following on the **Config** page: - -- the PV system (`pvforecast`), -- electricity prices, grid fees and the feed-in tariff (`elecprice`, `elecfee`, - `feedintariff`), -- the load forecast (`load`), -- battery, inverter, electric vehicle and home appliances (`devices`), -- measurements and the connection to your home automation (`measurement`, `adapter`). +All other settings of EOS are on the **Config** page. Then look at the **Prediction** page to check the forecasts and at the **Plan** page for the result of the optimization. @@ -387,6 +1037,9 @@ def Wizard( data = data or {} step = wizard_step(data.get("wizard_step")) + # The lazily loaded options of some configuration cards are fetched from the EOS server + remember_eos_server(eos_host, eos_port) + save_status = None action = data.get("action") if action == "update": diff --git a/tests/test_eosdashbrowser.py b/tests/test_eosdashbrowser.py index fb4a45fb..8f578365 100644 --- a/tests/test_eosdashbrowser.py +++ b/tests/test_eosdashbrowser.py @@ -201,3 +201,21 @@ class TestEOSdashInBrowser: assert dash.page.locator("#wizard").count() == 1 assert "Step 2 of" in dash.page_content() assert dash.errors == [] + + def test_setup_wizard_shows_provider_settings(self, dash: DashPage) -> None: + """Selecting a provider in the setup wizard shows its settings, which work in the wizard.""" + assert dash.open_tab("/eosdash/wizard") == 200 + dash.page.click("#wizard-step-pv") + dash.page.wait_for_selector("#wizard[hx-vals*='\"pv\"']") + + dash.page.click("#wizard summary:has-text('pvforecast.provider')") + dash.page.select_option("#pvforecast-provider-value-select", "PVForecastAkkudoktor") + dash.page.click("#pvforecast-provider-update-value-form button") + planes = "#wizard summary:has-text('pvforecast.planes')" + dash.page.wait_for_selector(planes) + + # The card of a list of items sends its updates to the wizard, too + dash.page.click(f"{planes} button:has-text('Add item')") + dash.page.wait_for_selector(f"{planes} >> text=1 item") + assert dash.page.locator("#wizard[hx-vals*='\"pv\"']").count() == 1 + assert dash.errors == [] diff --git a/tests/test_eosdashwizard.py b/tests/test_eosdashwizard.py index 5b55b31a..eb908421 100644 --- a/tests/test_eosdashwizard.py +++ b/tests/test_eosdashwizard.py @@ -10,10 +10,18 @@ from akkudoktoreos.optimization.optimization import OptimizationAlgorithm from akkudoktoreos.server.dash import wizard from akkudoktoreos.server.dash.configuration import create_config_details from akkudoktoreos.server.dash.wizard import ( + PV_PLANES_PROVIDERS, WIZARD_STEPS, Wizard, + check_devices, + check_homeassistant, check_horizon, + check_load, check_location, + check_measurements, + check_prices, + check_pv, + check_weather, config_value, step_config_names, step_todo, @@ -50,21 +58,76 @@ class TestWizardSteps: assert step.title assert step.intro.strip() - @pytest.mark.parametrize("algorithm", [algorithm.value for algorithm in OptimizationAlgorithm]) - def test_config_names_exist(self, config_details, algorithm): + def test_config_names_exist(self, config_details): """A renamed configuration value must not silently drop out of the wizard.""" - set_value(config_details, "optimization.algorithm", algorithm) for step in WIZARD_STEPS: - assert len(step_config_names(step, config_details)) == len(step.config_names), ( - f"Step '{step.id}' names unknown configuration values for {algorithm}." - ) - - def test_config_names_are_writable(self, config_details): - for step in WIZARD_STEPS: - for name in step_config_names(step, config_details): + names = list(step.config_names) + for choice in step.choices: + assert choice.name in step.config_names, f"{step.id}: {choice.name}" + for chosen in choice.config_names.values(): + names.extend(chosen) + for name in names: + assert name in config_details, f"Step {step.id}: unknown {name}." assert config_details[name]["read-only"] == "rw", name assert not config_details[name]["deprecated"], name + def test_choice_values_exist(self, config_details): + """A renamed provider must not silently lose its settings in the wizard.""" + for step in WIZARD_STEPS: + for choice in step.choices: + if choice.name == "optimization.algorithm": + available = [algorithm.value for algorithm in OptimizationAlgorithm] + else: + assert choice.name.endswith(".provider") + available = config_value(config_details, f"{choice.name}s") + for value in choice.config_names: + assert value in available, f"Step {step.id}: unknown {value}." + + def test_pv_planes_providers_exist(self, config_details): + available = config_value(config_details, "pvforecast.providers") + for provider in PV_PLANES_PROVIDERS: + assert provider in available + + def test_choice_follows_value(self, config_details): + step = wizard_step("load") + set_value(config_details, "load.provider", "LoadVrm") + assert step_config_names(step, config_details) == [ + "load.provider", + "load.vrm.site_id", + "load.vrm.token", + ] + set_value(config_details, "load.provider", None) + assert step_config_names(step, config_details) == ["load.provider"] + + def test_choice_with_list_value(self, config_details): + step = wizard_step("measurements") + set_value(config_details, "adapter.provider", ["HomeAssistant", "NodeRED"]) + names = step_config_names(step, config_details) + assert "adapter.nodered.host" in names + assert "adapter.nodered.port" in names + assert len(names) == len(set(names)) + + def test_homeassistant_step_shows_all_adapter_settings(self, config_details): + """Every writable setting of the Home Assistant adapter is in its step.""" + adapter_names = [ + name + for name, details in config_details.items() + if name.startswith("adapter.homeassistant.") + and name.count(".") == 2 + and details["read-only"] == "rw" + and not details["deprecated"] + ] + assert adapter_names + names = step_config_names(wizard_step("homeassistant"), config_details) + assert sorted(names) == sorted(adapter_names) + + @pytest.mark.parametrize("algorithm", [algorithm.value for algorithm in OptimizationAlgorithm]) + def test_horizon_follows_algorithm(self, config_details, algorithm): + set_value(config_details, "optimization.algorithm", algorithm) + names = step_config_names(wizard_step("horizon"), config_details) + horizons = [name for name in names if name.endswith(".horizon_hours")] + assert horizons == [f"optimization.{algorithm.lower()}.horizon_hours"] + def test_wizard_step(self): assert wizard_step("location").id == "location" assert wizard_step(None) is WIZARD_STEPS[0] @@ -123,6 +186,201 @@ class TestWizardChecks: assert len(todo) == 1 assert f"optimization.{algorithm.lower()}.horizon_hours (24)" in todo[0] + def test_pv(self, config_details): + set_value(config_details, "pvforecast.provider", None) + assert "no PV system" in check_pv(config_details)[0] + + set_value(config_details, "pvforecast.provider", "PVForecastAkkudoktor") + set_value(config_details, "pvforecast.planes", None) + assert "at least one plane" in check_pv(config_details)[0] + set_value(config_details, "pvforecast.planes", [{"peakpower": None}]) + assert "at least one plane" in check_pv(config_details)[0] + set_value(config_details, "pvforecast.planes", [{"peakpower": 8.0}]) + assert check_pv(config_details) == [] + + set_value(config_details, "pvforecast.provider", "PVForecastSolcast") + set_value(config_details, "pvforecast.solcast.api_key", "") + set_value(config_details, "pvforecast.solcast.site_id", "abcd") + assert check_pv(config_details) == ["pvforecast.solcast.api_key is not set."] + + set_value(config_details, "pvforecast.provider", "PVForecastVrm") + assert check_pv(config_details) == [] + + def test_weather(self, config_details): + set_value(config_details, "weather.provider", None) + assert len(check_weather(config_details)) == 1 + set_value(config_details, "weather.provider", "BrightSky") + assert check_weather(config_details) == [] + + def test_prices(self, config_details): + set_value(config_details, "elecprice.provider", None) + set_value(config_details, "elecfee.provider", None) + set_value(config_details, "feedintariff.provider", None) + todo = check_prices(config_details) + assert len(todo) == 2 + assert "electricity price provider" in todo[0] + assert "feed-in tariff provider" in todo[1] + + set_value(config_details, "elecprice.provider", "ElecPriceEnergyCharts") + set_value(config_details, "feedintariff.provider", "FeedInTariffEnergyCharts") + assert check_prices(config_details) == [] + + set_value(config_details, "elecprice.provider", "ElecPriceFixed") + set_value(config_details, "elecfee.provider", "ElecFeeFixed") + set_value(config_details, "feedintariff.provider", "FeedInTariffFixed") + for name in ( + "elecprice.elecpricefixed.elecprice_marketprice_amt_kwh.windows", + "elecfee.elecfeefixed.consumption_amt_kwh.windows", + "elecfee.elecfeefixed.consumption_percent_amt.windows", + "elecfee.elecfeefixed.feedin_amt_kwh.windows", + "elecfee.elecfeefixed.feedin_percent_amt.windows", + "feedintariff.feedintarifffixed.feed_in_tariff_amt_kwh.windows", + ): + set_value(config_details, name, []) + assert len(check_prices(config_details)) == 3 + + set_value(config_details, "elecfee.elecfeefixed.consumption_percent_amt.windows", [{}]) + assert len(check_prices(config_details)) == 2 + + def test_load(self, config_details): + set_value(config_details, "load.provider", None) + assert len(check_load(config_details)) == 1 + + set_value(config_details, "load.provider", "LoadAkkudoktor") + set_value(config_details, "load.loadakkudoktor.loadakkudoktor_year_energy_kwh", None) + assert len(check_load(config_details)) == 1 + set_value(config_details, "load.loadakkudoktor.loadakkudoktor_year_energy_kwh", 4500) + assert check_load(config_details) == [] + + set_value(config_details, "load.provider", "LoadAkkudoktorAdjusted") + set_value(config_details, "measurement.load_emr_keys", None) + assert check_load(config_details) == ["measurement.load_emr_keys is not set."] + + def test_devices(self, config_details): + for kind in ("batteries", "electric_vehicles", "home_appliances"): + set_value(config_details, f"devices.{kind}", None) + assert len(check_devices(config_details)) == 1 + set_value( + config_details, + "devices.batteries", + {"battery1": {"device_id": "battery1", "effective_nominal_capacity_wh": 10000}}, + ) + assert check_devices(config_details) == [] + + def test_devices_without_size(self, config_details): + """The packaged profiles are generic: each unit needs its size.""" + set_value( + config_details, + "devices.batteries", + {"battery1": {"device_id": "battery1", "effective_nominal_capacity_wh": None}}, + ) + set_value( + config_details, + "devices.electric_vehicles", + {"ev1": {"device_id": "ev1", "effective_nominal_capacity_wh": 60000}}, + ) + set_value( + config_details, + "devices.inverters", + {"inverter1": {"device_id": "inverter1", "effective_nominal_power_w": None}}, + ) + todo = check_devices(config_details) + assert len(todo) == 2 + assert "battery1 has no nominal_capacity_wh" in todo[0] + assert "profile_adjustment.nominal_capacity_wh" in todo[0] + assert "inverter1 has no nominal_power_w" in todo[1] + + def test_devices_size_of_a_configured_device(self, config_eos): + """The check reads the effective size as the EOS server reports it.""" + config_eos.merge_settings_from_dict( + { + "devices": { + "max_batteries": 1, + "max_inverters": 1, + "batteries": {"battery1": {"device_id": "battery1"}}, + "inverters": { + "inverter1": { + "device_id": "inverter1", + "profile_adjustment": {"nominal_power_w": 10000}, + } + }, + } + } + ) + details = create_config_details( + type(config_eos), json.loads(config_eos.model_dump_json()) + ) + todo = check_devices(details) + assert len(todo) == 1 + assert todo[0].startswith("battery1 has no nominal_capacity_wh") + + def test_measurements(self, config_details): + set_value(config_details, "devices.batteries", {"battery1": {}}) + set_value(config_details, "devices.electric_vehicles", {"ev1": {}}) + + # Without an adapter no source for the SoC is known + set_value(config_details, "adapter.provider", None) + todo = check_measurements(config_details) + assert len(todo) == 1 + assert "No adapter is selected" in todo[0] + assert "of battery1, ev1 is known" in todo[0] + + # The mapping of the Home Assistant adapter is checked in its own step + for providers in (["NodeRED"], ["HomeAssistant"]): + set_value(config_details, "adapter.provider", providers) + assert check_measurements(config_details) == [] + + def test_homeassistant_not_selected(self, config_details, monkeypatch): + monkeypatch.setattr(wizard, "is_home_assistant_addon", lambda: False) + set_value(config_details, "devices.batteries", {"battery1": {}}) + for providers in (None, ["NodeRED"]): + set_value(config_details, "adapter.provider", providers) + assert check_homeassistant(config_details) == [] + + def test_homeassistant(self, config_details, monkeypatch): + monkeypatch.setattr(wizard, "is_home_assistant_addon", lambda: True) + set_value(config_details, "devices.batteries", {"battery1": {}}) + set_value(config_details, "devices.electric_vehicles", {"ev1": {}}) + set_value(config_details, "adapter.provider", ["HomeAssistant"]) + measurements = "adapter.homeassistant.device_measurement_entity_ids" + instructions = "adapter.homeassistant.device_instruction_entity_ids" + + set_value(config_details, measurements, None) + set_value(config_details, instructions, None) + todo = check_homeassistant(config_details) + assert len(todo) == 3 + assert "battery1-soc-factor" in todo[0] + assert "ev1-soc-factor" in todo[1] + assert instructions in todo[2] + + set_value(config_details, measurements, {"battery1-soc-factor": "sensor.battery_soc"}) + set_value(config_details, instructions, ["sensor.eos_battery1"]) + todo = check_homeassistant(config_details) + assert len(todo) == 1 + assert "ev1-soc-factor" in todo[0] + + set_value( + config_details, + measurements, + {"battery1-soc-factor": "sensor.battery_soc", "ev1-soc-factor": "sensor.ev_soc"}, + ) + assert check_homeassistant(config_details) == [] + + def test_homeassistant_outside_of_addon(self, config_details, monkeypatch): + """The adapter cannot reach Home Assistant outside of an add-on.""" + monkeypatch.setattr(wizard, "is_home_assistant_addon", lambda: False) + set_value(config_details, "adapter.provider", ["HomeAssistant"]) + set_value(config_details, "adapter.homeassistant.device_instruction_entity_ids", ["x"]) + todo = check_homeassistant(config_details) + assert len(todo) == 1 + assert "add-on" in todo[0] + + def test_measurements_fresh_system(self, config_details): + """A fresh system has no adapter and no devices: the step must not look done.""" + todo = check_measurements(config_details) + assert len(todo) == 1 + assert "state of charge is known" in todo[0] + def test_step_without_check_has_nothing_to_do(self, config_details): assert step_todo(wizard_step("start"), config_details) == [] @@ -152,6 +410,12 @@ class TestWizardPage: assert 'id="wizard-next"' in html assert 'id="wizard-back"' not in html + @pytest.mark.parametrize("step_id", [step.id for step in WIZARD_STEPS]) + def test_every_step_renders(self, eos, step_id): + html = to_xml(Wizard("localhost", 8503, {"wizard_step": step_id})) + number = [step.id for step in WIZARD_STEPS].index(step_id) + 1 + assert f"Step {number} of {len(WIZARD_STEPS)}: {wizard_step(step_id).title}" in html + def test_step_shows_only_its_config(self, eos): html = to_xml(Wizard("localhost", 8503, {"wizard_step": "location"})) assert "Location" in html @@ -172,11 +436,11 @@ class TestWizardPage: assert page.attrs["id"] == "wizard" assert json.loads(page.attrs["hx-vals"]) == {"wizard_step": "horizon"} - def test_horizon_step_follows_algorithm(self, eos, config): - config["optimization"]["algorithm"] = "GENETIC0" - html = to_xml(Wizard("localhost", 8503, {"wizard_step": "horizon"})) - assert "optimization.genetic0.horizon_hours" in html - assert "optimization.genetic.horizon_hours" not in html + def test_provider_settings_are_shown(self, eos, config): + config["pvforecast"]["provider"] = "PVForecastSolcast" + html = to_xml(Wizard("localhost", 8503, {"wizard_step": "pv"})) + assert "pvforecast.solcast.api_key" in html + assert "pvforecast.vrm.token" not in html def test_update(self, eos): data = { @@ -202,3 +466,38 @@ class TestWizardPage: html = to_xml(Wizard("localhost", 8503, {"wizard_step": "finish", "action": "save_to_file"})) assert eos["save"] == [("localhost", 8503)] assert "saved-by-test" in html + + def test_devices_step_has_an_example_for_every_device_type(self, eos): + html = to_xml(Wizard("localhost", 8503, {"wizard_step": "devices"})) + for device_type in ("Battery", "Inverter", "Electric vehicle", "Home appliance"): + assert f"{device_type}" in html + + def test_devices_step_names_existing_settings(self, eos): + """The device settings the step names exist (they moved into profiles before).""" + from akkudoktoreos.devices.settings.batterysettings import ( + BatteriesCommonSettings, + BatteryProfileAdjustment, + ) + from akkudoktoreos.devices.settings.invertersettings import ( + InverterCommonSettings, + InverterProfileAdjustment, + ) + + html = to_xml(Wizard("localhost", 8503, {"wizard_step": "devices"})) + for model, names in ( + (BatteriesCommonSettings, ("battery_profile", "min_soc_percentage")), + (BatteryProfileAdjustment, ("nominal_capacity_wh", "max_charge_power_w")), + (InverterCommonSettings, ("inverter_profile", "battery_id")), + (InverterProfileAdjustment, ("nominal_power_w",)), + ): + for name in names: + assert name in model.model_fields + assert f"{name}" in html or f"profile_adjustment.{name}" in html + for stale in ("capacity_wh", "charging_efficiency", "max_power_w"): + assert stale not in html + + def test_homeassistant_step_example_renders_yaml(self, eos): + html = to_xml(Wizard("localhost", 8503, {"wizard_step": "homeassistant"})) + assert "