Skip to content

Commit

Permalink
Merge branch 'main' into documentation/shiftable_load_tutorial
Browse files Browse the repository at this point in the history
  • Loading branch information
victorgarcia98 committed Aug 3, 2023
2 parents 49c3ec5 + f9ae9cf commit 9516402
Show file tree
Hide file tree
Showing 9 changed files with 314 additions and 14 deletions.
1 change: 1 addition & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ v0.15.0 | July XX, 2023
New features
-------------

* Users can select a new chart type (daily heatmap) on the sensor page of the UI, showing how sensor values are distributed over the time of day [see `PR #715 <https://www.github.com/FlexMeasures/flexmeasures/pull/715>`_]
* Allow deleting multiple sensors with a single call to ``flexmeasures delete sensor`` by passing the ``--id`` option multiple times [see `PR #734 <https://www.github.com/FlexMeasures/flexmeasures/pull/734>`_]
* Make it a lot easier to read off the color legend on the asset page, especially when showing many sensors, as they will now be ordered from top to bottom in the same order as they appear in the chart (as defined in the ``sensors_to_show`` attribute), rather than alphabetically [see `PR #742 <https://www.github.com/FlexMeasures/flexmeasures/pull/742>`_]
* Users on FlexMeasures servers in play mode (``FLEXMEASURES_MODE = "play"``) can use the ``sensors_to_show`` attribute to show any sensor on their asset pages, rather than only sensors registered to assets in their own account or to public assets [see `PR #740 <https://www.github.com/FlexMeasures/flexmeasures/pull/740>`_]
Expand Down
7 changes: 5 additions & 2 deletions flexmeasures/api/dev/sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from flexmeasures.data.models.generic_assets import GenericAsset
from flexmeasures.data.models.time_series import Sensor
from flexmeasures.data.services.annotations import prepare_annotations_for_chart
from flexmeasures.ui.utils.view_utils import set_time_range_for_session
from flexmeasures.ui.utils.view_utils import set_session_variables


