Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI: Dashboard using GenericAssets #251

Merged
merged 17 commits into from Dec 21, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 @@ -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."""
Expand Down Expand Up @@ -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
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