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

feat: show split data during fall DST transition in daily heatmap #723

Merged
merged 41 commits into from Aug 3, 2023
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
5b6e0e3
Introduce new sensor chart type
Flix6x Jun 6, 2023
2239ff7
Fix alignment of y-axis labels
Flix6x Jun 7, 2023
bfa52ab
Add button to switch between sensor chart types
Flix6x Jun 7, 2023
0a85983
Streamline button title with others
Flix6x Jun 7, 2023
4098e76
Improve alignment of first and last time-of-day labels
Flix6x Jun 7, 2023
2c98c9d
Cleaner solution to prevent showing next date outside selected daterange
Flix6x Jun 7, 2023
04d0e28
refactor: field definitions
Flix6x Jun 7, 2023
402ae9e
remove obsolete line
Flix6x Jun 7, 2023
ac7430c
fix: replay ruler for new chart type
Flix6x Jun 7, 2023
16a1a79
black
Flix6x Jun 7, 2023
dfb2a4a
fix: separate overlapping data during the fall DST transition
Flix6x Jun 13, 2023
efed90e
refactor: reuse code
Flix6x Jun 13, 2023
cef688e
fix: tooltip
Flix6x Jun 13, 2023
fc049a1
fix: mind the gap
Flix6x Jun 13, 2023
9571b64
add: warn users when viewing sensor data that includes a DST transition
Flix6x Jul 19, 2023
abb0998
add: also warn users when viewing asset data that includes a DST tran…
Flix6x Jul 19, 2023
d6df4a6
add: UI styling
Flix6x Jul 19, 2023
dc8f749
add: add timezone to tooltip during fall DST transition
Flix6x Jul 20, 2023
bf29897
style: black
Flix6x Jul 20, 2023
dc36a23
Merge branch 'main' into sensor-matrix-chart-fall-dst-transition
Flix6x Jul 25, 2023
4f45b23
Merge branch 'main' into sensor-matrix-chart
Flix6x Jul 26, 2023
b2a71a1
feat: remember selected chart type within session
Flix6x Jul 26, 2023
2d09c7c
refactor: move towards a single function to set session variables fro…
Flix6x Jul 26, 2023
01759da
refactor: move template variables
Flix6x Jul 27, 2023
129b5d9
only store non-None session variables
Flix6x Jul 27, 2023
90808fa
docs: changelog entry
Flix6x Jul 27, 2023
0ebb4af
flake8
Flix6x Jul 27, 2023
9b92b6e
feat: top y-coordinate of ruler moves with belief time, too
Flix6x Jul 28, 2023
8222cf8
rename matrix chart to heatmap
Flix6x Jul 28, 2023
ef4563e
feat: heatmap shows only data from a single source
Flix6x Jul 31, 2023
57d6bea
feat: selected source is the one with the most visible data
Flix6x Jul 31, 2023
c0197cd
Merge remote-tracking branch 'origin/main' into sensor-matrix-chart
Flix6x Jul 31, 2023
7d13ee3
docs: add name of new chart type to changelog entry
Flix6x Jul 31, 2023
df0e923
Merge branch 'sensor-matrix-chart' into sensor-matrix-chart-fall-dst-…
Flix6x Jul 31, 2023
3d23cd9
fix: workaround for https://github.com/vega/vega-lite/issues/6544
Flix6x Aug 1, 2023
ccb39ca
docs: changelog entry
Flix6x Aug 1, 2023
12bac09
fix: in case of a tied rank, where multiple sources have exactly the …
Flix6x Aug 1, 2023
60d8146
feature: show a warning message whenever a heatmap is shown that mask…
Flix6x Aug 1, 2023
84d9b10
Merge remote-tracking branch 'origin/main' into sensor-matrix-chart
Flix6x Aug 1, 2023
1a3017e
Merge branch 'sensor-matrix-chart' into sensor-matrix-chart-fall-dst-…
Flix6x Aug 1, 2023
8d5f58b
Merge remote-tracking branch 'origin/main' into sensor-matrix-chart-f…
Flix6x Aug 3, 2023
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
1 change: 1 addition & 0 deletions flexmeasures/api/dev/sensors.py
Expand Up @@ -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 Down
180 changes: 180 additions & 0 deletions flexmeasures/data/models/charts/belief_charts.py
Expand Up @@ -83,6 +83,186 @@ def bar_chart(
return chart_specs


def matrix_chart(
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,
],
}
mark = {"type": "rect", "clip": True, "opacity": 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"],
]
chart_specs = {
"description": "A simple heatmap chart 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": mark,
"encoding": {
"x": event_start_field_definition,
"y": event_start_date_field_definition,
"color": event_value_field_definition,
"detail": FIELD_DEFINITIONS["source"],
"tooltip": tooltip,
},
"transform": [
{
# Mask overlapping data during the fall DST transition, which we show later with a special layer
"filter": "timezoneoffset(datum.event_start) >= timezoneoffset(datum.event_start + 60 * 60 * 1000) && timezoneoffset(datum.event_start) <= timezoneoffset(datum.event_start - 60 * 60 * 1000)"
},
{
"calculate": "datum.source.name + ' (ID: ' + datum.source.id + ')'",
"as": "source_name_and_id",
},
],
},
{
"data": {"name": "replay"},
"mark": {
"type": "rule",
},
"encoding": {
"x": {
"field": "belief_time",
"type": "temporal",
"timeUnit": "hoursminutesseconds",
},
},
},
create_fall_dst_transition_layer(
sensor.timezone,
mark,
event_value_field_definition,
event_start_field_definition,
tooltip,
),
],
}
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 create_fall_dst_transition_layer(
timezone, mark, event_value_field_definition, event_start_field_definition, tooltip
) -> dict:
"""Special layer for showing data during the daylight savings time transition in fall."""
return {
"mark": mark,
"encoding": {
"x": event_start_field_definition,
"y": {
"field": "dst_transition_event_start",
"type": "temporal",
"title": None,
"timeUnit": {"unit": "yearmonthdatehours", "step": 12},
},
"y2": {
"field": "dst_transition_event_start_next",
"timeUnit": {"unit": "yearmonthdatehours", "step": 12},
},
"color": event_value_field_definition,
"detail": FIELD_DEFINITIONS["source"],
"tooltip": [
{
"field": "event_start",
"type": "temporal",
"title": "Timezone",
"timeUnit": "utc",
"format": [timezone],
"formatType": "timezoneFormat",
},
*tooltip,
],
},
"transform": [
{
"filter": "timezoneoffset(datum.event_start) < timezoneoffset(datum.event_start + 60 * 60 * 1000) || timezoneoffset(datum.event_start) > timezoneoffset(datum.event_start - 60 * 60 * 1000)"
},
{
# Push the more recent hour into the second 12-hour bin
"calculate": "timezoneoffset(datum.event_start + 60 * 60 * 1000) > timezoneoffset(datum.event_start) ? datum.event_start : datum.event_start + 12 * 60 * 60 * 1000",
"as": "dst_transition_event_start",
},
{
# Calculate a time point in the next 12-hour bin
"calculate": "datum.dst_transition_event_start + 12 * 60 * 60 * 1000 - 60 * 60 * 1000",
"as": "dst_transition_event_start_next",
},
{
"calculate": "datum.source.name + ' (ID: ' + datum.source.id + ')'",
"as": "source_name_and_id",
},
],
}


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
Expand Up @@ -189,7 +189,7 @@ p.error {
font-weight: 700;
}

