diff --git a/documentation/changelog.rst b/documentation/changelog.rst index a734982fe4..599cd295f7 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -80,6 +80,7 @@ Infrastructure / Support Bugfixes ----------- +* Show icons for more asset types in the UI's asset structure view, which previously fell back to a question mark: the ``wind``, ``process`` and ``heat-storage`` types that FlexMeasures seeds by default, and EV infrastructure under its various names (such as ``one-way_evse``, ``two-way_evse``, ``evse``, ``charging_station`` and ``charging_hub``) and building services equipment (``hvac``, ``ahu``, ``dhw``, ``heatpump``, ``chiller``, ``lighting`` and ``other-loads``). Asset type names are now matched ignoring case and separators, so an asset type named ``charge-point`` gets the same icon as ``chargepoint`` [see `PR #2391 `_] * Replaying a chart for a past window no longer shows annotations that were only recorded later; annotation searches and the ``chart_annotations`` endpoints can now be scoped by recording (belief) time [see `PR #2367 `_] * Continuing the query-parameter cleanup started in PR #2352: the chart-related endpoints now use ``prior``, ``start``, ``end`` and hyphenated field names, with a new ``duration`` field to derive a missing ``start``/``end``; old spellings keep working as legacy aliases [see `PR #2367 `_] * Scheduling jobs no longer print ``Job ... made schedule.`` before ``scheduler.compute()`` runs (only after a successful schedule) [see `PR #2342 `_] diff --git a/flexmeasures/ui/tests/test_utils.py b/flexmeasures/ui/tests/test_utils.py index 425b41fd6b..3e499ec9e3 100644 --- a/flexmeasures/ui/tests/test_utils.py +++ b/flexmeasures/ui/tests/test_utils.py @@ -3,6 +3,13 @@ from flexmeasures import Asset, AssetType, Account, Sensor from flexmeasures.data.models.generic_assets import GenericAsset from flexmeasures.ui.utils.breadcrumb_utils import get_ancestry +from flexmeasures.ui.utils.view_utils import ( + CHARGER_ICON, + FALLBACK_SVG_ICON, + SVG_ICON_MAPPING, + normalize_asset_type_name, + svg_asset_icon_name, +) from flexmeasures.data.schemas.scheduling import ( UI_FLEX_CONTEXT_SCHEMA, UI_FLEX_MODEL_SCHEMA, @@ -12,6 +19,57 @@ from timely_beliefs.sensors.func_store.knowledge_horizons import x_days_ago_at_y_oclock +def test_svg_asset_icon_name_for_default_asset_types(): + """Every asset type that FlexMeasures seeds by default should have its own icon. + + These are the types added by flexmeasures.data.scripts.data_gen.add_default_asset_types. + """ + for asset_type_name in ( + "solar", + "wind", + "one-way_evse", + "two-way_evse", + "battery", + "building", + "process", + "heat-storage", + ): + assert svg_asset_icon_name(asset_type_name) != FALLBACK_SVG_ICON + + for asset_type_name in ("one-way_evse", "two-way_evse"): + assert svg_asset_icon_name(asset_type_name) == CHARGER_ICON + + +def test_svg_asset_icon_name_falls_back_for_unknown_asset_type(): + assert svg_asset_icon_name("no-such-asset-type") == FALLBACK_SVG_ICON + assert svg_asset_icon_name("") == FALLBACK_SVG_ICON + assert svg_asset_icon_name(None) == FALLBACK_SVG_ICON + + +def test_svg_asset_icon_name_ignores_case_and_separators(): + """Projects are free to name their asset types, so spelling variants should find the same icon.""" + for asset_type_name in ("charge-point", "charge point", "Charge_Point"): + assert svg_asset_icon_name(asset_type_name) == CHARGER_ICON + + # a namespaced asset type, as a plugin may define it, is matched on its last component + assert svg_asset_icon_name("myplugin.battery") == svg_asset_icon_name("battery") + + +def test_svg_icon_mapping_keys_do_not_collide_when_normalized(): + """Two keys that normalize to the same name would silently shadow each other.""" + normalized_keys: dict[str, list[str]] = {} + for asset_type_name in SVG_ICON_MAPPING: + normalized_keys.setdefault( + normalize_asset_type_name(asset_type_name), [] + ).append(asset_type_name) + + assert { + normalized: keys + for normalized, keys in normalized_keys.items() + if len(keys) > 1 + } == {} + + def test_get_ancestry(app, db): account = Account(name="Test Account") asset_type = AssetType(name="TestAssetType") diff --git a/flexmeasures/ui/utils/view_utils.py b/flexmeasures/ui/utils/view_utils.py index 3ffd07764c..6954181a27 100644 --- a/flexmeasures/ui/utils/view_utils.py +++ b/flexmeasures/ui/utils/view_utils.py @@ -253,6 +253,9 @@ def _minimal_ext_cmd(cmd: list): "wind speed": "wi wi-strong-wind", } +CHARGER_ICON = "https://api.iconify.design/material-symbols/ev-station-outline.svg" +FALLBACK_SVG_ICON = "https://api.iconify.design/fa-solid/question-circle.svg" + SVG_ICON_MAPPING = { # site structure "building": "https://api.iconify.design/mdi/home-city.svg", @@ -262,12 +265,47 @@ def _minimal_ext_cmd(cmd: list): "scenario": "https://api.iconify.design/mdi/binoculars.svg", "pv": "https://api.iconify.design/wi/day-sunny.svg", "solar": "https://api.iconify.design/wi/day-sunny.svg", - "chargepoint": "https://api.iconify.design/material-symbols/ev-station-outline.svg", - "ev": "https://api.iconify.design/material-symbols/ev-station-outline.svg", + "wind": "https://api.iconify.design/mdi/wind-turbine.svg", + "process": "https://api.iconify.design/mdi/factory.svg", + "heat-storage": "https://api.iconify.design/mdi/storage-tank.svg", + # EV infrastructure, which projects name in a variety of ways + "chargepoint": CHARGER_ICON, + "ev": CHARGER_ICON, + "evse": CHARGER_ICON, + "one-way_evse": CHARGER_ICON, + "two-way_evse": CHARGER_ICON, + "charging_station": CHARGER_ICON, + "charging_hub": CHARGER_ICON, + "ev_charger": CHARGER_ICON, + "charger": CHARGER_ICON, + # building services equipment, named by their common abbreviations + "hvac": "https://api.iconify.design/mdi/hvac.svg", + "ahu": "https://api.iconify.design/mdi/air-filter.svg", # air handling unit + "dhw": "https://api.iconify.design/mdi/water-boiler.svg", # domestic hot water + "heatpump": "https://api.iconify.design/mdi/heat-pump.svg", + "chiller": "https://api.iconify.design/mdi/snowflake.svg", + "lighting": "https://api.iconify.design/mdi/lightbulb.svg", + "other-loads": "https://api.iconify.design/mdi/power-plug.svg", "add_asset": "https://api.iconify.design/material-symbols/add-rounded.svg?color=white", # Plus Icon for Add Asset } +def normalize_asset_type_name(asset_type_name: str) -> str: + """Reduce an asset type name to its lower-case alphanumeric characters, so that spelling variants share one mapping key. + + For example, "charge-point", "charge point" and "Charge_Point" all reduce to "chargepoint". + """ + return "".join( + character for character in asset_type_name.lower() if character.isalnum() + ) + + +NORMALIZED_SVG_ICON_MAPPING = { + normalize_asset_type_name(asset_type_name): icon + for asset_type_name, icon in SVG_ICON_MAPPING.items() +} + + def asset_icon_name(asset_type_name: str) -> str: """Icon name for this asset type. @@ -285,12 +323,16 @@ def asset_icon_name(asset_type_name: str) -> str: def svg_asset_icon_name(asset_type_name: str) -> str: + """SVG icon URL for this asset type, as used in the asset structure view. + Asset type names are not restricted, so projects spell the same type in different ways. + Matching therefore ignores case and separators, letting a type named "charge-point" share the icon mapped to "chargepoint". + A namespaced name, such as a plugin's "myplugin.battery", is matched on its last dot-separated component. + An asset type we have no icon for falls back to a question mark. + """ if asset_type_name: - asset_type_name = asset_type_name.split(".")[-1].lower() - return SVG_ICON_MAPPING.get( - asset_type_name, "https://api.iconify.design/fa-solid/question-circle.svg" - ) + asset_type_name = normalize_asset_type_name(asset_type_name.split(".")[-1]) + return NORMALIZED_SVG_ICON_MAPPING.get(asset_type_name, FALLBACK_SVG_ICON) def username(user_id) -> str: