diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 4fc2f13886..f98f6bb3d5 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -12,7 +12,9 @@ v1.0.0 | July XX, 2026 New features ------------- + * Breaking behaviour change: the top-level flex-context's ``relax-constraints`` field now defaults to ``True`` (matching the default already used within each ``commodities`` entry), so constraint violations are softly penalized by default instead of being hard constraints, unless explicitly set to ``False`` [see `PR #2172 `_] +* Support for creating new assets by using another asset as a template from the UI. [see `PR #2195 `_ and `PR #2268 `_ * In the UI, asset and sensor lists can be filtered by ID prefix through API-backed search fields [see `PR #2231 `_] * Floor off-clock API datetimes to a non-instantaneous sensor's resolution by default when ingesting sensor data, uploading sensor data, and handling scheduler flex-model timed events; configurable with the ``floor_datetimes_to_resolution`` sensor attribute [see `PR #2146 `_] * Sensor references in flex-model and flex-context support various ways of filtering by source [see `PR #2209 `_] @@ -81,7 +83,6 @@ New features * New ``GET /api/v3_0/sources`` endpoint to list accessible data sources and defined types, with ``only_latest=true`` by default to return only the most recent version per source [see `PR #2126 `_] * Add support for filtering sensor data GET requests by ``source-type`` on ``/api/v3_0/sensors//data`` [see `PR #2127 `_] * Making monitoring alerts more flexible: allow ``flexmeasures monitor`` alerts to target one or more user IDs or email addresses with ``--recipient``; ``flexmeasures monitor last-seen`` can now narrow monitored users to one or more accounts with ``--account`` or to client accounts with ``--consultancy`` [see `PR #2158 `_] -* Support for creating new assets by using another asset as a template from the UI. [see `PR #2195 `_] * Improve LightGBM daily seasonal lag handling for sub-hourly forecasting sensors [see `PR #2157 `_] Infrastructure / Support diff --git a/documentation/configuration.rst b/documentation/configuration.rst index 1c79235e6a..fd6ee7c4f4 100644 --- a/documentation/configuration.rst +++ b/documentation/configuration.rst @@ -781,6 +781,25 @@ When ``FLEXMEASURES_MODE=demo``\ , this can hold login credentials (demo user em Default: ``None`` +FLEXMEASURES_CREATE_TEMPLATE_ASSETS_ON_STARTUP +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Whether FlexMeasures should create its built-in starter template assets when the +application starts. + +If ``True``, FlexMeasures provisions a small set of public starter templates, +such as ``Battery Template``, ``EV Charger Template`` and ``Heat Pump Template``, +if they do not exist yet. Together with the asset copy workflow, new users will +find FlexMeasures to be more useful out of the box. + +If one of these built-in templates is deleted while this setting remains +``True``, FlexMeasures will recreate the missing template on the next +application startup. + +If ``False``, no template assets are created automatically at startup. + +Default: ``True`` + .. _sunset-config: Sunset diff --git a/documentation/getting-started.rst b/documentation/getting-started.rst index 30f7959467..6f2aaf23ef 100644 --- a/documentation/getting-started.rst +++ b/documentation/getting-started.rst @@ -10,6 +10,7 @@ Below, we added helpful pointers to start reading. :local: :depth: 2 +| .. _start_using_flexmeasures_in_your_organization: @@ -20,11 +21,31 @@ We make FlexMeasures, so that your software developers are as productive with en Your journey, from dipping your toes in the water towards being a productive energy optimization company, could look like this: -1. Quickstart ― Find an optimized schedule for your flexible asset, like a battery, with standard FlexMeasures tooling. This is basically what we show in :ref:`tut_toy_schedule`. All you need are 10 minutes and a CSV file with prices to optimize against. -2. Automate ― get the prices from an open API, for instance `ENTSO-E `_ (using a plugin like `flexmeasures-entsoe `_), and run the scheduler regularly in a cron job. -3. Integrate ― Load the schedules via FlexMeasures' API, so you can directly control your assets and/or show them within your own frontend. -4. Customize ― Load other data (e.g. add weather forecasts via `flexmeasures-weather `_, which can help you better forecast local production or consumption). Adapt the data dashboards to your liking. Play with flex-context and flex-model settings. In your own plugin, add your custom pages or endpoints or even adapt the algorithms. For example, do your own forecasting or tweak the standard scheduling algorithm so it optimizes what you care about. Or write a plugin for accessing a new kind of market. The opportunities are endless! +1. Quickstart +^^^^^^^^^^^^^^^^^^^ + +Find an optimized schedule for your flexible asset, like a battery, with standard FlexMeasures tooling. This is basically what we show in :ref:`tut_toy_schedule`. All you need are 10 minutes and a CSV file with prices to optimize against. + +2. Automate +^^^^^^^^^^^^^^^^^^^ + +Get the prices from an open API, for instance `ENTSO-E `_ (using a plugin like `flexmeasures-entsoe `_), and run the scheduler regularly in a cron job. Do more, like automating forecasts, too. And for scale, copy your successfully-tested asset programmatically (or in the UI), to set up new customers quickly, e.g. as you are servicing the same kind of site often. + +3. Integrate +^^^^^^^^^^^^^^^^^^^ + +Download the schedules via FlexMeasures' API, so you can directly control your assets within your IoT/gateway. +You can also download forecasts, schedules and reports to show them within your own frontend. + +4. Customize +^^^^^^^^^^^^^^^^^^^ + +The opportunities are endless! +- Load other data (e.g. add weather forecasts via `flexmeasures-weather `_, which can help you better forecast local production or consumption). +- Adapt the data dashboards to your liking. Play with flex-context and flex-model settings. +- In your own plugin, write your own KPI reporter, add your custom pages or endpoints or even adapt the algorithms. For example, do your own forecasting or tweak the standard scheduling algorithm so it optimizes what you care about. +- Or write a plugin for accessing a new kind of market. diff --git a/documentation/plugin/customisation.rst b/documentation/plugin/customisation.rst index 003a92dc34..9cdf5d5001 100644 --- a/documentation/plugin/customisation.rst +++ b/documentation/plugin/customisation.rst @@ -288,6 +288,17 @@ In the same way, you can customize the siblings that are shown as drop-down for For this, the attribute is named "breadcrumb_siblings" and follows the same syntax. One use case might be to set it to empty (``[]``). +Creating your own template assets (for scaling setups) +----------------------------------------------------------- + +In FlexMeasures, you can create new assets by copying existing ones. This can be great for repeating a successful setup, +e.g. as you are servicing the same kind of site often. + +- You can manually set up a well-working asset, with flex-config, sensors, child assets etc and then copy it in the UI. +- You can also copy it via the API endpoint for asset copying, and make that part of your workflow. +- Finally, you could script the template. Your plugin can make sure on startup that custom template assets exist, programmatically. For FlexMeasures' own initial templates, which are scripted on startups, you might find the code in ``flexmeasures/data/scripts/data_gen.py::_ensure_public_root_asset()`` useful. + + Validating arguments in your CLI commands with marshmallow ----------------------------------------------------------- diff --git a/flexmeasures/api/common/utils/api_utils.py b/flexmeasures/api/common/utils/api_utils.py index 2d7e43b48e..31c879634a 100644 --- a/flexmeasures/api/common/utils/api_utils.py +++ b/flexmeasures/api/common/utils/api_utils.py @@ -30,6 +30,7 @@ SAVE_TO_DB_SUCCESS, SAVE_TO_DB_SUCCESS_BUT_NOTHING_NEW, SAVE_TO_DB_SUCCESS_WITH_UNCHANGED_BELIEFS_SKIPPED, + TEMPLATE_COPY_GUIDANCE_PREFIX, ) from flexmeasures.auth.policy import check_access from flexmeasures.api.common.responses import ( @@ -260,6 +261,34 @@ def convert_asset_json_fields(asset_kwargs): return asset_kwargs +def _remove_template_copy_guidance(description: str | None) -> str | None: + """Strip template-specific copy instructions from an asset description.""" + if not description: + return description + + cleaned_description = re.sub( + rf"\s*{re.escape(TEMPLATE_COPY_GUIDANCE_PREFIX)}(?: asset)?\b.*?(?:\.\s*|$)", + "", + description, + flags=re.IGNORECASE, + ).strip() + return cleaned_description or None + + +def _sanitize_copied_asset_kwargs(asset_kwargs: dict) -> dict: + """Turn a copied template into a regular asset payload.""" + attributes = asset_kwargs.get("attributes") + if isinstance(attributes, dict) and "template" in attributes: + sanitized_attributes: dict = deepcopy(attributes) + sanitized_attributes.pop("template", None) + asset_kwargs["attributes"] = sanitized_attributes + asset_kwargs["description"] = _remove_template_copy_guidance( + asset_kwargs.get("description") + ) + + return asset_kwargs + + def _copy_direct_sensors( source_asset: GenericAsset, copied_asset: GenericAsset ) -> dict[int, int]: @@ -293,6 +322,9 @@ def _copy_direct_sensors( knowledge_horizon_fnc, deepcopy(source_sensor.knowledge_horizon_par), ) + if isinstance(sensor_kwargs.get("attributes"), dict): + sensor_kwargs["attributes"] = deepcopy(sensor_kwargs["attributes"]) + sensor_kwargs["attributes"].pop("template_role", None) new_sensor = Sensor(**sensor_kwargs) db.session.add(new_sensor) @@ -465,6 +497,7 @@ def _copy_asset_subtree( # set external_id to None to avoid conflicts with unique constraint on (account_id, external_id) asset_kwargs["external_id"] = None asset_kwargs = convert_asset_json_fields(asset_kwargs) + asset_kwargs = _sanitize_copied_asset_kwargs(asset_kwargs) copied_asset = GenericAsset(**asset_kwargs) db.session.add(copied_asset) diff --git a/flexmeasures/api/v3_0/tests/test_assets_api.py b/flexmeasures/api/v3_0/tests/test_assets_api.py index 85485d2059..0a87518240 100644 --- a/flexmeasures/api/v3_0/tests/test_assets_api.py +++ b/flexmeasures/api/v3_0/tests/test_assets_api.py @@ -907,6 +907,65 @@ def test_copy_asset_increments_name_under_same_parent( assert third_copy.name == f"{battery.name} (Copy 3)" +def test_copy_template_asset_drops_template_metadata( + setup_api_test_data, setup_accounts, db +): + """Copying a template should yield a regular asset in the target account.""" + prosumer_account = setup_accounts["Prosumer"] + battery = db.session.scalars( + select(GenericAsset).filter_by( + account_id=prosumer_account.id, + name="Test grid connected battery storage", + ) + ).first() + assert battery is not None + + template_asset = GenericAsset( + name="Battery Template", + generic_asset_type_id=battery.generic_asset_type_id, + account_id=None, + description=( + "Single battery asset with example power and state-of-charge sensors, " + "plus a basic storage flex-model. Copy this to start modeling a battery." + ), + attributes={"template": {"key": "battery-template", "has_scenarios": False}}, + ) + db.session.add(template_asset) + db.session.flush() + template_sensor = Sensor( + name="electricity-power", + generic_asset_id=template_asset.id, + unit="kW", + timezone="UTC", + event_resolution=timedelta(minutes=15), + attributes={"template_role": "power", "consumption_is_positive": True}, + ) + db.session.add(template_sensor) + db.session.flush() + + assert template_asset.attributes["template"]["key"] == "battery-template" + assert "Copy this" in template_asset.description + + copied_asset = copy_asset(template_asset, account=prosumer_account) + copied_sensor = db.session.scalars( + select(Sensor).filter_by(generic_asset_id=copied_asset.id) + ).one() + + assert copied_asset.account_id == prosumer_account.id + assert copied_asset.parent_asset_id is None + assert copied_asset.attributes == {} + assert copied_asset.description == ( + "Single battery asset with example power and state-of-charge sensors, " + "plus a basic storage flex-model." + ) + assert copied_sensor.attributes == {"consumption_is_positive": True} + + # The source template stays available as a template for future copies. + assert template_asset.attributes["template"]["key"] == "battery-template" + assert "Copy this" in template_asset.description + assert template_sensor.attributes["template_role"] == "power" + + def test_copy_asset_to_another_account_preserves_config( setup_api_test_data, setup_accounts, setup_markets, setup_generic_asset_types, db ): diff --git a/flexmeasures/api/v3_0/tests/test_assets_api_fresh_db.py b/flexmeasures/api/v3_0/tests/test_assets_api_fresh_db.py index f0da27295b..8dee2c6c8c 100644 --- a/flexmeasures/api/v3_0/tests/test_assets_api_fresh_db.py +++ b/flexmeasures/api/v3_0/tests/test_assets_api_fresh_db.py @@ -35,12 +35,14 @@ def test_post_an_asset_as_admin(client, setup_api_fresh_test_data, requesting_us print("Server responded with:\n%s" % post_assets_response.json) assert post_assets_response.status_code == 201 assert post_assets_response.json["latitude"] == 30.1 + assert post_assets_response.json["description"] == post_data["description"] asset: GenericAsset = db.session.execute( select(GenericAsset).filter_by(name=post_data["name"]) ).scalar_one_or_none() assert asset is not None assert asset.latitude == 30.1 + assert asset.description == post_data["description"] @pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) @@ -106,7 +108,7 @@ def test_edit_an_asset(client, setup_api_fresh_test_data, requesting_user, db): with AccountContext("Test Supplier Account") as supplier: existing_asset = supplier.generic_assets[0] - post_data = dict(latitude=10) + post_data = dict(latitude=10, description="Updated description") edit_asset_response = client.patch( url_for("AssetAPI:patch", id=existing_asset.id), json=post_data, @@ -118,6 +120,7 @@ def test_edit_an_asset(client, setup_api_fresh_test_data, requesting_user, db): assert updated_asset.latitude == 10 # changed value assert updated_asset.longitude == existing_asset.longitude assert updated_asset.name == existing_asset.name + assert updated_asset.description == "Updated description" @pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) diff --git a/flexmeasures/api/v3_0/tests/utils.py b/flexmeasures/api/v3_0/tests/utils.py index 79683e868c..2a293c9eb3 100644 --- a/flexmeasures/api/v3_0/tests/utils.py +++ b/flexmeasures/api/v3_0/tests/utils.py @@ -42,6 +42,7 @@ def make_sensor_data_request_for_gas_sensor( def get_asset_post_data(account_id: int = 1, asset_type_id: int = 1) -> dict: post_data = { "name": "Test battery 2", + "description": "A test battery asset.", "latitude": 30.1, "longitude": 100.42, "generic_asset_type_id": asset_type_id, diff --git a/flexmeasures/app.py b/flexmeasures/app.py index ee9fcab76a..252f81c060 100644 --- a/flexmeasures/app.py +++ b/flexmeasures/app.py @@ -50,6 +50,7 @@ def create( # noqa C901 ) from flexmeasures.utils.error_utils import add_basic_error_handlers from flexmeasures.utils.secrets_utils import set_secret_key, set_totp_secrets + from sqlalchemy.exc import OperationalError, ProgrammingError configure_logging() # do this first, see https://flask.palletsprojects.com/en/2.0.x/logging cfg_location = find_flexmeasures_cfg() # Find flexmeasures.cfg location @@ -209,6 +210,24 @@ def create( # noqa C901 register_ui_at(app) + if ( + app.config.get("FLEXMEASURES_CREATE_TEMPLATE_ASSETS_ON_STARTUP", False) + and not app.testing + and app.config.get("FLEXMEASURES_ENV") != "documentation" + ): + from flexmeasures.data import db + from flexmeasures.data.scripts.data_gen import ( + provision_default_template_assets, + ) + + try: + with app.app_context(): + provision_default_template_assets(db) + except (OperationalError, ProgrammingError) as exc: + app.logger.warning( + f"Skipping startup template provisioning due to an error: {exc}" + ) + # Global template variables for both our own templates and external templates @app.context_processor def set_global_template_variables(): diff --git a/flexmeasures/data/migrations/versions/4b0f2e9c1a6d_add_description_to_generic_asset.py b/flexmeasures/data/migrations/versions/4b0f2e9c1a6d_add_description_to_generic_asset.py new file mode 100644 index 0000000000..fa578de45e --- /dev/null +++ b/flexmeasures/data/migrations/versions/4b0f2e9c1a6d_add_description_to_generic_asset.py @@ -0,0 +1,27 @@ +"""add description to generic asset + +Revision ID: 4b0f2e9c1a6d +Revises: 55d8936a55f9 +Create Date: 2026-07-02 17:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "4b0f2e9c1a6d" +down_revision = "55d8936a55f9" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("generic_asset", schema=None) as batch_op: + batch_op.add_column(sa.Column("description", sa.Text(), nullable=True)) + + +def downgrade(): + with op.batch_alter_table("generic_asset", schema=None) as batch_op: + batch_op.drop_column("description") diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py index ad64be981d..763fc679d5 100644 --- a/flexmeasures/data/models/generic_assets.py +++ b/flexmeasures/data/models/generic_assets.py @@ -241,6 +241,7 @@ class GenericAsset(db.Model, AuthModelMixin): # No relationship id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), default="") + description = db.Column(db.Text, nullable=True) latitude = db.Column(db.Float, nullable=True) longitude = db.Column(db.Float, nullable=True) attributes = db.Column(MutableDict.as_mutable(JSONB), nullable=False, default={}) diff --git a/flexmeasures/data/schemas/generic_assets.py b/flexmeasures/data/schemas/generic_assets.py index f09ad37d48..6217d4cb3a 100644 --- a/flexmeasures/data/schemas/generic_assets.py +++ b/flexmeasures/data/schemas/generic_assets.py @@ -349,6 +349,7 @@ class GenericAssetSchema(ma.SQLAlchemySchema): id = ma.auto_field(dump_only=True) name = fields.Str(required=True) + description = fields.Str(required=False, allow_none=True) account_id = ma.auto_field() owner = ma.Nested("AccountSchema", dump_only=True, only=("id", "name")) latitude = LatitudeField(allow_none=True) diff --git a/flexmeasures/data/scripts/data_gen.py b/flexmeasures/data/scripts/data_gen.py index f2c1a7bde1..46826456c8 100644 --- a/flexmeasures/data/scripts/data_gen.py +++ b/flexmeasures/data/scripts/data_gen.py @@ -17,10 +17,7 @@ from flexmeasures.data.models.user import User, Role, AccountRole from flexmeasures.data.transactional import as_transaction from flexmeasures.cli.utils import MsgStyle - - -BACKUP_PATH = app.config.get("FLEXMEASURES_DB_BACKUP_PATH") -LOCAL_TIME_ZONE = app.config.get("FLEXMEASURES_TIMEZONE") +from flexmeasures.data.utils import TEMPLATE_COPY_GUIDANCE_PREFIX def add_default_data_sources(db: SQLAlchemy): @@ -146,6 +143,226 @@ def add_transmission_zone_asset(country_code: str, db: SQLAlchemy) -> GenericAss return transmission_zone +def _ensure_public_root_asset( + db: SQLAlchemy, + *, + name: str, + asset_type: GenericAssetType, + description: str, + flex_model: dict | None = None, + attributes: dict | None = None, + sensors_to_show: list | None = None, +) -> GenericAsset: + """Create or update a public root asset used as a template.""" + asset = db.session.execute( + select(GenericAsset).filter_by( + name=name, + account_id=None, + parent_asset_id=None, + ) + ).scalar_one_or_none() + + if asset is None: + asset = GenericAsset( + name=name, + generic_asset_type=asset_type, + account_id=None, + ) + db.session.add(asset) + db.session.flush() + + asset.generic_asset_type = asset_type + asset.description = description + if attributes: + asset.attributes = dict(asset.attributes or {}) + asset.attributes.update(attributes) + if flex_model is not None: + asset.flex_model = flex_model + if sensors_to_show is not None: + asset.sensors_to_show = sensors_to_show + return asset + + +def _ensure_sensor( + db: SQLAlchemy, + *, + asset: GenericAsset, + name: str, + unit: str, + event_resolution: timedelta, + attributes: dict | None = None, +) -> Sensor: + """Create or update one sensor for a template asset.""" + sensor = db.session.execute( + select(Sensor).filter_by(name=name, generic_asset_id=asset.id) + ).scalar_one_or_none() + if sensor is None: + sensor = Sensor( + name=name, + generic_asset=asset, + unit=unit, + timezone="Europe/Amsterdam", + event_resolution=event_resolution, + ) + db.session.add(sensor) + db.session.flush() + + sensor.unit = unit + sensor.timezone = "Europe/Amsterdam" + sensor.event_resolution = event_resolution + if attributes: + sensor.attributes = dict(sensor.attributes or {}) + sensor.attributes.update(attributes) + return sensor + + +def _template_metadata(template_key: str) -> dict: + """Return the metadata block used to tag built-in asset templates.""" + return { + "template": { + "key": template_key, + "kind": "single-asset", + "has_scenarios": False, + } + } + + +@as_transaction +def provision_default_template_assets(db: SQLAlchemy): + """Ensure the default starter template assets exist. + + This currently provisions the single-asset starter templates which are + intended to show up in the asset copy UI. + """ + asset_types = add_default_asset_types(db) + + # Battery + battery = _ensure_public_root_asset( + db, + name="Battery Template", + asset_type=asset_types["battery"], + description=( + "Single battery asset with example power and state-of-charge sensors, " + f"plus a basic storage flex-model. {TEMPLATE_COPY_GUIDANCE_PREFIX} to " + "start modeling a battery." + ), + attributes=_template_metadata("battery-template"), + ) + battery_power = _ensure_sensor( + db, + asset=battery, + name="electricity-power", + unit="kW", + event_resolution=timedelta(minutes=15), + attributes={"consumption_is_positive": True, "template_role": "power"}, + ) + battery_soc = _ensure_sensor( + db, + asset=battery, + name="state-of-charge", + unit="kWh", + event_resolution=timedelta(0), + attributes={"template_role": "state-of-charge"}, + ) + battery.flex_model = { + "soc-max": "450 kWh", + "soc-min": "50 kWh", + "roundtrip-efficiency": "90%", + "power-capacity": "500 kW", + "state-of-charge": {"sensor": battery_soc.id}, + } + battery.sensors_to_show = [ + {"title": "Power", "plots": [{"sensor": battery_power.id}]}, + {"title": "State of charge", "plots": [{"sensor": battery_soc.id}]}, + ] + + # EV charger + ev_charger = _ensure_public_root_asset( + db, + name="EV Charger Template", + asset_type=asset_types["one-way_evse"], + description=( + "Single EV charger asset with example charging power and state-of-charge " + "sensors, plus a simple charging flex-model. " + f"{TEMPLATE_COPY_GUIDANCE_PREFIX} to start building a charger or EV " + "charging setup." + ), + attributes=_template_metadata("ev-charger-template"), + ) + ev_power = _ensure_sensor( + db, + asset=ev_charger, + name="electricity-power", + unit="kW", + event_resolution=timedelta(minutes=15), + attributes={"consumption_is_positive": True, "template_role": "power"}, + ) + ev_soc = _ensure_sensor( + db, + asset=ev_charger, + name="state-of-charge", + unit="kWh", + event_resolution=timedelta(0), + attributes={"template_role": "state-of-charge"}, + ) + ev_charger.flex_model = { + "soc-max": "60 kWh", + "soc-min": "0 kWh", + "soc-minima": [{"value": "12 kWh"}], + "roundtrip-efficiency": "90%", + "power-capacity": "11 kW", + "production-capacity": "0 kW", + "state-of-charge": {"sensor": ev_soc.id}, + } + ev_charger.sensors_to_show = [ + {"title": "Power", "plots": [{"sensor": ev_power.id}]}, + {"title": "State of charge", "plots": [{"sensor": ev_soc.id}]}, + ] + + # Heat pump / buffer + heat_pump = _ensure_public_root_asset( + db, + name="Heat Pump Template", + asset_type=asset_types["heat-storage"], + description=( + "Single heat-pump-with-buffer style asset, represented as thermal storage " + "with example power and thermal state-of-charge sensors. " + f"{TEMPLATE_COPY_GUIDANCE_PREFIX} to start modeling heat flexibility." + ), + attributes=_template_metadata("heat-pump-template"), + ) + heat_power = _ensure_sensor( + db, + asset=heat_pump, + name="electricity-power", + unit="kW", + event_resolution=timedelta(minutes=15), + attributes={"consumption_is_positive": True, "template_role": "power"}, + ) + heat_soc = _ensure_sensor( + db, + asset=heat_pump, + name="state-of-charge", + unit="kWh", + event_resolution=timedelta(0), + attributes={"template_role": "state-of-charge"}, + ) + heat_pump.flex_model = { + "soc-max": "15 kWh", + "soc-min": "0 kWh", + "charging-efficiency": "300 %", + "storage-efficiency": "99.3 %", + "consumption-capacity": "5 kW", + "production-capacity": "0 kW", + "power-capacity": "5 kW", + "state-of-charge": {"sensor": heat_soc.id}, + } + heat_pump.sensors_to_show = [ + {"title": "Power", "plots": [{"sensor": heat_power.id}]}, + {"title": "State of charge", "plots": [{"sensor": heat_soc.id}]}, + ] + + # ------------ Main functions -------------------------------- # These can registered at the app object as cli functions diff --git a/flexmeasures/data/tests/test_template_assets.py b/flexmeasures/data/tests/test_template_assets.py new file mode 100644 index 0000000000..44dc8e689a --- /dev/null +++ b/flexmeasures/data/tests/test_template_assets.py @@ -0,0 +1,73 @@ +from sqlalchemy import func, select + +from flexmeasures.data.models.generic_assets import GenericAsset +from flexmeasures.data.models.time_series import Sensor +from flexmeasures.data.scripts.data_gen import provision_default_template_assets + + +def test_provision_default_template_assets_creates_single_asset_templates( + fresh_db, app +): + provision_default_template_assets(fresh_db) + + assets = fresh_db.session.scalars( + select(GenericAsset).where(GenericAsset.account_id.is_(None)) + ).all() + assets_by_name = {asset.name: asset for asset in assets} + + assert set(assets_by_name) >= { + "Battery Template", + "EV Charger Template", + "Heat Pump Template", + } + + battery = assets_by_name["Battery Template"] + assert battery.generic_asset_type.name == "battery" + assert battery.attributes["template"]["key"] == "battery-template" + assert battery.attributes["template"]["has_scenarios"] is False + assert battery.description.startswith("Single battery asset") + + ev_charger = assets_by_name["EV Charger Template"] + assert ev_charger.generic_asset_type.name == "one-way_evse" + assert ev_charger.attributes["template"]["key"] == "ev-charger-template" + assert ev_charger.description.startswith("Single EV charger asset") + assert ev_charger.flex_model["soc-min"] == "0 kWh" + assert ev_charger.flex_model["soc-minima"] == [{"value": "12 kWh"}] + + heat_pump = assets_by_name["Heat Pump Template"] + assert heat_pump.generic_asset_type.name == "heat-storage" + assert heat_pump.attributes["template"]["key"] == "heat-pump-template" + assert heat_pump.description.startswith("Single heat-pump-with-buffer style asset") + + battery_sensor_names = {sensor.name for sensor in battery.sensors} + ev_sensor_names = {sensor.name for sensor in ev_charger.sensors} + heat_sensor_names = {sensor.name for sensor in heat_pump.sensors} + + assert battery_sensor_names == {"electricity-power", "state-of-charge"} + assert ev_sensor_names == {"electricity-power", "state-of-charge"} + assert heat_sensor_names == {"electricity-power", "state-of-charge"} + + battery_soc_sensor = next( + sensor for sensor in battery.sensors if sensor.name == "state-of-charge" + ) + assert battery.flex_model["state-of-charge"]["sensor"] == battery_soc_sensor.id + + assert app.config["FLEXMEASURES_CREATE_TEMPLATE_ASSETS_ON_STARTUP"] is False + + +def test_provision_default_template_assets_is_idempotent(fresh_db): + provision_default_template_assets(fresh_db) + asset_count = fresh_db.session.scalar( + select(func.count()).select_from(GenericAsset) + ) + sensor_count = fresh_db.session.scalar(select(func.count()).select_from(Sensor)) + + provision_default_template_assets(fresh_db) + assert ( + fresh_db.session.scalar(select(func.count()).select_from(GenericAsset)) + == asset_count + ) + assert ( + fresh_db.session.scalar(select(func.count()).select_from(Sensor)) + == sensor_count + ) diff --git a/flexmeasures/data/utils.py b/flexmeasures/data/utils.py index 84d2c2c423..aee54aaeb9 100644 --- a/flexmeasures/data/utils.py +++ b/flexmeasures/data/utils.py @@ -28,6 +28,7 @@ SAVE_TO_DB_SUCCESS, SAVE_TO_DB_SUCCESS_WITH_UNCHANGED_BELIEFS_SKIPPED, ) +TEMPLATE_COPY_GUIDANCE_PREFIX = "Copy this" def save_to_session(objects: list[db.Model], overwrite: bool = False): diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index be45760153..b6e9b5b1e0 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -5061,6 +5061,12 @@ "name": { "type": "string" }, + "description": { + "type": [ + "string", + "null" + ] + }, "account_id": { "type": [ "integer", diff --git a/flexmeasures/ui/templates/assets/asset_context.html b/flexmeasures/ui/templates/assets/asset_context.html index 09a1348dfb..c8c332ac1d 100644 --- a/flexmeasures/ui/templates/assets/asset_context.html +++ b/flexmeasures/ui/templates/assets/asset_context.html @@ -28,7 +28,12 @@

-

Type: {{ asset.generic_asset_type.name.split('.')[-1] | title }}

+

+ Type: {{ asset.generic_asset_type.name.split('.')[-1] | title }} + {% if asset.description %} + Description: {{ asset.description }} + {% endif %} +

diff --git a/flexmeasures/ui/templates/assets/asset_new.html b/flexmeasures/ui/templates/assets/asset_new.html index 74c63beb68..477968297f 100644 --- a/flexmeasures/ui/templates/assets/asset_new.html +++ b/flexmeasures/ui/templates/assets/asset_new.html @@ -54,6 +54,16 @@

Creating a new asset {% if parent_asset_name %} under asset + {{ asset_form.description.label(class="col-md-6 control-label") }} +
+ {{ asset_form.description.description }} + {{ asset_form.description(class_="form-control", rows="3") }} + {% for error in asset_form.errors.description %} + [{{error}}] + {% endfor %} +
+

{% if asset_form.account_id %}
{{ asset_form.account_id.label(class="col-md-6 control-label") }} @@ -136,7 +146,7 @@

Location

+ placeholder="Search assets…" value="Template">