#tzwarn {
#tzwarn,#dstwarn {
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
31 changes: 30 additions & 1 deletion flexmeasures/ui/static/js/daterange-utils.js
Expand Up @@ -38,4 +38,33 @@ function getTimeZoneOffset(date, timeZone) {
// Positive values are West of GMT, opposite of ISO 8601
// this matches the output of `Date.getTimeZoneOffset`
return -(lie - date) / 60 / 1000;
}
}

/**
* Count the number of Daylight Saving Time (DST) transitions within a given datetime range.
* @param {Date} startDate - The start date of the datetime range.
* @param {Date} endDate - The end date of the datetime range.
* @param {number} increment - The number of days to increment between iterations.
* @returns {number} The count of DST transitions within the specified range.
*/
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
export function countDSTTransitions(startDate, endDate, increment) {
let transitions = 0;
let currentDate = new Date(startDate);
let nextDate = new Date(startDate);

while (currentDate <= endDate) {
const currentOffset = currentDate.getTimezoneOffset();
nextDate.setDate(currentDate.getDate() + increment);
if (nextDate > endDate) {
nextDate = endDate;
}
const nextOffset = nextDate.getTimezoneOffset();

if (currentOffset !== nextOffset) {
transitions++;
}
currentDate.setDate(currentDate.getDate() + increment);
}

return transitions;
}
54 changes: 36 additions & 18 deletions flexmeasures/ui/static/js/flexmeasures.js
Expand Up @@ -311,28 +311,31 @@ function submit_sensor_type() {
$("#sensor_type-form").attr("action", empty_location).submit();
}

