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 26 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
26 changes: 26 additions & 0 deletions flexmeasures/data/models/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/data/models/charts/belief_charts.py
@@ -0,0 +1,27 @@
from flexmeasures.data.models.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",
},
],
},
}
42 changes: 42 additions & 0 deletions flexmeasures/data/models/charts/defaults.py
@@ -0,0 +1,42 @@
from functools import wraps
from typing import Callable, Union

import altair as alt


HEIGHT = 300
WIDTH = 600
REDUCED_HEIGHT = REDUCED_WIDTH = 60
SELECTOR_COLOR = "darkred"
TIME_FORMAT = "%I:%M %p on %A %b %e, %Y"
TIME_TOOLTIP_TITLE = "Time and date"
TIME_TITLE = None
TIME_SELECTION_TOOLTIP = "Click and drag to select a time window"


def apply_chart_defaults(fn):
@wraps(fn)
def decorated_chart_specs(*args, **kwargs):
dataset_name = kwargs.pop("dataset_name", None)
if isinstance(fn, Callable):
# function that returns a chart specification
chart_specs: Union[dict, alt.TopLevelMixin] = fn(*args, **kwargs)
else:
# not a function, but a direct chart specification
chart_specs: Union[dict, alt.TopLevelMixin] = fn
if isinstance(chart_specs, alt.TopLevelMixin):
chart_specs = chart_specs.to_dict()
chart_specs.pop("$schema")
if dataset_name:
chart_specs["data"] = {"name": dataset_name}
chart_specs["height"] = HEIGHT
chart_specs["width"] = WIDTH
chart_specs["transform"] = [
{
"as": "full_date",
"calculate": f"timeFormat(datum.event_start, '{TIME_FORMAT}')",
}
]
return chart_specs

return decorated_chart_specs
7 changes: 7 additions & 0 deletions flexmeasures/data/models/charts/readme.md
@@ -0,0 +1,7 @@
# Developer docs for adding chart specs

Chart specs can be specified as a dictionary with a vega-lite specification or as an altair chart.
Alternatively, they can be specified as a function that returns a dict (with vega-lite specs) or an altair chart.
This approach is useful if you need to parameterize the specification with kwargs.

Todo: support a plug-in architecture, see https://packaging.python.org/guides/creating-and-discovering-plugins/