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

Sensor view #99

Merged
merged 27 commits into from Apr 29, 2021
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a8a028a
Add CLI command to add beliefs from csv
Flix6x Apr 4, 2021
1eb8f5c
Refactor if-else block
Flix6x Apr 4, 2021
f9548a0
Add API functions to chart sensor data
Flix6x Apr 9, 2021
d938eda
Move chart specs to dedicated module
Flix6x Apr 9, 2021
56d8397
Docstring correction
Flix6x Apr 12, 2021
19dc9e3
Fix data loading
Flix6x Apr 12, 2021
8ed1d02
Rename variables in sync with version 1.4.1 of timely-beliefs, and ex…
Flix6x Apr 15, 2021
4614091
Clarify what belief_charts_mapping does
Flix6x Apr 15, 2021
503e5ab
Make mapping chart types to specs a function
Flix6x Apr 15, 2021
3fdeed1
Adjust query parameters to renamed tb variables
Flix6x Apr 19, 2021
44c9b1b
Separate endpoint for loading chart data and clean up boolean chart o…
Flix6x Apr 19, 2021
324f4e2
Upgrade tb dependency
Flix6x Apr 19, 2021
13f2370
Dedicated endpoint for get_chart
Flix6x Apr 19, 2021
6894988
Split SensorView into UI and API parts
Flix6x Apr 19, 2021
9bf9e57
Remove the need for jquery in sensors.html
Flix6x Apr 20, 2021
1a238ef
Add FlexMeasures menu (integrate page) and move js util functions
Flix6x Apr 20, 2021
fdcd2f7
Better Altair js dependency management
Flix6x Apr 20, 2021
052795e
Merge branch 'main' into sensor-view
Flix6x Apr 20, 2021
1f404b7
Merge class methods (chart_data into search_beliefs)
Flix6x Apr 22, 2021
9bd99f9
Missing indentation
Flix6x Apr 22, 2021
65122e4
Move SensorAPI to API package under dev version
Flix6x Apr 22, 2021
d0ee431
Update API path in frontend
Flix6x Apr 22, 2021
8578018
flexmeasures.js should be module when exporting functions
Flix6x Apr 22, 2021
8fc6c1a
Factor out as_html
Flix6x Apr 22, 2021
48255a5
Move chart specs from the UI package into the data package (#104)
Flix6x Apr 29, 2021
c921ba9
Fix regression
Flix6x Apr 29, 2021
d7ea462
Merge branch 'main' into sensor-view
Flix6x Apr 29, 2021
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
2 changes: 2 additions & 0 deletions flexmeasures/api/__init__.py
Expand Up @@ -105,9 +105,11 @@ def register_at(app: Flask):
from flexmeasures.api.v1_2 import register_at as v1_2_register_at
from flexmeasures.api.v1_3 import register_at as v1_3_register_at
from flexmeasures.api.v2_0 import register_at as v2_0_register_at
from flexmeasures.api.dev import register_at as dev_register_at

v1_register_at(app)
v1_1_register_at(app)
v1_2_register_at(app)
v1_3_register_at(app)
v2_0_register_at(app)
dev_register_at(app)
13 changes: 13 additions & 0 deletions flexmeasures/api/common/schemas/times.py
Expand Up @@ -62,3 +62,16 @@ def ground_from(
)
return (pd.Timestamp(start) + offset).to_pydatetime() - start
return duration


class AwareDateTimeField(fields.AwareDateTime):
"""Field that deserializes to a timezone aware datetime
and serializes back to a string."""

def _deserialize(self, value: str, attr, obj, **kwargs) -> datetime:
"""
Work-around until this PR lands:
https://github.com/marshmallow-code/marshmallow/pull/1787
"""
value = value.replace(" ", "+")
return fields.AwareDateTime._deserialize(self, value, attr, obj, **kwargs)
9 changes: 9 additions & 0 deletions flexmeasures/api/dev/__init__.py
@@ -0,0 +1,9 @@
from flask import Flask


def register_at(app: Flask):
"""This can be used to register FlaskViews."""

from flexmeasures.api.dev.sensors import SensorAPI

SensorAPI.register(app, route_prefix="/api/dev")
74 changes: 74 additions & 0 deletions flexmeasures/api/dev/sensors.py
@@ -0,0 +1,74 @@
import json

from flask_classful import FlaskView, route
from flask_login import login_required
from flask_security import roles_required
from marshmallow import fields
from webargs.flaskparser import use_kwargs
from werkzeug.exceptions import abort

from flexmeasures.api.common.schemas.times import AwareDateTimeField
from flexmeasures.data.models.time_series import Sensor


class SensorAPI(FlaskView):
"""
This view exposes sensor attributes through API endpoints under development.
These endpoints are not yet part of our official API, but support the FlexMeasures UI.
"""

route_base = "/sensor"

@login_required
@roles_required("admin") # todo: remove after we check for sensor ownership
@route("/<id>/chart/")
@use_kwargs(
{
"event_starts_after": AwareDateTimeField(format="iso", required=False),
"event_ends_before": AwareDateTimeField(format="iso", required=False),
"beliefs_after": AwareDateTimeField(format="iso", required=False),
"beliefs_before": AwareDateTimeField(format="iso", required=False),
"include_data": fields.Boolean(required=False),
"dataset_name": fields.Str(required=False),
},
location="query",
)
def get_chart(self, id, **kwargs):
"""GET from /sensor/<id>/chart"""
sensor = get_sensor_or_abort(id)
return json.dumps(sensor.chart(**kwargs))

@login_required
@roles_required("admin") # todo: remove after we check for sensor ownership
@route("/<id>/chart_data/")
@use_kwargs(
{
"event_starts_after": AwareDateTimeField(format="iso", required=False),
"event_ends_before": AwareDateTimeField(format="iso", required=False),
"beliefs_after": AwareDateTimeField(format="iso", required=False),
"beliefs_before": AwareDateTimeField(format="iso", required=False),
},
location="query",
)
def get_chart_data(self, id, **kwargs):
"""GET from /sensor/<id>/chart_data

Data for use in charts (in case you have the chart specs already).
"""
sensor = get_sensor_or_abort(id)
return sensor.search_beliefs(as_json=True, **kwargs)

@login_required
@roles_required("admin") # todo: remove after we check for sensor ownership
def get(self, id: int):
"""GET from /sensor/<id>"""
sensor = get_sensor_or_abort(id)
attributes = ["name", "timezone", "timerange"]
return {attr: getattr(sensor, attr) for attr in attributes}


def get_sensor_or_abort(id: int) -> Sensor:
sensor = Sensor.query.filter(Sensor.id == id).one_or_none()
if sensor is None:
raise abort(404, f"Sensor {id} not found")
return sensor
2 changes: 1 addition & 1 deletion flexmeasures/api/v2_0/implementations/assets.py
Expand Up @@ -66,7 +66,7 @@ def load_asset(admins_only: bool = False):
should be allowed.

@app.route('/asset/<id>')
@check_asset
@load_asset
def get_asset(asset):
return asset_schema.dump(asset), 200

Expand Down
146 changes: 118 additions & 28 deletions flexmeasures/data/models/time_series.py
@@ -1,5 +1,6 @@
from typing import List, Dict, Optional, Union, Tuple
from datetime import datetime as datetime_type, timedelta
import json

from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import Query, Session
Expand All @@ -17,6 +18,9 @@
exclude_source_type_filter,
)
from flexmeasures.data.services.time_series import collect_time_series_data
from flexmeasures.ui.charts import chart_type_to_chart_specs
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
from flexmeasures.utils.time_utils import server_now
from flexmeasures.utils.flexmeasures_inflection import capitalize


