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 4 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
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"User {id} not found")
nhoening marked this conversation as resolved.
Show resolved Hide resolved
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
47 changes: 46 additions & 1 deletion flexmeasures/data/models/generic_assets.py
@@ -1,8 +1,13 @@
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.mutable import MutableDict

from flexmeasures.data import db
from flexmeasures.data.models.user import User


class GenericAssetType(db.Model):
Expand Down Expand Up @@ -38,6 +43,11 @@ class GenericAsset(db.Model):
backref=db.backref("generic_assets", lazy=True),
)

@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 All @@ -55,6 +65,7 @@ class GenericAsset(db.Model):

@property
def location(self) -> Optional[Tuple[float, float]]:
print((self.latitude, self.longitude))
nhoening marked this conversation as resolved.
Show resolved Hide resolved
if None not in (self.latitude, self.longitude):
return self.latitude, self.longitude
return None
Expand Down Expand Up @@ -104,3 +115,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
14 changes: 13 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 @@ -28,7 +29,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 +68,15 @@ 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.id}", "update": f"account:{self.id}"}
nhoening marked this conversation as resolved.
Show resolved Hide resolved

@property
def entity_address(self) -> str:
return build_entity_address(dict(sensor_id=self.id), "sensor")
Expand Down Expand Up @@ -464,6 +474,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
242 changes: 242 additions & 0 deletions flexmeasures/data/services/asset_grouping.py
@@ -0,0 +1,242 @@
"""
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.utils.flexmeasures_inflection import parameterize, pluralize
from flexmeasures.data.models.generic_assets import (
GenericAssetType,
GenericAsset,
assets_share_location,
)

p = inflect.engine()


def get_asset_group_queries(
nhoening marked this conversation as resolved.
Show resolved Hide resolved
custom_additional_groups: Optional[List[str]] = None,
) -> Dict[str, Query]:
"""
An asset group is defined by Asset queries. Each query has a name, and we prefer pluralised display names.
They still need an executive call, like all(), count() or first().

Note: Make sure the current user has the "read" permission on his account (on GenericAsset.__class__??).

:param custom_additional_groups: list of additional groups next to groups that represent unique asset types.
Valid names are:
- "renewables", to query all solar and wind assets
- "EVSE", to query all Electric Vehicle Supply Equipment
- "location", to query each individual location with assets
(i.e. all EVSE at 1 location or each household)
"""

if custom_additional_groups is None:
custom_additional_groups = []
asset_queries = {}

"""
GenericAsset
.join(GenericAssetType)
.filter(GenericAsset.generic_asset_type_id == GenericAssetType.id)
.filter(GenericAssetType.name == generic_asset_type_name)
"""
nhoening marked this conversation as resolved.
Show resolved Hide resolved

# 1. Custom asset groups by combinations of asset types
if "renewables" in custom_additional_groups:
asset_queries["renewables"] = (
GenericAsset.query.join(GenericAssetType)
.filter(GenericAsset.generic_asset_type_id == GenericAssetType.id)
.filter(GenericAssetType.name.in_(["solar", "wind"]))
)
if "EVSE" in custom_additional_groups:
asset_queries["EVSE"] = (
GenericAsset.query.join(GenericAssetType)
.filter(GenericAsset.generic_asset_type_id == GenericAssetType.id)
.filter(GenericAssetType.name.in_(["one-way_evse", "two-way_evse"]))
)

nhoening marked this conversation as resolved.
Show resolved Hide resolved
# 2. We also include a group per asset type - using the pluralised asset type display name
nhoening marked this conversation as resolved.
Show resolved Hide resolved
for asset_type in GenericAssetType.query.all():
asset_queries[pluralize(asset_type.name)] = (
GenericAsset.query.join(GenericAssetType)
.filter(GenericAsset.generic_asset_type_id == GenericAssetType.id)
.filter(GenericAssetType.name == asset_type.name)
)

# 3. Finally, we group assets by location
if "location" in custom_additional_groups:
asset_queries.update(get_location_queries())

# only current user's account
asset_queries = limit_assets_to_account(asset_queries)

return asset_queries


def get_location_queries() -> Dict[str, Query]:
"""
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().

The Charge Points are named on the basis of the first EVSE in their list,
using either the whole EVSE display name or that part that comes before a " -" delimiter. For example:
If:
evse_display_name = "Seoul Hilton - charger 1"
Then:
charge_point_display_name = "Seoul Hilton (Charge Point)"
nhoening marked this conversation as resolved.
Show resolved Hide resolved

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."""
nhoening marked this conversation as resolved.
Show resolved Hide resolved
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 a plural asset type name, the resource will contain all assets of
the given type that are accessible to the current user's account if the user may read them.

When initialised with just one asset name, the resource will list only that asset.

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):
""" The resource 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

# Query assets for all users to set some public information about the resource
asset_queries = get_asset_group_queries(
custom_additional_groups=["renewables", "EVSE", "location"],
)
asset_query = (
asset_queries[self.name]
if name in asset_queries
else GenericAsset.query.filter_by(name=self.name)
) # gather assets that are identified by this resource's name

# List unique asset types and asset type names represented by this resource
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 resource's name and accessible by the current user
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
5 changes: 5 additions & 0 deletions 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
Expand Down