Repository: FlexMeasures/flexmeasures (language: python)

Issue:
UI: Dashboard using GenericAssets
As discussed in #247, moving to GenericAssets has multiple steps. The dashboard is distinct from the CRUD. This is done here.
- Adapt the dashboard view code to use the GenericAsset.
- Simplify the dashboard if necessary. I.e. try, but not too hard to find a solution for the latest state (e.g. find the power sensors / sum all power sensors).
- Consider caching, e.g. when asking for asset type groups
UI: Dashboard using GenericAssets
As discussed in #247, moving to GenericAssets has multiple steps. The dashboard is distinct from the CRUD. This is done here.
- Adapt the dashboard view code to use the GenericAsset.
- Simplify the dashboard if necessary. I.e. try, but not too hard to find a solution for the latest state (e.g. find the power sensors / sum all power sensors).
- Consider caching, e.g. when asking for asset type groups


Discussion / hints:
Branch [issue-249-UI_Dashboard_using_GenericAssets](https://github.com/SeitaBV/flexmeasures/tree/issue-249-UI_Dashboard_using_GenericAssets) created!
Branch [issue-249-UI_Dashboard_using_GenericAssets](https://github.com/SeitaBV/flexmeasures/tree/issue-249-UI_Dashboard_using_GenericAssets) created!

Fix patch:
diff --git a/documentation/configuration.rst b/documentation/configuration.rst
index 226c83e9d..b40e83a93 100644
--- a/documentation/configuration.rst
+++ b/documentation/configuration.rst
@@ -197,6 +197,14 @@ Interval in which viewing the queues dashboard refreshes itself, in milliseconds
 Default: ``3000`` (3 seconds) 
 
 
+FLEXMEASURES_ASSET_TYPE_GROUPS
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+How to group asset types together, e.g. in a dashboard.
+
+Default: ``{"renewables": ["solar", "wind"], "EVSE": ["one-way_evse", "two-way_evse"]}``
+
+
 Timing
 ------
 
diff --git a/flexmeasures/api/common/schemas/generic_assets.py b/flexmeasures/api/common/schemas/generic_assets.py
new file mode 100644
index 000000000..6bdab3249
--- /dev/null
+++ b/flexmeasures/api/common/schemas/generic_assets.py
@@ -0,0 +1,21 @@
+from flask import abort
+from marshmallow import fields
+
+from flexmeasures.data.models.generic_assets import GenericAsset
+
+
+class AssetIdField(fields.Integer):
+    """
+    Field that represents a generic asset ID. It de-serializes from the asset id to an asset instance.
+    """
+
+    def _deserialize(self, asset_id: int, attr, obj, **kwargs) -> GenericAsset:
+        asset: GenericAsset = GenericAsset.query.filter_by(
+            id=int(asset_id)
+        ).one_or_none()
+        if asset is None:
+            raise abort(404, f"GenericAsset {asset_id} not found")
+        return asset
+
+    def _serialize(self, asset: GenericAsset, attr, data, **kwargs) -> int:
+        return asset.id
diff --git a/flexmeasures/api/common/schemas/sensors.py b/flexmeasures/api/common/schemas/sensors.py
index 6e3d288a6..3b4daa65e 100644
--- a/flexmeasures/api/common/schemas/sensors.py
+++ b/flexmeasures/api/common/schemas/sensors.py
@@ -1,3 +1,4 @@
+from flask import abort
 from marshmallow import fields
 
 from flexmeasures.api import FMValidationError
@@ -15,6 +16,21 @@ class EntityAddressValidationError(FMValidationError):
     status = "INVALID_DOMAIN"  # USEF error status
 
 
+class SensorIdField(fields.Integer):
+    """
+    Field that represents a sensor ID. It de-serializes from the sensor id to a sensor instance.
+    """
+
+    def _deserialize(self, sensor_id: int, attr, obj, **kwargs) -> Sensor:
+        sensor: Sensor = Sensor.query.filter_by(id=int(sensor_id)).one_or_none()
+        if sensor is None:
+            raise abort(404, f"Sensor {sensor_id} not found")
+        return sensor
+
+    def _serialize(self, sensor: Sensor, attr, data, **kwargs) -> int:
+        return sensor.id
+
+
 class SensorField(fields.Str):
     """Field that de-serializes to a Sensor,
     and serializes a Sensor, Asset, Market or WeatherSensor into an entity address (string)."""
diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py
index a36c122a4..7009fa05d 100644
--- a/flexmeasures/data/models/generic_assets.py
+++ b/flexmeasures/data/models/generic_assets.py
@@ -1,4 +1,8 @@
-from typing import Optional, Tuple
+from typing import Optional, Tuple, List
+
+from flask_security import current_user
+from sqlalchemy.orm import Session
+from sqlalchemy.engine import Row
 
 from sqlalchemy.ext.hybrid import hybrid_method
 from sqlalchemy.sql.expression import func
@@ -6,6 +10,8 @@
 from sqlalchemy.ext.mutable import MutableDict
 
 from flexmeasures.data import db
+from flexmeasures.data.models.user import User
+from flexmeasures.auth.policy import AuthModelMixin
 from flexmeasures.utils import geo_utils
 
 
@@ -20,7 +26,7 @@ class GenericAssetType(db.Model):
     description = db.Column(db.String(80), nullable=True, unique=False)
 
 
-class GenericAsset(db.Model):
+class GenericAsset(db.Model, AuthModelMixin):
     """An asset is something that has economic value.
 
     Examples of tangible assets: a house, a ship, a weather station.
@@ -42,6 +48,23 @@ class GenericAsset(db.Model):
         backref=db.backref("generic_assets", lazy=True),
     )
 
+    def __acl__(self):
+        """
+        Within same account, everyone can read and update.
+        Creation and deletion are left to site admins in CLI.
+
+        TODO: needs an iteration
+        """
+        return {
+            "read": f"account:{self.account_id}",
+            "update": f"account:{self.account_id}",
+        }
+
+    @property
+    def asset_type(self) -> GenericAssetType:
+        """ This property prepares for dropping the "generic" prefix later"""
+        return self.generic_asset_type
+
     account_id = db.Column(
         db.Integer, db.ForeignKey("account.id", ondelete="CASCADE"), nullable=True
     )  # if null, asset is public
@@ -116,6 +139,16 @@ def set_attribute(self, attribute: str, value):
         if self.has_attribute(attribute):
             self.attributes[attribute] = value
 
+    @property
+    def has_power_sensors(self) -> bool:
+        """True if at least one power sensor is attached"""
+        return any([s.measures_power for s in self.sensors])
+
+    @property
+    def has_energy_sensors(self) -> bool:
+        """True if at least one power energy is attached"""
+        return any([s.measures_energy for s in self.sensors])
+
 
 def create_generic_asset(generic_asset_type: str, **kwargs) -> GenericAsset:
     """Create a GenericAsset and assigns it an id.
@@ -150,3 +183,37 @@ def create_generic_asset(generic_asset_type: str, **kwargs) -> GenericAsset:
     db.session.add(new_generic_asset)
     db.session.flush()  # generates the pkey for new_generic_asset
     return new_generic_asset
+
+
+def assets_share_location(assets: List[GenericAsset]) -> bool:
+    """
+    Return True if all assets in this list are located on the same spot.
+    TODO: In the future, we might soften this to compare if assets are in the same "housing" or "site".
+    """
+    if not assets:
+        return True
+    return all([a.location == assets[0].location for a in assets])
+
+
+def get_center_location_of_assets(
+    db: Session, user: Optional[User]
+) -> Tuple[float, float]:
+    """
+    Find the center position between all generic assets of the user's account.
+    """
+    query = (
+        "Select (min(latitude) + max(latitude)) / 2 as latitude,"
+        " (min(longitude) + max(longitude)) / 2 as longitude"
+        " from generic_asset"
+    )
+    if user is None:
+        user = current_user
+    query += f" where generic_asset.account_id = {user.account_id}"
+    locations: List[Row] = db.session.execute(query + ";").fetchall()
+    if (
+        len(locations) == 0
+        or locations[0].latitude is None
+        or locations[0].longitude is None
+    ):
+        return 52.366, 4.904  # Amsterdam, NL
+    return locations[0].latitude, locations[0].longitude
diff --git a/flexmeasures/data/models/time_series.py b/flexmeasures/data/models/time_series.py
index 126307090..3e32309dd 100644
--- a/flexmeasures/data/models/time_series.py
+++ b/flexmeasures/data/models/time_series.py
@@ -9,6 +9,7 @@
 import timely_beliefs as tb
 import timely_beliefs.utils as tb_utils
 
+from flexmeasures.auth.policy import AuthModelMixin
 from flexmeasures.data.config import db
 from flexmeasures.data.queries.utils import (
     create_beliefs_query,
@@ -20,6 +21,7 @@
     aggregate_values,
 )
 from flexmeasures.utils.entity_address_utils import build_entity_address
+from flexmeasures.utils.unit_utils import is_energy_unit, is_power_unit
 from flexmeasures.data.models.charts import chart_type_to_chart_specs
 from flexmeasures.data.models.data_sources import DataSource
 from flexmeasures.data.models.generic_assets import GenericAsset
@@ -28,7 +30,7 @@
 from flexmeasures.utils.flexmeasures_inflection import capitalize
 
 
-class Sensor(db.Model, tb.SensorDBMixin):
+class Sensor(db.Model, tb.SensorDBMixin, AuthModelMixin):
     """A sensor measures events. """
 
     attributes = db.Column(MutableDict.as_mutable(db.JSON), nullable=False, default={})
@@ -67,6 +69,18 @@ def __init__(
             kwargs["attributes"] = attributes
         db.Model.__init__(self, **kwargs)
 
+    def __acl__(self):
+        """
+        Within same account, everyone can read and update.
+        Creation and deletion are left to site admins in CLI.
+
+        TODO: needs an iteration
+        """
+        return {
+            "read": f"account:{self.generic_asset.account_id}",
+            "update": f"account:{self.generic_asset.account_id}",
+        }
+
     @property
     def entity_address(self) -> str:
         return build_entity_address(dict(sensor_id=self.id), "sensor")
@@ -85,6 +99,16 @@ def location(self) -> Optional[Tuple[float, float]]:
             return self.latitude, self.longitude
         return None
 
+    @property
+    def measures_power(self) -> bool:
+        """True if this sensor's unit is measuring power"""
+        return is_power_unit(self.unit)
+
+    @property
+    def measures_energy(self) -> bool:
+        """True if this sensor's unit is measuring energy"""
+        return is_energy_unit(self.unit)
+
     @property
     def is_strictly_non_positive(self) -> bool:
         """Return True if this sensor strictly records non-positive values."""
@@ -464,6 +488,8 @@ class TimedValue(object):
     """
     A mixin of all tables that store time series data, either forecasts or measurements.
     Represents one row.
+
+    Note: This will be deprecated in favour of Timely-Beliefs - based code (see Sensor/TimedBelief)
     """
 
     @declared_attr
diff --git a/flexmeasures/data/queries/generic_assets.py b/flexmeasures/data/queries/generic_assets.py
new file mode 100644
index 000000000..c3058e811
--- /dev/null
+++ b/flexmeasures/data/queries/generic_assets.py
@@ -0,0 +1,20 @@
+from typing import List, Union
+
+from sqlalchemy.orm import Query
+
+from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType
+
+
+def query_assets_by_type(type_names: Union[List[str], str]) -> Query:
+    """
+    Return a query which looks for GenericAssets by their type.
+    Pass in a list of type names or only one type name.
+    """
+    query = GenericAsset.query.join(GenericAssetType).filter(
+        GenericAsset.generic_asset_type_id == GenericAssetType.id
+    )
+    if isinstance(type_names, str):
+        query = query.filter(GenericAssetType.name == type_names)
+    else:
+        query = query.filter(GenericAssetType.name.in_(type_names))
+    return query
diff --git a/flexmeasures/data/services/asset_grouping.py b/flexmeasures/data/services/asset_grouping.py
new file mode 100644
index 000000000..fb9e6ce25
--- /dev/null
+++ b/flexmeasures/data/services/asset_grouping.py
@@ -0,0 +1,222 @@
+"""
+Convenience functions and class for accessing generic assets in groups.
+For example, group by asset type or by location.
+"""
+
+from __future__ import annotations
+from typing import List, Dict, Optional, Union
+import inflect
+from itertools import groupby
+
+from sqlalchemy.orm import Query
+from flask_security import current_user
+from werkzeug.exceptions import Forbidden
+from flexmeasures.auth.policy import ADMIN_ROLE, ADMIN_READER_ROLE
+
+from flexmeasures.utils.flexmeasures_inflection import parameterize, pluralize
+from flexmeasures.data.models.generic_assets import (
+    GenericAssetType,
+    GenericAsset,
+    assets_share_location,
+)
+from flexmeasures.data.queries.generic_assets import query_assets_by_type
+
+p = inflect.engine()
+
+
+def get_asset_group_queries(
+    custom_additional_groups: Optional[Dict[str, List[str]]] = None,
+    group_by_location: bool = False,
+) -> Dict[str, Query]:
+    """
+    An asset group is defined by Asset queries. Each query has a name, and we prefer pluralised  names.
+    They still need an executive call, like all(), count() or first().
+
+    This function limits the assets to be queried to the current user's account,
+    if the user is not an admin.
+
+    Note: Make sure the current user has the "read" permission on his account (on GenericAsset.__class__?? See https://github.com/FlexMeasures/flexmeasures/issues/200).
+
+    :param custom_additional_groups: dict of asset type groupings (mapping group names to names of asset types). See also the setting FLEXMEASURES_ASSET_TYPE_GROUPS.
+    :param group_by_location: If True, groups will be made for assets at the same location. Naming of the location currently supports charge points (for EVSEs).
+    """
+    asset_queries = {}
+
+    # 1. Custom asset groups by combinations of asset types
+    if custom_additional_groups:
+        for asset_type_group_name, asset_types in custom_additional_groups.items():
+            asset_queries[asset_type_group_name] = query_assets_by_type(asset_types)
+
+    # 2. We also include a group per asset type - using the pluralised asset type name
+    for asset_type in GenericAssetType.query.all():
+        asset_queries[pluralize(asset_type.name)] = query_assets_by_type(
+            asset_type.name
+        )
+
+    # 3. Finally, we group assets by location
+    if group_by_location:
+        asset_queries.update(get_location_queries())
+
+    if not (
+        current_user.has_role(ADMIN_ROLE) or current_user.has_role(ADMIN_READER_ROLE)
+    ):
+        # only current user's account
+        asset_queries = limit_assets_to_account(asset_queries)
+
+    return asset_queries
+
+
+def get_location_queries() -> Dict[str, Query]:
+    """
+    Make queries for grouping assets by location.
+
+    We group EVSE assets by location (if they share a location, they belong to the same Charge Point)
+    Like get_asset_group_queries, the values in the returned dict still need an executive call, like all(), count() or first(). Note that this function will still load and inspect assets to do its job.
+
+    The Charge Points are named on the basis of the first EVSE in their list,
+    using either the whole EVSE name or that part that comes before a " -" delimiter. For example:
+    If:
+        evse_name = "Seoul Hilton - charger 1"
+    Then:
+        charge_point_name = "Seoul Hilton (Charge Point)"
+
+    A Charge Point is a special case. If all assets on a location are of type EVSE,
+    we can call the location a "Charge Point".
+    """
+    asset_queries = {}
+    all_assets = GenericAsset.query.all()
+    loc_groups = group_assets_by_location(all_assets)
+    for loc_group in loc_groups:
+        if len(loc_group) == 1:
+            continue
+        location_type = "(Location)"
+        if all(
+            [
+                asset.asset_type.name in ["one-way_evse", "two-way_evse"]
+                for asset in loc_group
+            ]
+        ):
+            location_type = "(Charge Point)"
+        location_name = f"{loc_group[0].name.split(' -')[0]} {location_type}"
+        asset_queries[location_name] = GenericAsset.query.filter(
+            GenericAsset.name.in_([asset.name for asset in loc_group])
+        )
+    return asset_queries
+
+
+def limit_assets_to_account(
+    asset_queries: Union[Query, Dict[str, Query]]
+) -> Union[Query, Dict[str, Query]]:
+    """Filter out any assets that are not in the user's account."""
+    if not hasattr(current_user, "account_id"):
+        raise Forbidden("Unauthenticated user cannot list asset groups.")
+    if isinstance(asset_queries, dict):
+        for name, query in asset_queries.items():
+            asset_queries[name] = query.filter(
+                GenericAsset.account_id == current_user.account.id
+            )
+    else:
+        asset_queries = asset_queries.filter(
+            GenericAsset.account_id == current_user.account_id
+        )
+    return asset_queries
+
+
+class AssetGroup:
+    """
+    This class represents a group of assets of the same type, offering some convenience functions
+    for displaying their properties.
+
+    When initialised with an asset type name, the group will contain all assets of
+    the given type that are accessible to the current user's account.
+
+    When initialised with a query for GenericAssets, as well, the group will list the assets returned by that query. This can be useful in combination with get_asset_group_queries,
+    see above.
+
+    TODO: On a conceptual level, we can model two functionally useful ways of grouping assets:
+    - AggregatedAsset if it groups assets of only 1 type,
+    - GeneralizedAsset if it groups assets of multiple types
+    There might be specialised subclasses, as well, for certain groups, like a market and consumers.
+    """
+
+    name: str
+    assets: List[GenericAsset]
+    count: int
+    unique_asset_types: List[GenericAssetType]
+    unique_asset_type_names: List[str]
+
+    def __init__(self, name: str, asset_query: Optional[Query] = None):
+        """ The asset group name is either the name of an asset group or an individual asset. """
+        if name is None or name == "":
+            raise Exception("Empty asset (group) name passed (%s)" % name)
+        self.name = name
+
+        if not asset_query:
+            asset_query = GenericAsset.query.filter_by(name=self.name)
+
+        # List unique asset types and asset type names represented by this group
+        self.assets = asset_query.all()
+        self.unique_asset_types = list(set([a.asset_type for a in self.assets]))
+        self.unique_asset_type_names = list(
+            set([a.asset_type.name for a in self.assets])
+        )
+
+        # Count all assets that are identified by this group's name
+        self.count = len(self.assets)
+
+    @property
+    def is_unique_asset(self) -> bool:
+        """Determines whether the resource represents a unique asset."""
+        return [self.name] == [a.name for a in self.assets]
+
+    @property
+    def display_name(self) -> str:
+        """Attempt to get a beautiful name to show if possible."""
+        if self.is_unique_asset:
+            return self.assets[0].name
+        return self.name
+
+    def is_eligible_for_comparing_individual_traces(self, max_traces: int = 7) -> bool:
+        """
+        Decide whether comparing individual traces for assets in this resource
+        is a useful feature.
+        The number of assets that can be compared is parametrizable with max_traces.
+        Plot colors are reused if max_traces > 7, and run out if max_traces > 105.
+        """
+        return len(self.assets) in range(2, max_traces + 1) and assets_share_location(
+            self.assets
+        )
+
+    @property
+    def hover_label(self) -> Optional[str]:
+        """Attempt to get a hover label to show if possible."""
+        label = p.join(
+            [
+                asset_type.description
+                for asset_type in self.unique_asset_types
+                if asset_type.description is not None
+            ]
+        )
+        return label if label else None
+
+    @property
+    def parameterized_name(self) -> str:
+        """Get a parametrized name for use in javascript."""
+        return parameterize(self.name)
+
+    def __str__(self):
+        return self.display_name
+
+
+def group_assets_by_location(
+    asset_list: List[GenericAsset],
+) -> List[List[GenericAsset]]:
+    groups = []
+
+    def key_function(x):
+        return x.location if x.location else ()
+
+    sorted_asset_list = sorted(asset_list, key=key_function)
+    for _k, g in groupby(sorted_asset_list, key=key_function):
+        groups.append(list(g))
+    return groups
diff --git a/flexmeasures/data/services/resources.py b/flexmeasures/data/services/resources.py
index 4f68f8593..d4a7f8247 100644
--- a/flexmeasures/data/services/resources.py
+++ b/flexmeasures/data/services/resources.py
@@ -1,5 +1,10 @@
 """
 Generic services for accessing asset data.
+
+TODO: This works with the legacy data model (esp. Assets), so it is marked for deprecation.
+      We are building data.services.asset_grouping, porting much of the code here.
+      The data access logic here might also be useful for sensor data access logic we'll build
+      elsewhere, but that's not quite certain at this point in time.
 """
 
 from __future__ import annotations
diff --git a/flexmeasures/ui/templates/base.html b/flexmeasures/ui/templates/base.html
index 7e8cf6d36..335f5546c 100644
--- a/flexmeasures/ui/templates/base.html
+++ b/flexmeasures/ui/templates/base.html
@@ -255,6 +255,75 @@
                 <script>var CurrentYear = new Date().getFullYear(); document.write(CurrentYear)</script>.
                 {% endblock copyright_notice %}
 
+                {% block about %}
+                <a href="#" data-toggle="modal" data-target="#About">About FlexMeasures</a>.
+                <!-- The modal -->
+                <div class="modal fade" id="About" tabindex="-1" role="dialog" aria-labelledby="modalLabelLarge" aria-hidden="true">
+                <div class="modal-dialog modal-lg">
+                <div class="modal-content">
+
+                <div class="modal-header">
+                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">&times;</span>
+                    </button>
+                    <h4 class="modal-title" id="modalLabelLarge">About FlexMeasures</h4>
+                </div>
+
+                <div class="modal-body">
+
+                    <div class="justify">
+                        <p>
+                            In a world with renewable energy, flexibility is crucial for cost and CO₂ reduction.
+                            Planning ahead allows flexible devices to profit from scheduling the best flexible
+                            actions (such as shifting or curtailing energy use).
+                        </p>
+                        <p>
+                            The {{ FLEXMEASURES_PLATFORM_NAME }} Platform is a tool for businesses operating energy devices.
+                            Its purpose is to realise the best value for device owners by scheduling balancing actions.
+                            For instance, if the devices draw from or supply to the power grid, {{ FLEXMEASURES_PLATFORM_NAME }} can assist in
+                            selling balancing services to energy markets.
+                            It fulfills this purpose with three services: Monitoring, forecasting and scheduling.
+                            {{ FLEXMEASURES_PLATFORM_NAME }} is designed as open-source software to empower energy service companies while they
+                            maintain autonomy over their operations and their technology roadmap.
+                            Read more <a href="https://flexmeasures.readthedocs.io">here</a>.
+
+                        </p>
+                        <p>
+                            {{ FLEXMEASURES_PLATFORM_NAME }} is compliant with the Universal Smart Energy Framework (<a
+                                href="https://www.usef.energy/download-the-framework/a-flexibility-market-design/">USEF</a>), a
+                            “flexibility market design for the trading and commoditisation of energy flexibility and the
+                            architecture, tools and rules to make it work effectively.
+                            USEF fits on top of most market models and is already being adopted across Europe to accelerate and
+                            future-proof smart energy projects.”
+                        </p>
+                        {% if current_user.has_role('anonymous') and current_user.has_role('CPO') %}
+                        <p>
+                            This demo is tuned to operators of charge points for electric vehicles (EVs).
+                            The terminology used follows the Open Charge Point Interface (<a
+                                href="https://ocpi-protocol.org/">OCPI</a>).
+                            This dashboard shows you the locations of all charge points connected to the platform and how they
+                            are doing.
+                            Each charge point contains multiple chargers&mdash;called Electric Vehicle Supply Equipment (EVSE)
+                            in OCPI&mdash;which are monitored individually (click on a charge point location to reveal its
+                            EVSE).
+                        </p>
+                        <p>
+                            {{ FLEXMEASURES_PLATFORM_NAME }} supports automated scheduling of charging profiles through its API.
+                            Charging profiles are optimised against applicable market conditions like wholesale prices and
+                            contracted (time-of-use) tariffs.
+                            Charging preferences can be set to reflect whether EV owners are in a hurry or parking for some
+                            time.
+                        </p>
+                        {% endif %}
+                    </div>
+                </div>
+                
+                </div>
+                </div>
+                </div>
+
+                {% endblock about %}
+
                 {% block credits %}
                 <a href="#" data-toggle="modal" data-target="#Credits">Credits</a>.
 
diff --git a/flexmeasures/ui/templates/views/new_dashboard.html b/flexmeasures/ui/templates/views/new_dashboard.html
new file mode 100644
index 000000000..b9e2e747b
--- /dev/null
+++ b/flexmeasures/ui/templates/views/new_dashboard.html
@@ -0,0 +1,224 @@
+{% extends "base.html" %}
+
+{% set active_page = "dashboard" %}
+
+{% block title %} Dashboard {% endblock %}
+
+{% block divs %}
+
+<div class="container-fluid">
+    <div class="row">
+        <div class="col-sm-12">
+            <h3>
+                {% if user_is_admin %}{{ FLEXMEASURES_PLATFORM_NAME }} ― asset status:
+                {% else %}Status of my assets:{% endif%}
+            </h3>
+
+            <div id="mapid"></div>
+
+            <table class="table" style="font-size: 100%;">
+                <thead>
+                    <tr>
+                        <th></th>
+                        {% for asset_group_name in asset_groups if asset_groups[asset_group_name].count > 0 %}
+                        {# On demo, show all non-empty groups, otherwise show all groups that are non-empty for the current user #}
+                        <th {% if asset_groups[asset_group_name].hover_label %}
+                            title="{{ asset_groups[asset_group_name].hover_label | capitalize }}" {% endif %}
+                            class="text-center{% if asset_group_name in aggregate_groups %} agg-group{% endif %}"><a
+                                href="analytics?resource={{ asset_group_name }}">{{ asset_group_name | capitalize }}</a>
+                        </th>
+                        {% endfor %}
+                    </tr>
+                </thead>
+                <tbody>
+                    <tr>
+                        <td></td>
+
+                        {% for asset_group_name in asset_groups if asset_groups[asset_group_name].count > 0 %}
+                        <td class="text-center{% if asset_group_name in aggregate_groups %} agg-group{% endif %}">
+                            <div style="min-height: 20px; padding:15px;">
+                                <i class="{{ asset_group_name | asset_icon }} center-icon"></i>
+                            </div>
+                        </td>
+                        {% endfor %}
+                    </tr>
+                    {% if not user_is_admin %}
+                    <tr>
+                        <td>My assets:</td>
+
+                        {% for asset_group_name in asset_groups if asset_groups[asset_group_name].count > 0 %}
+                        <td class="text-center{% if asset_group_name in aggregate_groups %} agg-group{% endif %}">
+                            {{ asset_groups[asset_group_name].count }}</td>
+                        {% endfor %}
+                    </tr>
+                    {% endif %}
+                    {% if user_is_admin or FLEXMEASURES_MODE == "demo" %}
+                    <tr>
+                        <td>{{ FLEXMEASURES_PLATFORM_NAME }} total:</td>
+
+                        {% for asset_group_name in asset_groups if asset_groups[asset_group_name].count > 0 %}
+                        <td class="text-center{% if asset_group_name in aggregate_groups %} agg-group{% endif %}">
+                            {{ asset_groups[asset_group_name].count }}</td>
+                        {% endfor %}
+                    </tr>
+                    {% endif %}
+                </tbody>
+            </table>
+        </div>
+    </div>
+</div>
+
+
+<!-- Initialise the map -->
+<script src="https://cdn.jsdelivr.net/npm/leaflet@1.7.1/dist/leaflet-src.min.js"></script>
+<script src="{{ url_for('flexmeasures_ui.static', filename='js/map-init.js') }}"></script>
+<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
+<script
+    src="https://unpkg.com/leaflet.markercluster.layersupport@2.0.1/dist/leaflet.markercluster.layersupport.js"></script>
+
+{{ bokeh_html_embedded | safe }}
+<script type="text/javascript">
+
+    function lookForState(event) {
+        $.ajax({
+            url: "/asset/" + encodeURI(this.options.id) + "/state",
+            success: stateFoundCallback
+        });
+    }
+
+    function stateFoundCallback(data) {
+        $(".bokeh-state-plot").html(data);
+    }
+
+    // create lists for markers, one per asset type, and icons, one per asset type
+
+    {% for asset_group_name in asset_groups %}
+    {% if asset_group_name not in aggregate_groups %}
+    {% if asset_groups[asset_group_name].count > 0 %}
+    var {{ asset_groups[asset_group_name].parameterized_name }}_markers = [];
+
+    var {{ asset_groups[asset_group_name].parameterized_name }}_icon = new L.DivIcon({
+        className: 'map-icon',
+        html: '<i class="icon-empty-marker center-icon supersize"></i><i class="overlay center-icon {{ asset_groups[asset_group_name].unique_asset_types[0].name | asset_icon }}"></i>',
+        iconSize: [100, 100], // size of the icon
+        iconAnchor: [50, 50], // point of the icon which will correspond to marker's location
+        popupAnchor: [0, -50] // point from which the popup should open relative to the iconAnchor
+    });
+
+    var {{ asset_groups[asset_group_name].parameterized_name }}_opportunity_icon = new L.DivIcon({
+        className: 'map-icon opportunity',
+        html: '<i class="{{ asset_groups[asset_group_name].unique_asset_types[0].name | asset_icon }}"></i>',
+        iconSize: [24, 24], // size of the icon
+        iconAnchor: [12, 12], // point of the icon which will correspond to marker's location
+        popupAnchor: [0, -12] // point from which the popup should open relative to the iconAnchor
+    });
+    {% endif %}
+    {% endif %}
+    {% endfor %}
+
+
+    // create markers, keep them in separate lists (by asset type) to be put into layers
+
+    {% for asset_group_name in asset_groups %}
+    {% if asset_group_name not in aggregate_groups %}
+    {% for asset in asset_groups[asset_group_name].assets if asset.location %}
+                <!--var marker_for_{{ (asset.name | parameterize) }} = makeMarker({{ asset }})-->
+
+                if (typeof marker_for_{{ (asset.name | parameterize) }} == 'undefined') {
+        var marker_for_{{ (asset.name | parameterize)
+    }} = L
+        .marker(
+            [{{ asset.location[0] }}, {{ asset.location[1] }}],
+            { icon: {{ asset_groups[asset_group_name].parameterized_name }}_icon, id: "{{ asset.id }}"}
+    )
+        .bindPopup(`
+                            <div class="leaflet-marker-popup">
+                                <div class="row">
+                                    <div class="col-sm-12">
+                                        <h4>{{ asset.name }}</h4>
+                                    </div>
+                                </div>
+                                <div class="row">
+                                    <div class="col-sm-5 bokeh-state-plot"></div>
+                                    <div class="col-sm-7">
+                                        <div class="row top-buffer">
+                                            <div class="col-sm-12">
+                                                <form action="analytics" method="get">
+                                                    <input type="hidden" name="resource" value="{{ asset.name }}">
+                                                    <button class="btn btn-sm btn-responsive btn-info" type="submit">Analyse this asset</button>
+                                                </form>
+                                            </div>
+                                        </div>
+                                        <div class="row top-buffer">
+                                            <div class="col-sm-12">
+                                                <form action="assets/{{ asset.id }}" method="get">
+                                                    <button class="btn btn-sm btn-responsive btn-info" type="submit">Edit this asset</button>
+                                                </form>
+                                            </div>
+                                        </div>
+        <!--                                <div class="row top-buffer">-->
+        <!--                                    <div class="col-sm-12">-->
+        <!--                                        <form action="control" method="get">-->
+        <!--                                            &lt;!&ndash;<input type="hidden" name="offer" value="still_mocked">&ndash;&gt;-->
+        <!--                                            &lt;!&ndash;<button class="btn btn-sm btn-responsive btn-danger" type="submit">Review opportunity</button>&ndash;&gt;-->
+        <!--                                        </form>-->
+        <!--                                    </div>-->
+        <!--                                </div>-->
+                                    </div>
+                                </div>
+                            </div>
+                            `
+        )
+        .on('mouseover', function () {
+            $(this._icon).addClass('over');
+        })
+        .on('mouseout', function () {
+            $(this._icon).delay(3000).queue(function (next) {
+                $(this).removeClass('over');
+                next();
+            });
+        })
+        .bindTooltip("{{ asset.name }}",
+        {
+            permanent: false,
+            direction: 'right'
+        })
+        .on('click', clickPan)
+        .on('click', lookForState);
+    // .openPopup();
+    {{ asset_groups[asset_group_name].parameterized_name }}_markers.push(marker_for_{{ (asset.name | parameterize) }});
+                }
+    {% endfor %}
+    {% endif %}
+    {% endfor %}
+
+    // create Map with tiles
+    var assetMap = L
+        .map('mapid', { center: [{{ map_center[0] }}, {{ map_center[1] }}], zoom: 6 })
+        .on('popupopen', function () {
+            $(function () {
+                $('[data-toggle="tooltip"]').tooltip();
+            });
+        });
+    addTileLayer(assetMap, '{{ mapboxAccessToken }}');
+    var mcgLayerSupportGroup = L.markerClusterGroup.layerSupport();
+    var control = L.control.layers(null, null, { collapsed: true });
+
+    // add a layer for each asset type
+    {% for asset_group_name in asset_groups %}
+    {% if asset_group_name not in aggregate_groups %}
+    {% if asset_groups[asset_group_name].count > 0 %}
+    var {{ asset_groups[asset_group_name].parameterized_name }}_layer = new L.LayerGroup({{ asset_groups[asset_group_name].parameterized_name }}_markers);
+    mcgLayerSupportGroup.checkIn({{ asset_groups[asset_group_name].parameterized_name }}_layer);
+    control.addOverlay({{ asset_groups[asset_group_name].parameterized_name }}_layer, "{{ asset_group_name | capitalize }}")
+    {{ asset_groups[asset_group_name].parameterized_name }}_layer.addTo(assetMap);
+    {% endif %}
+    {% endif %}
+    {% endfor %}
+    mcgLayerSupportGroup.addTo(assetMap);
+    control.addTo(assetMap);
+
+</script>
+
+
+{% endblock %}
diff --git a/flexmeasures/ui/views/__init__.py b/flexmeasures/ui/views/__init__.py
index d427aca78..4614f48f9 100644
--- a/flexmeasures/ui/views/__init__.py
+++ b/flexmeasures/ui/views/__init__.py
@@ -3,11 +3,14 @@
 from flexmeasures.ui import flexmeasures_ui
 
 # Now views can register
-from flexmeasures.ui.views.dashboard import dashboard_view  # noqa: F401
+from flexmeasures.ui.views.dashboard import (  # noqa: F401
+    dashboard_view as legacy_dashboard_view,
+)
+from flexmeasures.ui.views.new_dashboard import new_dashboard_view  # noqa: F401
 from flexmeasures.ui.views.portfolio import portfolio_view  # noqa: F401
 from flexmeasures.ui.views.control import control_view  # noqa: F401
 from flexmeasures.ui.views.analytics import analytics_view  # noqa: F401
-from flexmeasures.ui.views.state import state_view  # noqa: F401
+from flexmeasures.ui.views.state import sensor_state_view  # noqa: F401
 
 from flexmeasures.ui.views.logged_in_user import (  # noqa: F401  # noqa: F401
     logged_in_user_view,
diff --git a/flexmeasures/ui/views/dashboard.py b/flexmeasures/ui/views/dashboard.py
index fbcb553f5..51a881b81 100644
--- a/flexmeasures/ui/views/dashboard.py
+++ b/flexmeasures/ui/views/dashboard.py
@@ -12,9 +12,12 @@
     get_center_location,
 )
 
+"""
+Note: This view is deprecated.
+"""
 
-# Dashboard (default root view, see utils/app_utils.py)
-@flexmeasures_ui.route("/dashboard")
+
+@flexmeasures_ui.route("/legacy-dashboard")
 @login_required
 def dashboard_view():
     """Dashboard view.
diff --git a/flexmeasures/ui/views/new_dashboard.py b/flexmeasures/ui/views/new_dashboard.py
new file mode 100644
index 000000000..90623e8b7
--- /dev/null
+++ b/flexmeasures/ui/views/new_dashboard.py
@@ -0,0 +1,65 @@
+from flask import request, current_app
+from flask_security import login_required
+from flask_security.core import current_user
+from bokeh.resources import CDN
+
+from flexmeasures.data.config import db
+from flexmeasures.ui.views import flexmeasures_ui
+from flexmeasures.ui.utils.view_utils import render_flexmeasures_template, clear_session
+from flexmeasures.data.models.generic_assets import get_center_location_of_assets
+from flexmeasures.data.services.asset_grouping import (
+    AssetGroup,
+    get_asset_group_queries,
+)
+
+
+# Dashboard (default root view, see utils/app_utils.py)
+@flexmeasures_ui.route("/dashboard")
+@login_required
+def new_dashboard_view():
+    """Dashboard view.
+    This is the default landing page.
+    It shows a map with the location of all of the assets in the user's account,
+    as well as a breakdown of the asset types.
+    Here, we are only interested in showing assets with power sensors.
+    Admins get to see all assets.
+
+    TODO: Assets for which the platform has identified upcoming balancing opportunities are highlighted.
+    """
+    msg = ""
+    if "clear-session" in request.values:
+        clear_session()
+        msg = "Your session was cleared."
+
+    aggregate_groups = current_app.config.get("FLEXMEASURES_ASSET_TYPE_GROUPS", {})
+    asset_groups = get_asset_group_queries(custom_additional_groups=aggregate_groups)
+
+    map_asset_groups = {}
+    for asset_group_name, asset_group_query in asset_groups.items():
+        asset_group = AssetGroup(asset_group_name, asset_query=asset_group_query)
+        if any(
+            [
+                a.location and (a.has_power_sensors or a.has_energy_sensors)
+                for a in asset_group.assets
+            ]
+        ):
+            map_asset_groups[asset_group_name] = asset_group
+
+    # Pack CDN resources (from pandas_bokeh/base.py)
+    bokeh_html_embedded = ""
+    for css in CDN.css_files:
+        bokeh_html_embedded += (
+            """<link href="%s" rel="stylesheet" type="text/css">\n""" % css
+        )
+    for js in CDN.js_files:
+        bokeh_html_embedded += """<script src="%s"></script>\n""" % js
+
+    return render_flexmeasures_template(
+        "views/new_dashboard.html",
+        message=msg,
+        bokeh_html_embedded=bokeh_html_embedded,
+        mapboxAccessToken=current_app.config.get("MAPBOX_ACCESS_TOKEN", ""),
+        map_center=get_center_location_of_assets(db, user=current_user),
+        asset_groups=map_asset_groups,
+        aggregate_groups=aggregate_groups,
+    )
diff --git a/flexmeasures/ui/views/state.py b/flexmeasures/ui/views/state.py
index 9c1e710fb..b49510f36 100644
--- a/flexmeasures/ui/views/state.py
+++ b/flexmeasures/ui/views/state.py
@@ -1,33 +1,90 @@
+from typing import Optional
+
 from flask import request
 from flask_security import auth_required
+from webargs.flaskparser import use_kwargs
 
+from flexmeasures.auth.decorators import permission_required_for_context
 from flexmeasures.data.models.assets import Asset
+from flexmeasures.data.models.generic_assets import GenericAsset
+from flexmeasures.data.models.time_series import Sensor
 from flexmeasures.data.services.resources import can_access_asset
-from flexmeasures.ui.utils.plotting_utils import get_latest_power_as_plot
+from flexmeasures.api.common.schemas.sensors import SensorIdField
+from flexmeasures.api.common.schemas.generic_assets import AssetIdField
+from flexmeasures.ui.utils.plotting_utils import (
+    get_latest_power_as_plot as legacy_get_latest_power_as_plot,
+)
+from flexmeasures.utils.unit_utils import is_power_unit
+from flexmeasures.ui.charts.latest_state import get_latest_power_as_plot
 from flexmeasures.ui.views import flexmeasures_ui
 
 
+@flexmeasures_ui.route("/asset/<id>/state/")
+@use_kwargs({"asset": AssetIdField(data_key="id")}, location="path")
+@permission_required_for_context("read", arg_name="asset")
+def asset_state_view(id: str, asset: GenericAsset):
+    """Asset state view.
+    This returns a little html snippet with a plot of the most recent state of the
+    first found power sensor.
+    TODO: no need to only support power plots in the future. Might make it optional.
+    """
+    power_sensor: Optional[Sensor] = None
+    for sensor in asset.sensors:
+        if is_power_unit(sensor.unit):
+            power_sensor = sensor
+            break
+    if power_sensor is None:
+        return (
+            """<script type="text/javascript">
+        console.log("State not available. Asset %s has no power sensors.");
+        </script>"""
+            % id
+        )
+    time_str, plot_html_str = get_latest_power_as_plot(power_sensor, small=True)
+    return plot_html_str
+
+
+@flexmeasures_ui.route("/sensor/<id>/state")
+@use_kwargs({"sensor": SensorIdField(data_key="id")}, location="path")
+@permission_required_for_context("read", arg_name="sensor")
+def sensor_state_view(id: int, sensor: Sensor):
+    """Sensor state view.
+    This returns a little html snippet with a plot of the most recent state of the sensor.
+    Only works for power sensors at the moment.
+    TODO: no need to only support power plots in the future. Might make it optional.
+    """
+    if not is_power_unit(sensor.unit):
+        return """"<script type="text/javascript">
+        console.log("State not available. Sensor is not a power sensor.");
+        </script>"""
+    time_str, plot_html_str = get_latest_power_as_plot(sensor, small=True)
+    return plot_html_str
+
+
 @flexmeasures_ui.route("/state")
 @auth_required()
 def state_view():
     """State view.
     This returns a little html snippet with a plot of the most recent state of the asset.
+
+    TODO: This is legacy ― it uses the old database model.
     """
     asset_id = request.args.get("id")
     try:
         int(asset_id)
     except ValueError:
-        return """"<script type="text/javascript">
+        return """<script type="text/javascript">
         console.log("State not available. Asset not officially registered.");
         </script>"""
+    # TODO: try Sensor, then Asset (legacy)?
     asset = Asset.query.filter(Asset.id == asset_id).one_or_none()
     if asset is None:
         return """"<script type="text/javascript">
         console.log("State not available. No asset found.");
         </script>"""
     if not can_access_asset(asset):
-        return """"<script type="text/javascript">
+        return """<script type="text/javascript">
         console.log("State not available. No access rights for this asset.");
         </script>"""
-    time_str, plot_html_str = get_latest_power_as_plot(asset, small=True)
+    time_str, plot_html_str = legacy_get_latest_power_as_plot(asset, small=True)
     return plot_html_str
diff --git a/flexmeasures/utils/config_defaults.py b/flexmeasures/utils/config_defaults.py
index 17415c9d7..aa122f163 100644
--- a/flexmeasures/utils/config_defaults.py
+++ b/flexmeasures/utils/config_defaults.py
@@ -109,6 +109,10 @@ class Config(object):
     ]
     FLEXMEASURES_MENU_LISTED_VIEW_ICONS: Dict[str, str] = {}
     FLEXMEASURES_MENU_LISTED_VIEW_TITLES: Dict[str, str] = {}
+    FLEXMEASURES_ASSET_TYPE_GROUPS = {
+        "renewables": ["solar", "wind"],
+        "EVSE": ["one-way_evse", "two-way_evse"],
+    }  # how to group assets by asset types
     FLEXMEASURES_LP_SOLVER: str = "cbc"
     FLEXMEASURES_JOB_TTL: timedelta = timedelta(days=1)
     FLEXMEASURES_PLANNING_HORIZON: timedelta = timedelta(hours=2 * 24)
diff --git a/flexmeasures/utils/error_utils.py b/flexmeasures/utils/error_utils.py
index 27c53c612..8fa2ad728 100644
--- a/flexmeasures/utils/error_utils.py
+++ b/flexmeasures/utils/error_utils.py
@@ -63,7 +63,7 @@ def error_handling_router(error: HTTPException):
     if hasattr(error, "code"):
         try:
             http_error_code = int(error.code)
-        except ValueError:
+        except (ValueError, TypeError):  # if code is not an int or None
             pass
 
     error_text = getattr(
diff --git a/flexmeasures/utils/unit_utils.py b/flexmeasures/utils/unit_utils.py
index d4e46b53d..48c4523e3 100644
--- a/flexmeasures/utils/unit_utils.py
+++ b/flexmeasures/utils/unit_utils.py
@@ -20,3 +20,23 @@ def determine_stock_unit(flow_unit: str, time_unit: str = "h"):
         if flow_unit.endswith(f"/{time_unit}")
         else f"{flow_unit}{time_unit}"
     )
+
+
+def is_power_unit(unit: str) -> bool:
+    """For example:
+    >>> is_power_unit("kW")  # True
+    >>> is_power_unit("°C")  # False
+    >>> is_power_unit("kWh")  # False
+    >>> is_power_unit("EUR/kWh")  # False
+    """
+    return unit in ("W", "kW", "MW")
+
+
+def is_energy_unit(unit: str) -> bool:
+    """For example:
+    >>> is_power_unit("kW")  # False
+    >>> is_power_unit("°C")  # False
+    >>> is_power_unit("kWh")  # True
+    >>> is_power_unit("EUR/kWh")  # False
+    """
+    return unit in ("Wh", "kWh", "MWh")
diff --git a/requirements/app.txt b/requirements/app.txt
index 0bb93b3de..3b2eaa204 100644
--- a/requirements/app.txt
+++ b/requirements/app.txt
@@ -21,6 +21,10 @@ attrs==21.2.0
     #   jsonschema
     #   outcome
     #   trio
+backports.zoneinfo==0.2.1
+    # via
+    #   pytz-deprecation-shim
+    #   tzlocal
 bcrypt==3.2.0
     # via -r requirements/app.in
 beautifulsoup4==4.10.0
@@ -136,7 +140,10 @@ idna==3.3
 importlib-metadata==4.8.1
     # via
     #   -r requirements/app.in
+    #   alembic
     #   timely-beliefs
+importlib-resources==5.4.0
+    # via alembic
 inflect==5.3.0
     # via -r requirements/app.in
 inflection==0.5.1
@@ -383,7 +390,9 @@ wtforms==2.3.3
 xlrd==2.0.1
     # via -r requirements/app.in
 zipp==3.6.0
-    # via importlib-metadata
+    # via
+    #   importlib-metadata
+    #   importlib-resources
 
 # The following packages are considered to be unsafe in a requirements file:
 # setuptools


Test patch:
diff --git a/flexmeasures/ui/charts/latest_state.py b/flexmeasures/ui/charts/latest_state.py
new file mode 100644
index 000000000..8422c5e57
--- /dev/null
+++ b/flexmeasures/ui/charts/latest_state.py
@@ -0,0 +1,115 @@
+from typing import Tuple
+from datetime import datetime
+
+from flask import current_app
+from colour import Color
+import pandas as pd
+import pandas_bokeh
+from bokeh.models import BoxAnnotation
+
+
+from flexmeasures.data.services.time_series import convert_query_window_for_demo
+from flexmeasures.utils.time_utils import (
+    server_now,
+    localized_datetime_str,
+)
+from flexmeasures.data.models.time_series import Sensor
+
+
+def get_latest_power_as_plot(sensor: Sensor, small: bool = False) -> Tuple[str, str]:
+    """
+    Create a plot of a sensor's latest power measurement as an embeddable html string (incl. javascript).
+    First returned string is the measurement time, second string is the html string.
+
+    Assumes that the sensor has the "capacity_in_mw" attribute.
+
+    TODO: move to Altair.
+    """
+
+    if current_app.config.get("FLEXMEASURES_MODE", "") == "play":
+        before = None  # type:ignore
+    else:
+        before = server_now()
+        _, before = convert_query_window_for_demo((before, before))
+
+    latest_power = sensor.latest_state()
+    if not latest_power.empty:
+        # TODO: Get first entry
+        latest_power_value = latest_power.event_value
+        if current_app.config.get("FLEXMEASURES_MODE", "") == "demo":
+            latest_power_datetime = latest_power.belief_time.replace(
+                year=datetime.now().year
+            )
+        else:
+            latest_power_datetime = latest_power.belief_time
+        latest_measurement_time_str = localized_datetime_str(
+            latest_power_datetime + sensor.event_resolution
+        )
+    else:
+        latest_power_value = 0
+        latest_measurement_time_str = "time unknown"
+    if latest_power_value < 0:
+        consumption = True
+        latest_power_value *= -1
+    else:
+        consumption = False
+    capacity_in_mw = sensor.get_attribute("capacity_in_mw")
+    data = {
+        latest_measurement_time_str if not small else "": [0],
+        "Capacity in use": [latest_power_value],
+        "Remaining capacity": [capacity_in_mw - latest_power_value],
+    }
+    percentage_capacity = latest_power_value / capacity_in_mw
+    df = pd.DataFrame(data)
+    p = df.plot_bokeh(
+        kind="bar",
+        x=latest_measurement_time_str if not small else "",
+        y=["Capacity in use", "Remaining capacity"],
+        stacked=True,
+        colormap=[
+            "%s"
+            % Color(
+                hue=0.3 * min(1.0, 3 / 2 * percentage_capacity),
+                saturation=1,
+                luminance=min(0.5, 1 - percentage_capacity * 3 / 4),
+            ).get_hex_l(),  # 0% red, 38% yellow, 67% green, >67% darker green
+            "#f7ebe7",
+        ],
+        alpha=0.7,
+        title=None,
+        xlabel=None,
+        ylabel="Power (%s)" % sensor.unit,
+        zooming=False,
+        show_figure=False,
+        hovertool=None,
+        legend=None,
+        toolbar_location=None,
+        figsize=(200, 400) if not small else (100, 100),
+        ylim=(0, capacity_in_mw),
+        xlim=(-0.5, 0.5),
+    )
+    p.xgrid.visible = False
+    for r in p.renderers:
+        try:
+            r.glyph.width = 1
+        except AttributeError:
+            pass
+    p.xaxis.ticker = []
+    p.add_layout(BoxAnnotation(bottom=0, top=capacity_in_mw, fill_color="#f7ebe7"))
+    plot_html_str = pandas_bokeh.embedded_html(p)
+    hover_tool_str = "%s at %s %s (%s%% capacity).\nLatest state at %s." % (
+        "Consuming"
+        if consumption
+        else "Running"
+        if latest_power_value == 0
+        else "Producing",
+        round(latest_power_value, 3),
+        sensor.unit,
+        round(100 * percentage_capacity),
+        latest_measurement_time_str,
+    )
+    return (
+        latest_measurement_time_str,
+        """<div data-toggle="tooltip" data-placement="bottom" title="%s">%s</div>"""
+        % (hover_tool_str, plot_html_str),
+    )