diff --git a/documentation/changelog.rst b/documentation/changelog.rst index cea9a91b9..b0fc1f0c7 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -14,6 +14,7 @@ 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 `_] +* Users are warned in the UI on when the data they are seeing includes one or more Daylight Saving Time (DST) transitions, and heatmaps (see previous feature) visualize these transitions intuitively [see `PR #723 `_] * Allow deleting multiple sensors with a single call to ``flexmeasures delete sensor`` by passing the ``--id`` option multiple times [see `PR #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 `_] * 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 `_] diff --git a/flexmeasures/data/models/charts/belief_charts.py b/flexmeasures/data/models/charts/belief_charts.py index 195a4d628..c93f2e415 100644 --- a/flexmeasures/data/models/charts/belief_charts.py +++ b/flexmeasures/data/models/charts/belief_charts.py @@ -100,7 +100,7 @@ def daily_heatmap( formatType="quantityWithUnitFormat", stack=None, **FIELD_DEFINITIONS["event_value"], - scale={"scheme": "blueorange", "domainMid": 0}, + scale={"scheme": "blueorange", "domainMid": 0, "domain": {"unionWith": [0]}}, ) event_start_field_definition = dict( field="event_start", @@ -147,33 +147,35 @@ def daily_heatmap( 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 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, - }, + "mark": mark, "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"], - ], + "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", @@ -227,6 +229,13 @@ def daily_heatmap( }, }, }, + 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(): @@ -238,6 +247,60 @@ def daily_heatmap( 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, diff --git a/flexmeasures/ui/static/css/flexmeasures.css b/flexmeasures/ui/static/css/flexmeasures.css index 517fe3f57..348317962 100644 --- a/flexmeasures/ui/static/css/flexmeasures.css +++ b/flexmeasures/ui/static/css/flexmeasures.css @@ -189,7 +189,7 @@ p.error { font-weight: 700; } -#tzwarn,#sourcewarn { +#tzwarn,#dstwarn,#sourcewarn { margin-top: 20px; margin-bottom: 20px; color: var(--secondary-color); diff --git a/flexmeasures/ui/static/js/daterange-utils.js b/flexmeasures/ui/static/js/daterange-utils.js index ebf9fde97..1f43467fb 100644 --- a/flexmeasures/ui/static/js/daterange-utils.js +++ b/flexmeasures/ui/static/js/daterange-utils.js @@ -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; -} \ No newline at end of file +} + +/** + * 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. + */ +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; +} diff --git a/flexmeasures/ui/static/js/flexmeasures.js b/flexmeasures/ui/static/js/flexmeasures.js index 24a7ba4b6..73374c964 100644 --- a/flexmeasures/ui/static/js/flexmeasures.js +++ b/flexmeasures/ui/static/js/flexmeasures.js @@ -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': [, ], - * 'formatType': 'quantityWithUnitFormat' - * } - * - Timedeltas measured in human-readable quantities (usually not milliseconds) - * Usage: - * { - * 'format': [, ], - * 'formatType': 'timedeltaFormat' - * } - * is a d3 format identifier, e.g. 'd' for decimal notation, rounded to integer. - * See https://github.com/d3/d3-format for more details. - * 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': [, ], + * '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': [, ], + * 'formatType': 'timedeltaFormat' + * } + * is a d3 format identifier, e.g. 'd' for decimal notation, rounded to integer. + * See https://github.com/d3/d3-format for more details. + * 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" @@ -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': [], + * '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 + ')'; +}); diff --git a/flexmeasures/ui/templates/base.html b/flexmeasures/ui/templates/base.html index f19237016..9a5405e16 100644 --- a/flexmeasures/ui/templates/base.html +++ b/flexmeasures/ui/templates/base.html @@ -234,7 +234,7 @@