class Sensor(db.Model, tb.SensorDBMixin):
Expand All @@ -29,27 +33,115 @@ def __init__(self, name: str, **kwargs):

def search_beliefs(
self,
event_time_window: Tuple[Optional[datetime_type], Optional[datetime_type]] = (
None,
None,
),
belief_time_window: Tuple[Optional[datetime_type], Optional[datetime_type]] = (
None,
None,
),
event_starts_after: Optional[datetime_type] = None,
event_ends_before: Optional[datetime_type] = None,
beliefs_after: Optional[datetime_type] = None,
beliefs_before: Optional[datetime_type] = None,
source: Optional[Union[int, List[int], str, List[str]]] = None,
as_json: bool = False,
):
"""Search all beliefs about events for this sensor.

:param event_time_window: search only events within this time window
:param belief_time_window: search only beliefs within this time window
:param source: search only beliefs by this source (pass its name or id) or list of sources"""
return TimedBelief.search(
:param event_starts_after: only return beliefs about events that start after this datetime (inclusive)
:param event_ends_before: only return beliefs about events that end before this datetime (inclusive)
:param beliefs_after: only return beliefs formed after this datetime (inclusive)
:param beliefs_before: only return beliefs formed before this datetime (inclusive)
:param source: search only beliefs by this source (pass its name or id) or list of sources
:param as_json: return beliefs in JSON format (e.g. for use in charts) rather than as BeliefsDataFrame
"""
bdf = TimedBelief.search(
sensor=self,
event_time_window=event_time_window,
belief_time_window=belief_time_window,
event_starts_after=event_starts_after,
event_ends_before=event_ends_before,
beliefs_after=beliefs_after,
beliefs_before=beliefs_before,
source=source,
)
if as_json:
df = bdf.reset_index()
df["source"] = df["source"].apply(lambda x: x.name)
return df.to_json(orient="records")
return bdf

def chart(
self,
chart_type: str = "bar_chart",
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
event_starts_after: Optional[datetime_type] = None,
event_ends_before: Optional[datetime_type] = None,
beliefs_after: Optional[datetime_type] = None,
beliefs_before: Optional[datetime_type] = None,
source: Optional[Union[int, List[int], str, List[str]]] = None,
include_data: bool = False,
dataset_name: Optional[str] = None,
**kwargs,
) -> dict:
"""Create a chart showing sensor data.

:param chart_type: currently only "bar_chart" # todo: where can we properly list the available chart types?
:param event_starts_after: only return beliefs about events that start after this datetime (inclusive)
:param event_ends_before: only return beliefs about events that end before this datetime (inclusive)
:param beliefs_after: only return beliefs formed after this datetime (inclusive)
:param beliefs_before: only return beliefs formed before this datetime (inclusive)
:param source: search only beliefs by this source (pass its name or id) or list of sources
:param include_data: if True, include data in the chart, or if False, exclude data
:param dataset_name: optionally name the dataset used in the chart (the default name is sensor_<id>)
"""

# Set up chart specification
if dataset_name is None:
dataset_name = "sensor_" + str(self.id)
self.sensor_type = (
self.name
) # todo remove this placeholder when sensor types are modelled
chart_specs = chart_type_to_chart_specs(
chart_type,
title=capitalize(self.name),
quantity=capitalize(self.sensor_type),
unit=self.unit,
dataset_name=dataset_name,
**kwargs,
)

if include_data:
# Set up data
data = self.chart_data(
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
event_starts_after=event_starts_after,
event_ends_before=event_ends_before,
beliefs_after=beliefs_after,
beliefs_before=beliefs_before,
source=source,
)
# Combine chart specs and data
chart_specs["datasets"] = {dataset_name: json.loads(data)}
return chart_specs

@property
def timerange(self) -> Dict[str, datetime_type]:
"""Timerange for which sensor data exists.

:returns: dictionary with start and end, for example:
{
'start': datetime.datetime(2020, 12, 3, 14, 0, tzinfo=pytz.utc),
'end': datetime.datetime(2020, 12, 3, 14, 30, tzinfo=pytz.utc)
}
"""
least_recent_query = (
TimedBelief.query.filter(TimedBelief.sensor == self)
.order_by(TimedBelief.event_start.asc())
.limit(1)
)
most_recent_query = (
TimedBelief.query.filter(TimedBelief.sensor == self)
.order_by(TimedBelief.event_start.desc())
.limit(1)
)
results = least_recent_query.union_all(most_recent_query).all()
if not results:
# return now in case there is no data for the sensor
now = server_now()
return dict(start=now, end=now)
least_recent, most_recent = results
return dict(start=least_recent.event_start, end=most_recent.event_end)

def __repr__(self) -> str:
return f"<Sensor {self.id}: {self.name}>"
Expand Down Expand Up @@ -82,30 +174,28 @@ def __init__(
def search(
cls,
sensor: Sensor,
event_time_window: Tuple[Optional[datetime_type], Optional[datetime_type]] = (
None,
None,
),
belief_time_window: Tuple[Optional[datetime_type], Optional[datetime_type]] = (
None,
None,
),
event_starts_after: Optional[datetime_type] = None,
event_ends_before: Optional[datetime_type] = None,
beliefs_after: Optional[datetime_type] = None,
beliefs_before: Optional[datetime_type] = None,
source: Optional[Union[int, List[int], str, List[str]]] = None,
) -> tb.BeliefsDataFrame:
"""Search all beliefs about events for a given sensor.

:param sensor: search only this sensor
:param event_time_window: search only events within this time window
:param belief_time_window: search only beliefs within this time window
:param event_starts_after: only return beliefs about events that start after this datetime (inclusive)
:param event_ends_before: only return beliefs about events that end before this datetime (inclusive)
:param beliefs_after: only return beliefs formed after this datetime (inclusive)
:param beliefs_before: only return beliefs formed before this datetime (inclusive)
:param source: search only beliefs by this source (pass its name or id) or list of sources
"""
return cls.search_session(
session=db.session,
sensor=sensor,
event_before=event_time_window[1],
event_not_before=event_time_window[0],
belief_before=belief_time_window[1],
belief_not_before=belief_time_window[0],
event_starts_after=event_starts_after,
event_ends_before=event_ends_before,
beliefs_after=beliefs_after,
beliefs_before=beliefs_before,
source=source,
)

Expand Down
2 changes: 2 additions & 0 deletions flexmeasures/ui/__init__.py
Expand Up @@ -33,9 +33,11 @@ def register_at(app: Flask):

from flexmeasures.ui.crud.assets import AssetCrudUI
from flexmeasures.ui.crud.users import UserCrudUI
from flexmeasures.ui.views.sensors import SensorUI

AssetCrudUI.register(app)
UserCrudUI.register(app)
SensorUI.register(app)

import flexmeasures.ui.views # noqa: F401 this is necessary to load the views

Expand Down
26 changes: 26 additions & 0 deletions flexmeasures/ui/charts/__init__.py
@@ -0,0 +1,26 @@
from inspect import getmembers, isfunction

from . import belief_charts
from .defaults import apply_chart_defaults


def chart_type_to_chart_specs(chart_type: str, **kwargs) -> dict:
"""Create chart specs of a given chart type, using FlexMeasures defaults for settings like width and height.

:param chart_type: Name of a variable defining chart specs or a function returning chart specs.
The chart specs can be a dictionary or an Altair chart specification.
- In case of a dictionary, the creator needs to ensure that the dictionary contains valid specs
- In case of an Altair chart specification, Altair validates for you
:returns: A dictionary containing a vega-lite chart specification
"""
# Create a dictionary mapping chart types to chart specs, and apply defaults to the chart specs, too.
belief_charts_mapping = {
chart_type: apply_chart_defaults(chart_specs)
for chart_type, chart_specs in getmembers(belief_charts)
if isfunction(chart_specs) or isinstance(chart_specs, dict)
}
# Create chart specs
chart_specs_or_fnc = belief_charts_mapping[chart_type]
if isfunction(chart_specs_or_fnc):
return chart_specs_or_fnc(**kwargs)
return chart_specs_or_fnc
27 changes: 27 additions & 0 deletions flexmeasures/ui/charts/belief_charts.py
@@ -0,0 +1,27 @@
from flexmeasures.ui.charts.defaults import TIME_TITLE, TIME_TOOLTIP_TITLE


def bar_chart(title: str, quantity: str = "unknown quantity", unit: str = "a.u."):
if not unit:
unit = "a.u."
return {
"description": "A simple bar chart.",
"title": title,
"mark": "bar",
"encoding": {
"x": {"field": "event_start", "type": "T", "title": TIME_TITLE},
"y": {
"field": "event_value",
"type": "quantitative",
"title": quantity + " (" + unit + ")",
},
"tooltip": [
{"field": "full_date", "title": TIME_TOOLTIP_TITLE, "type": "nominal"},
{
"field": "event_value",
"title": quantity + " (" + unit + ")",
"type": "quantitative",
},
],
},
}