class SensorAPI(FlaskView):
Expand All @@ -45,6 +45,7 @@ class SensorAPI(FlaskView):
"include_asset_annotations": fields.Boolean(required=False),
"include_account_annotations": fields.Boolean(required=False),
"dataset_name": fields.Str(required=False),
"chart_type": fields.Str(required=False),
"height": fields.Str(required=False),
"width": fields.Str(required=False),
},
Expand All @@ -63,10 +64,12 @@ def get_chart(self, id: int, sensor: Sensor, **kwargs):
- "beliefs_after" (see the `timely-beliefs documentation <https://github.com/SeitaBV/timely-beliefs/blob/main/timely_beliefs/docs/timing.md/#events-and-sensors>`_)
- "beliefs_before" (see the `timely-beliefs documentation <https://github.com/SeitaBV/timely-beliefs/blob/main/timely_beliefs/docs/timing.md/#events-and-sensors>`_)
- "include_data" (if true, chart specs include the data; if false, use the `GET /api/dev/sensor/(id)/chart_data/ <../api/dev.html#get--api-dev-sensor-(id)-chart_data->`_ endpoint to fetch data)
- "chart_type" (currently 'bar_chart' and 'daily_heatmap' are supported types)
- "width" (an integer number of pixels; without it, the chart will be scaled to the full width of the container (hint: use ``<div style="width: 100%;">`` to set a div width to 100%)
- "height" (an integer number of pixels; without it, FlexMeasures sets a default, currently 300)
"""
set_time_range_for_session()
# Store selected time range and chart type as session variables, for a consistent UX across UI page loads
set_session_variables("event_starts_after", "event_ends_before", "chart_type")
return json.dumps(sensor.chart(**kwargs))

@route("/<id>/chart_data/")
Expand Down
5 changes: 3 additions & 2 deletions flexmeasures/api/v3_0/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from flexmeasures.api.common.schemas.generic_assets import AssetIdField
from flexmeasures.api.common.schemas.users import AccountIdField
from flexmeasures.utils.coding_utils import flatten_unique
from flexmeasures.ui.utils.view_utils import set_time_range_for_session
from flexmeasures.ui.utils.view_utils import set_session_variables


asset_schema = AssetSchema()
Expand Down Expand Up @@ -284,7 +284,8 @@ def get_chart(self, id: int, asset: GenericAsset, **kwargs):
.. :quickref: Chart; Download a chart with time series
"""
set_time_range_for_session()
# Store selected time range as session variables, for a consistent UX across UI page loads
set_session_variables("event_starts_after", "event_ends_before")
return json.dumps(asset.chart(**kwargs))

@route("/<id>/chart_data/")
Expand Down
151 changes: 151 additions & 0 deletions flexmeasures/data/models/charts/belief_charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,157 @@ def bar_chart(
return chart_specs


def daily_heatmap(
sensor: "Sensor", # noqa F821
event_starts_after: datetime | None = None,
event_ends_before: datetime | None = None,
**override_chart_specs: dict,
):
unit = sensor.unit if sensor.unit else "a.u."
event_value_field_definition = dict(
title=f"{capitalize(sensor.sensor_type)} ({unit})",
format=[".3~r", unit],
formatType="quantityWithUnitFormat",
stack=None,
**FIELD_DEFINITIONS["event_value"],
scale={"scheme": "blueorange", "domainMid": 0},
)
event_start_field_definition = dict(
field="event_start",
type="temporal",
title=None,
timeUnit={
"unit": "hoursminutesseconds",
"step": sensor.event_resolution.total_seconds(),
},
axis={
"labelExpr": "timeFormat(datum.value, '%H:%M')",
"labelFlush": False,
"labelOverlap": True,
"labelSeparation": 1,
},
scale={
"domain": [
{"hours": 0},
{"hours": 24},
]
},
)
event_start_date_field_definition = dict(
field="event_start",
type="temporal",
title=None,
timeUnit={
"unit": "yearmonthdate",
},
axis={
"tickCount": "day",
# Center align the date labels
"labelOffset": {
"expr": "(scale('y', 24 * 60 * 60 * 1000) - scale('y', 0)) / 2"
},
"labelFlush": False,
"labelBound": True,
},
)
if event_starts_after and event_ends_before:
event_start_date_field_definition["scale"] = {
"domain": [
event_starts_after.timestamp() * 10**3,
event_ends_before.timestamp() * 10**3,
],
}
chart_specs = {
"description": "A daily heatmap showing sensor data.",
# the sensor type is already shown as the y-axis title (avoid redundant info)
"title": capitalize(sensor.name) if sensor.name != sensor.sensor_type else None,
"layer": [
{
"mark": {
"type": "rect",
"clip": True,
},
"encoding": {
"x": event_start_field_definition,
"y": event_start_date_field_definition,
"color": event_value_field_definition,
"detail": FIELD_DEFINITIONS["source"],
"opacity": {"value": 0.7},
"tooltip": [
FIELD_DEFINITIONS["full_date"],
{
**event_value_field_definition,
**dict(title=f"{capitalize(sensor.sensor_type)}"),
},
FIELD_DEFINITIONS["source_name_and_id"],
FIELD_DEFINITIONS["source_model"],
],
},
"transform": [
{
"calculate": "datum.source.name + ' (ID: ' + datum.source.id + ')'",
"as": "source_name_and_id",
},
# In case of multiple sources, show the one with the most visible data
{
"joinaggregate": [{"op": "count", "as": "source_count"}],
"groupby": ["source.id"],
},
{
"window": [
{"op": "rank", "field": "source_count", "as": "source_rank"}
],
"sort": [{"field": "source_count", "order": "descending"}],
"frame": [None, None],
},
{"filter": "datum.source_rank == 1"},
# In case of a tied rank, arbitrarily choose the first one occurring in the data
{
"window": [
{
"op": "first_value",
"field": "source.id",
"as": "first_source_id",
}
],
},
{"filter": "datum.source.id == datum.first_source_id"},
],
},
{
"data": {"name": "replay"},
"mark": {
"type": "rule",
},
"encoding": {
"x": {
"field": "belief_time",
"type": "temporal",
"timeUnit": "hoursminutesseconds",
},
"y": {
"field": "belief_time",
"type": "temporal",
"timeUnit": "yearmonthdate",
},
"yOffset": {
"value": {
"expr": "(scale('y', 24 * 60 * 60 * 1000) - scale('y', 0))"
}
},
},
},
],
}
for k, v in override_chart_specs.items():
chart_specs[k] = v
chart_specs["config"] = {
"legend": {"orient": "right"},
# "legend": {"direction": "horizontal"},
}
return chart_specs


def chart_for_multiple_sensors(
sensors_to_show: list["Sensor", list["Sensor"]], # noqa F821
event_starts_after: datetime | None = None,
Expand Down
26 changes: 18 additions & 8 deletions flexmeasures/ui/static/css/flexmeasures.css
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ p.error {
font-weight: 700;
}

#tzwarn {
#tzwarn,#sourcewarn {
margin-top: 20px;
margin-bottom: 20px;
color: var(--secondary-color);
Expand Down Expand Up @@ -238,7 +238,7 @@ p.error {
border: none;
}

.navbar-default .dropdown-menu {
.dropdown-menu {
border: none !important;
}

Expand Down Expand Up @@ -304,7 +304,7 @@ p.error {
padding-bottom: 20px;
}

.navbar-default .dropdown-menu a {
.dropdown-menu a {
transition: .3s;
-webkit-transition: .3s;
-moz-transition: .3s;
Expand All @@ -315,7 +315,7 @@ p.error {
}

@media (max-width: 767px) {
.navbar-default .navbar-nav .open .dropdown-menu>li>a {
.dropdown-menu>li>a {
color: var(--nav-default-color);
transition: .4s;
-webkit-transition: .4s;
Expand All @@ -325,13 +325,23 @@ p.error {
}
}

.navbar-default .navbar-nav .open .dropdown-menu,
.navbar-default .navbar-nav .open .dropdown-menu>li>a {
.dropdown-menu.center-aligned {
right: auto;
left: 50%;
transform: translateX(-50%);
}

.dropdown-menu,
.dropdown-menu>li>a:not(.active) {
color: var(--nav-default-color);
background-color: var(--nav-default-background-color);
}
.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus,
.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover {
.dropdown-menu>li>a.active {
color: var(--nav-hover-color);
background-color: var(--nav-hover-background-color);
}
.dropdown-menu>li>a:focus,
.dropdown-menu>li>a:hover {
color: var(--nav-hover-color);
background-color: var(--nav-hover-background-color);
}
Expand Down
63 changes: 63 additions & 0 deletions flexmeasures/ui/static/js/data-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Data source utils

/**
* Extracts unique values of a specified nested property from an array of JSON objects.
*
* @param {Array} data - An array of JSON objects from which to extract unique values.
* @param {string} key - The dot-separated key representing the nested property (e.g., 'source.id').
* @returns {Array} An array containing the unique values of the specified nested property.
*
* @example
* const data = [
* {"id": 1, "name": "foo", "source": {"id": 4, "name": "bar"}},
* {"id": 2, "name": "baz", "source": {"id": 4, "name": "qux"}},
* {"id": 3, "name": "quux", "source": {"id": 5, "name": "corge"}}
* ];
*
* const key = 'source.id';
* const uniqueSourceIds = getUniqueValues(data, key);
* console.log(uniqueSourceIds); // Output: [4, 5]
*/
export function getUniqueValues(data, key) {
var lookup = {};
var items = data;
var results = [];

for (var item, i = 0; item = items[i++];) {
var val = getValueByNestedKey(item, key);

if (!(val in lookup)) {
lookup[val] = 1;
results.push(val);
}
}
return results;
}

/**
* Retrieves the value of a nested property in an object using a dot-separated key.
*
* @param {Object} obj - The input JavaScript object from which to retrieve the nested value.
* @param {string} key - The dot-separated key representing the nested property (e.g., 'source.id').
* @returns {*} The value of the nested property if found, otherwise, returns undefined.
*
* @example
* const jsonString = '{"id":11,"name":"ajax","subject":"OR","mark":63,"source":{"id":4,"name":"foo"}}';
* const jsonObject = JSON.parse(jsonString);
*
* const key = 'source.id';
* const sourceId = getValueByNestedKey(jsonObject, key);
* console.log(sourceId); // Output: 4
*/
function getValueByNestedKey(obj, key) {
const keys = key.split('.');
let value = obj;
for (const k of keys) {
if (value[k] === undefined) {
return undefined; // Property not found
}
value = value[k];
}
return value;
}

Loading

0 comments on commit 9516402

Please sign in to comment.