/** Tooltips: Register custom formatters
*
* - Quantities incl. units
* Usage:
* {
* 'format': [<d3-format>, <sensor unit>],
* 'formatType': 'quantityWithUnitFormat'
* }
* - Timedeltas measured in human-readable quantities (usually not milliseconds)
* Usage:
* {
* 'format': [<d3-format>, <breakpoint>],
* 'formatType': 'timedeltaFormat'
* }
* <d3-format> is a d3 format identifier, e.g. 'd' for decimal notation, rounded to integer.
* See https://github.com/d3/d3-format for more details.
* <breakpoint> is a scalar that decides the breakpoint from one duration unit to the next larger unit.
* For example, a breakpoint of 4 means we format 4 days as '4 days', but 3.96 days as '95 hours'.
/** Tooltips: Register custom formatters */

/* Quantities incl. units
* Usage:
* {
* 'format': [<d3-format>, <sensor unit>],
* 'formatType': 'quantityWithUnitFormat'
* }
*/
vega.expressionFunction('quantityWithUnitFormat', function(datum, params) {
return d3.format(params[0])(datum) + " " + params[1];
});

/*
* Timedeltas measured in human-readable quantities (usually not milliseconds)
* Usage:
* {
* 'format': [<d3-format>, <breakpoint>],
* 'formatType': 'timedeltaFormat'
* }
* <d3-format> is a d3 format identifier, e.g. 'd' for decimal notation, rounded to integer.
* See https://github.com/d3/d3-format for more details.
* <breakpoint> is a scalar that decides the breakpoint from one duration unit to the next larger unit.
* For example, a breakpoint of 4 means we format 4 days as '4 days', but 3.96 days as '95 hours'.
*/
vega.expressionFunction('timedeltaFormat', function(timedelta, params) {
return (Math.abs(timedelta) > 1000 * 60 * 60 * 24 * 365.2425 * params[1] ? d3.format(params[0])(timedelta / (1000 * 60 * 60 * 24 * 365.2425)) + " years"
: Math.abs(timedelta) > 1000 * 60 * 60 * 24 * params[1] ? d3.format(params[0])(timedelta / (1000 * 60 * 60 * 24)) + " days"
Expand All @@ -341,3 +344,18 @@ vega.expressionFunction('timedeltaFormat', function(timedelta, params) {
: Math.abs(timedelta) > 1000 * params[1] ? d3.format(params[0])(timedelta / 1000) + " seconds"
: d3.format(params[0])(timedelta) + " milliseconds");
});

/*
* Timezone offset including IANA timezone name
* Usage:
* {
* 'format': [<IANA timezone name, e.g. 'Europe/Amsterdam'>],
* 'formatType': 'timezoneFormat'
* }
*/
vega.expressionFunction('timezoneFormat', function(date, params) {
const timezoneString = params[0];
const tzOffsetNumber = date.getTimezoneOffset();
const tzDate = new Date(0,0,0,0,Math.abs(tzOffsetNumber));
return `${ tzOffsetNumber > 0 ? '-' : '+'}${("" + tzDate.getHours()).padStart(2, '0')}:${("" + tzDate.getMinutes()).padStart(2, '0')}` + ' (' + timezoneString + ')';
});