Skip to content

Commit

Permalink
UI: Dashboard using GenericAssets (#251)
Browse files Browse the repository at this point in the history
* Create draft PR for #249

* working state of new dashboard; created new file asset_grouping with class AssetGroup, which is aimed to replace resources.Resource; created new view /sensor/<id>/state

* smaller items from review and add a default ACL for generic assets

* move querying assets by type to a data.queries module

* make asset type grouping configurable & simplify initialising an AssetGroup

* query asset for its state (via one of its power sensors) and also identify power sensors by unit

* Dashboard only shows asset groups where at least one asset has a location and power sensors

* add missing module

* restore grouping by location behaviour for later

* move intro text to modal dialogue

* make is_power_unit only care about power

* include energy assets on dashboard

* fix docstring

Co-authored-by: nhoening <nhoening@users.noreply.github.com>
Co-authored-by: Nicolas Höning <nicolas@seita.nl>
  • Loading branch information
3 people committed Dec 21, 2021
1 parent 0d26f7c commit 754b46b
Show file tree
Hide file tree
Showing 19 changed files with 967 additions and 13 deletions.
8 changes: 8 additions & 0 deletions documentation/configuration.rst
Expand Up @@ -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
------

Expand Down
21 changes: 21 additions & 0 deletions 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
16 changes: 16 additions & 0 deletions flexmeasures/api/common/schemas/sensors.py
@@ -1,3 +1,4 @@
from flask import abort
from marshmallow import fields

from flexmeasures.api import FMValidationError
Expand All @@ -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)."""
Expand Down
71 changes: 69 additions & 2 deletions flexmeasures/data/models/generic_assets.py
@@ -1,11 +1,17 @@
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

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


Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
28 changes: 27 additions & 1 deletion flexmeasures/data/models/time_series.py
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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={})
Expand Down Expand Up @@ -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")
Expand All @@ -77,6 +91,16 @@ def location(self) -> Optional[Tuple[float, float]]:
if None not in location:
return location

@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."""
Expand Down Expand Up @@ -458,6 +482,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
Expand Down
20 changes: 20 additions & 0 deletions 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

0 comments on commit 754b46b

Please sign in to comment.