Skip to content

data_report

measurement.dash_apps.data_report ¤

Attributes¤

MAX_POINTS = 1000 module-attribute ¤

Maximum number of points to display in the graph.

app = DjangoDash('DataReport', external_stylesheets=[dbc.themes.BOOTSTRAP, '/static/styles/dashstyle.css']) module-attribute ¤

buttons_div = html.Div(children=[dbc.Button('Download CSV', color='primary', className='me-1', id='csv_button'), dbc.Button('Display data', color='success', className='me-1', id='display_button', style={'margin-left': '10px'}, n_clicks=0)], id='buttons_div', hidden=False, style={'margin-top': '30px'}) module-attribute ¤

filters = html.Div(style={'width': '35%', 'height': '100%', 'padding': '10px'}, children=[_traces_selection('primary'), html.Label('Date Range:', style={'font-weight': 'bold'}), dcc.DatePickerRange(id='date_range_picker', display_format='YYYY-MM-DD', start_date=None, end_date=None), secondary_trace, buttons_div]) module-attribute ¤

secondary_trace = html.Div(style={'margin-top': '10px', 'margin-left': '10px'}, children=[dbc.Checklist(options=[{'label': 'Add secondary trace', 'value': 1}], value=[], id='switch-show-secondary', switch=True), html.Div(_traces_selection('secondary'), id='secondary_traces_div', hidden=True)]) module-attribute ¤

Classes¤

Variable ¤

Bases: PermissionsBase

A variable with a physical meaning.

Such as precipitation, wind speed, wind direction, soil moisture, including the associated unit. It also includes metadata to help identify what is a reasonable value for the data, to flag outliers and to help with the validation process.

The nature of the variable can be one of the following:

  • sum: Cumulative value over a period of time.
  • average: Average value over a period of time.
  • value: One-off value.

Attributes:

Name Type Description
variable_id AutoField

Primary key.

variable_code CharField

Code of the variable, eg. airtemperature.

name CharField

Human-readable name of the variable, eg. Air temperature.

unit ForeignKey

Unit of the variable.

maximum DecimalField

Maximum value allowed for the variable.

minimum DecimalField

Minimum value allowed for the variable.

diff_error DecimalField

If two sequential values in the time-series data of this variable differ by more than this value, the validation process can mark this with an error flag.

outlier_limit DecimalField

The statistical deviation for defining outliers, in times the standard deviation (sigma).

null_limit DecimalField

The max % of null values (missing, caused by e.g. equipment malfunction) allowed for hourly, daily, monthly data. Cumulative values are not deemed trustworthy if the number of missing values in a given period is greater than the null_limit.

nature CharField

Nature of the variable, eg. if it represents a one-off value, the average over a period of time or the cumulative value over a period

Attributes¤
is_cumulative property ¤

Return True if the nature of the variable is sum.

Functions¤
__str__() ¤

Return the string representation of the object.

Source code in variable/models.py
165
166
167
def __str__(self) -> str:
    """Return the string representation of the object."""
    return str(self.name)
clean() ¤

Validate the model fields.

Source code in variable/models.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
def clean(self) -> None:
    """Validate the model fields."""
    if self.maximum < self.minimum:
        raise ValidationError(
            {"maximum": "The maximum value must be greater than the minimum value."}
        )
    if not self.variable_code.isidentifier():
        raise ValidationError(
            {
                "variable_code": "The variable code must be a valid Python "
                "identifier. Only letters, numbers and underscores are allowed, and"
                " it cannot start with a number."
            }
        )
    return super().clean()
get_absolute_url() ¤

Get the absolute URL of the object.

Source code in variable/models.py
169
170
171
def get_absolute_url(self) -> str:
    """Get the absolute URL of the object."""
    return reverse("variable:variable_detail", kwargs={"pk": self.pk})

Functions¤

_traces_selection(block_id) ¤

Build the traces selection widgets.

Parameters:

Name Type Description Default
block_id str

Prefix used to build the component id.

required

Returns:

Type Description

html.Div: Scrollable container holding the traces selection widgets.

Source code in measurement/dash_apps/data_report.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def _traces_selection(block_id):
    """Build the traces selection widgets.

    Args:
        block_id (str): Prefix used to build the component id.

    Returns:
        html.Div: Scrollable container holding the traces selection widgets.
    """
    return html.Div(
        children=[
            html.Label("Temporality:", style={"font-weight": "bold"}),
            dcc.Dropdown(
                id=f"{block_id}_temporality_drop",
                options=[
                    {"label": "Raw measurement", "value": "measurement"},
                    {
                        "label": "Validated measurement",
                        "value": "validated",
                    },
                    {"label": "Hourly", "value": "hourly"},
                    {"label": "Daily", "value": "daily"},
                    {"label": "Monthly", "value": "monthly"},
                ],
                value="measurement",
            ),
            html.Label("Station:", style={"font-weight": "bold"}),
            dcc.Dropdown(
                id=f"{block_id}_station_drop",
                options=[],
                value=None,
            ),
            html.Label("Variable:", style={"font-weight": "bold"}),
            dcc.Dropdown(
                id=f"{block_id}_variable_drop",
                options=[],
                value=None,
            ),
        ],
    )

add_nans_for_gaps(data) ¤

Add NaN values to create gaps in the plot when there are missing points.

Using values for maximum and minimum results in a shaded area indicating the gap which is more visually intuitive than just breaking the line.

We use 1.5 times the median time difference as a threshold to detect gaps.

Parameters:

Name Type Description Default
data DataFrame

The data to process, must contain 'time', 'value', 'maximum' and 'minimum' columns.

required

Returns:

Type Description
DataFrame

pd.DataFrame: Data with NaN values added for gaps

Source code in measurement/dash_apps/plots.py
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
def add_nans_for_gaps(data: pd.DataFrame) -> pd.DataFrame:
    """Add NaN values to create gaps in the plot when there are missing points.

    Using values for maximum and minimum results in a shaded area indicating the gap
    which is more visually intuitive than just breaking the line.

    We use 1.5 times the median time difference as a threshold to detect gaps.

    Args:
        data: The data to process, must contain 'time', 'value', 'maximum' and 'minimum'
            columns.

    Returns:
        pd.DataFrame: Data with NaN values added for gaps
    """
    data = data.sort_values("time").reset_index(drop=True)
    data["time_diff"] = data["time"].diff()
    median_diff = data["time_diff"].median()
    gap_threshold = median_diff * 1.5
    gap_indices = data.index[data["time_diff"] > gap_threshold].tolist()
    data = data.drop(columns=["time_diff"])

    nan_rows: list[dict[str, float | datetime]] = []
    for idx in reversed(gap_indices):
        nan_rows.append(
            {
                "time": data.loc[idx, "time"] - median_diff / 2,
                "value": float("nan"),
                "maximum": data.loc[idx, "maximum"],
                "minimum": data.loc[idx, "minimum"],
            }
        )
    data = (
        pd.concat([data, pd.DataFrame(nan_rows)], ignore_index=True)
        .sort_values("time")
        .reset_index(drop=True)
    )

    return data

create_empty_plot() ¤

Creates empty plot

Returns:

Type Description
scatter

px.Scatter: Plot

Source code in measurement/dash_apps/plots.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def create_empty_plot() -> px.scatter:
    """Creates empty plot

    Returns:
        px.Scatter: Plot
    """
    fig = px.scatter(title="No data to plot")
    fig.update_layout(
        autosize=True,
        margin=dict(
            l=50,
            r=20,
            b=0,
            t=50,
        ),
        title_font=dict(
            size=14,
        ),
    )
    return fig

create_report_plot(data, variable_name, station_code, agg='', add_secondary_axis=False, fig=None) ¤

Creates plot for Report app

Parameters:

Name Type Description Default
data DataFrame

Data

required
variable_name str

Variable name

required
station_code str

Station code

required
agg str

Aggregation level. Defaults to "".

''
add_secondary_axis bool

Whether to use secondary y-axis. Defaults to False.

False
fig Figure | None

Existing figure to add traces to. Defaults to None.

None

Returns: go.Figure: Plot

Source code in measurement/dash_apps/plots.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
def create_report_plot(
    data: pd.DataFrame,
    variable_name: str,
    station_code: str,
    agg: str = "",
    add_secondary_axis: bool = False,
    fig: go.Figure | None = None,
) -> go.Figure:
    """Creates plot for Report app

    Args:
        data (pd.DataFrame): Data
        variable_name (str): Variable name
        station_code (str): Station code
        agg (str, optional): Aggregation level. Defaults to "".
        add_secondary_axis (bool, optional): Whether to use secondary y-axis.
            Defaults to False.
        fig (go.Figure | None, optional): Existing figure to add traces to.
            Defaults to None.
    Returns:
        go.Figure: Plot
    """
    secondary_data = fig is not None
    if not fig:
        fig = make_subplots(specs=[[{"secondary_y": add_secondary_axis}]])

    variable_name = (
        variable_name if not secondary_data else f"{variable_name} (secondary)"
    )
    fig.add_traces(
        [
            # Main trace
            go.Scatter(
                name=variable_name,
                x=data["time"],
                y=data["value"],
                mode="lines",
            ),
            # Maximum and minimum as filled area
            go.Scatter(
                name="Maximum",
                x=data["time"],
                y=data["maximum"],
                mode="lines",
                marker=dict(color="#444"),
                line=dict(width=0),
                showlegend=False,
            ),
            go.Scatter(
                name="Minimum",
                x=data["time"],
                y=data["minimum"],
                marker=dict(color="#444"),
                line=dict(width=0),
                mode="lines",
                fillcolor="rgba(68, 68, 68, 0.3)",
                fill="tonexty",
                showlegend=False,
            ),
        ],
        secondary_ys=[secondary_data, secondary_data, secondary_data],
    )

    if secondary_data:
        title = fig["layout"]["title"]["text"]
        title = f"{title}<br>{station_code} - {variable_name}" + agg
    else:
        title = f"{station_code} - {variable_name}" + agg

    fig.update_traces(connectgaps=False)
    fig.update_layout(
        legend=dict(
            title=dict(text="", font=dict(size=12)),
            xanchor="left",
            yanchor="top",
            y=0.99,
            x=0.01,
        ),
        autosize=True,
        margin=dict(l=00, r=00, b=0, t=50),
        title_font=dict(size=14),
        title=title,
    )
    fig.update_yaxes(title_text=variable_name, secondary_y=secondary_data)

    return fig

download_csv_report(n_clicks, temporality, station, variable, start_time, end_time) ¤

Source code in measurement/dash_apps/data_report.py
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
@app.callback(
    [
        Output("download_csv", "data"),
        Output("csv_alert_div", "children"),
        Output("csv_alert_div", "hidden"),
    ],
    Input("csv_button", "n_clicks"),
    [
        State("primary_temporality_drop", "value"),
        State("primary_station_drop", "value"),
        State("primary_variable_drop", "value"),
        State("date_range_picker", "start_date"),
        State("date_range_picker", "end_date"),
    ],
    prevent_initial_call=True,
)
def download_csv_report(
    n_clicks: int,
    temporality: str,
    station: str,
    variable: str,
    start_time: str,
    end_time: str,
):
    if n_clicks and n_clicks > 0:
        try:
            file = (
                get_report_data_from_db(
                    station=station,
                    variable=variable,
                    start_time=start_time,
                    end_time=end_time,
                    report_type=temporality,
                    whole_months=False,
                )
                .drop(columns=["station", "variable", "data_import_id"])
                .dropna(axis=1, how="all")
                .dropna(axis=0)
                .to_csv(index=False)
            )
            return (
                dict(
                    content=file,
                    filename=f"{station}_{variable}_{temporality}_{start_time}-{end_time}.csv",
                ),
                [],
                True,
            )
        except Exception as e:
            alert = dbc.Alert(f"Could not export data to CSV: {e}", color="warning")
            return None, [alert], False

    return None, [], True

get_aggregation_level(timeseries, aggregate=False) ¤

Calculates the aggregation level based on the timeseries separation.

Parameters:

Name Type Description Default
timeseries Series

Time data to be aggregated.

required
aggregate bool

Flag indicating if there should be aggregation

False
Return

String indicating the aggregation level as " - LEVEL UNITS aggregation" or an empty string if no aggregation is required.

Source code in measurement/dash_apps/plots.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def get_aggregation_level(timeseries: pd.Series, aggregate: bool = False) -> str:
    """Calculates the aggregation level based on the timeseries separation.

    Args:
        timeseries: Time data to be aggregated.
        aggregate: Flag indicating if there should be aggregation

    Return:
        String indicating the aggregation level as " - LEVEL UNITS aggregation" or an
        empty string if no aggregation is required.
    """
    if not aggregate:
        return ""

    aggregation = timeseries.diff().dt.seconds.median() / 60
    unit = "minutes"
    if aggregation > 60:
        aggregation = aggregation / 60
        unit = "hours"
        if aggregation > 24:
            aggregation = aggregation / 24
            unit = "days"
    return f" - {aggregation:.1f} {unit} aggregation"

get_date_range(station, variable) ¤

Get the date range covered by a chosen station and variable.

Parameters:

Name Type Description Default
station str

Code for the chosen station

required
variable str

Code for the chosen variable

required

Returns:

Type Description
tuple[str, str]

tuple[str, str]: Start date, end date

Source code in measurement/filters.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def get_date_range(station: str, variable: str) -> tuple[str, str]:
    """Get the date range covered by a chosen station and variable.

    Args:
        station (str): Code for the chosen station
        variable (str): Code for the chosen variable

    Returns:
        tuple[str, str]: Start date, end date
    """
    filter_vals = Measurement.objects.filter(
        station__station_code=station,
        variable__variable_code=variable,
    ).aggregate(
        first_date=Min("time"),
        last_date=Max("time"),
    )

    first_date = to_local_time(filter_vals["first_date"]).strftime("%Y-%m-%d")
    last_date = to_local_time(filter_vals["last_date"]).strftime("%Y-%m-%d")
    return first_date, last_date

get_report_data_from_db(station, variable, start_time, end_time, report_type, whole_months=True) cached ¤

Retrieves the report data from the database.

Time is set to the station timezone and the time range is inclusive of both start and end times.

Parameters:

Name Type Description Default
station str

Station of interest.

required
variable str

Variable of interest.

required
start_time str

Start time.

required
end_time str

End time.

required
report_type str

Type of report to retrieve.

required
whole_months bool

Whether to cover whole months or not.

True

Returns:

Type Description
DataFrame

A dataframe with the report data.

Source code in measurement/reporting.py
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
@lru_cache(1)
def get_report_data_from_db(
    station: str,
    variable: str,
    start_time: str,
    end_time: str,
    report_type: str,
    whole_months: bool = True,
) -> pd.DataFrame:
    """Retrieves the report data from the database.

    Time is set to the station timezone and the time range is inclusive of both
    start and end times.

    Args:
        station: Station of interest.
        variable: Variable of interest.
        start_time: Start time.
        end_time: End time.
        report_type: Type of report to retrieve.
        whole_months: Whether to cover whole months or not.

    Returns:
        A dataframe with the report data.
    """
    start_time_, end_time_ = reformat_dates(start_time, end_time, whole_months)

    if report_type == "measurement":
        data = pd.DataFrame.from_records(
            Measurement.objects.filter(
                station__station_code=station,
                variable__variable_code=variable,
                time__date__range=(start_time_.date(), end_time_.date()),
            ).values()
        )
        raw_cols = [col for col in data.columns if col.startswith("raw_")]
        normal = [col.strip("raw_") for col in raw_cols]
        data = data.drop(columns=normal).rename(columns=dict(zip(raw_cols, normal)))

    elif report_type == "validated":
        data = pd.DataFrame.from_records(
            Measurement.objects.filter(
                station__station_code=station,
                variable__variable_code=variable,
                time__date__range=(start_time_.date(), end_time_.date()),
                is_validated=True,
                is_active=True,
            ).values()
        )
        raw_cols = [col for col in data.columns if col.startswith("raw_")]
        data = data.drop(columns=raw_cols)

    else:
        data = pd.DataFrame.from_records(
            Report.objects.filter(
                station__station_code=station,
                variable__variable_code=variable,
                time__date__range=(start_time_.date(), end_time_.date()),
                report_type=report_type,
            ).values()
        )

    data = data.rename(columns={"station_id": "station", "variable_id": "variable"})

    if data.empty:
        return data

    tz = timezone.get_current_timezone()
    data["time"] = data["time"].dt.tz_convert(tz)
    return data.sort_values("time")

get_station_options(station_codes) ¤

Get valid station options and default value based on permissions and data availability.

Parameters:

Name Type Description Default
station_codes list[str]

List of station codes based on permissions

required

Returns:

Type Description
tuple[list[dict[str, str]], str | None]

tuple[list[dict], str]: Options for the station dropdown, default value

Source code in measurement/filters.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def get_station_options(
    station_codes: list[str],
) -> tuple[list[dict[str, str]], str | None]:
    """Get valid station options and default value based on permissions and data
    availability.

    Args:
        station_codes (list[str]): List of station codes based on permissions

    Returns:
        tuple[list[dict], str]: Options for the station dropdown, default value
    """
    stations_with_measurements = Station.objects.filter(
        ~Q(variables=""), station_code__in=station_codes
    ).values_list("station_code", flat=True)

    station_options = [
        {"label": station_code, "value": station_code}
        for station_code in stations_with_measurements
    ]
    station_value = station_options[0]["value"] if station_options else None
    return station_options, station_value

get_variable_options(station) ¤

Get valid variable options and default value based on the chosen station.

Parameters:

Name Type Description Default
station str

Code for the chosen station

required

Returns:

Type Description
tuple[list[dict[str, str]], str | None]

tuple[list[dict], str]: Options for the variable dropdown, default value

Source code in measurement/filters.py
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def get_variable_options(station: str) -> tuple[list[dict[str, str]], str | None]:
    """Get valid variable options and default value based on the chosen station.

    Args:
        station (str): Code for the chosen station

    Returns:
        tuple[list[dict], str]: Options for the variable dropdown, default value
    """
    variable_codes = Station.objects.get(station_code=station).variables_list
    variable_dicts = Variable.objects.filter(variable_code__in=variable_codes).values(
        "name", "variable_code"
    )

    variable_options = [
        {
            "label": variable["name"],
            "value": variable["variable_code"],
        }
        for variable in variable_dicts
    ]

    variable_value = variable_options[0]["value"] if variable_options else None
    return variable_options, variable_value

populate_stations_dropdown(station_codes) ¤

Populate the station dropdown based on the list of station codes.

Source code in measurement/dash_apps/data_report.py
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
@app.callback(
    [
        Output("primary_station_drop", "options"),
        Output("primary_station_drop", "value"),
        Output("secondary_station_drop", "options"),
        Output("secondary_station_drop", "value"),
    ],
    Input("stations_list", "children"),
)
def populate_stations_dropdown(
    station_codes: list[str],
) -> tuple[list[dict[str, str]], str | None, list[dict[str, str]], str | None]:
    """Populate the station dropdown based on the list of station codes."""
    options = get_station_options(station_codes)
    return *options, *options

populate_variable_dropdown(primary_chosen_station, secondary_chosen_station) ¤

Populate the variable dropdown based on the chosen station.

Source code in measurement/dash_apps/data_report.py
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
@app.callback(
    [
        Output("primary_variable_drop", "options"),
        Output("primary_variable_drop", "value"),
        Output("secondary_variable_drop", "options"),
        Output("secondary_variable_drop", "value"),
    ],
    Input("primary_station_drop", "value"),
    Input("secondary_station_drop", "value"),
)
def populate_variable_dropdown(
    primary_chosen_station: str,
    secondary_chosen_station: str,
) -> tuple[list[dict[str, str]], str | None, list[dict[str, str]], str | None]:
    """Populate the variable dropdown based on the chosen station."""
    primary_options = get_variable_options(primary_chosen_station)
    secondary_options = get_variable_options(secondary_chosen_station)
    return *primary_options, *secondary_options

set_date_range(chosen_station, chosen_variable) ¤

Set the default date range based on the chosen station and variable.

Source code in measurement/dash_apps/data_report.py
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
@app.callback(
    [
        Output("date_range_picker", "start_date"),
        Output("date_range_picker", "end_date"),
    ],
    [
        Input("primary_station_drop", "value"),
        Input("primary_variable_drop", "value"),
    ],
)
def set_date_range(
    chosen_station, chosen_variable
) -> tuple[
    str,
    str,
]:
    """Set the default date range based on the chosen station and variable."""
    return get_date_range(chosen_station, chosen_variable)

toggle_secondary_traces_div(show_secondary) ¤

Show or hide the secondary traces selection div based on the switch value.

Source code in measurement/dash_apps/data_report.py
363
364
365
366
367
368
369
@app.callback(
    Output("secondary_traces_div", "hidden"),
    Input("switch-show-secondary", "value"),
)
def toggle_secondary_traces_div(show_secondary: list[int]) -> bool:
    """Show or hide the secondary traces selection div based on the switch value."""
    return not bool(show_secondary)

update_graph(relayout_data, n_clicks, temporality, station, variable, start_time, end_time, figure, show_secondary, secondary_temporality, secondary_station, secondary_variable, callback_context) ¤

Source code in measurement/dash_apps/data_report.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
@app.callback(
    [
        Output("data_report_graph", "figure"),
        Output("data_alert_div", "children"),
        Output("data_alert_div", "hidden"),
    ],
    [
        Input("data_report_graph", "relayoutData"),
        Input("display_button", "n_clicks"),
    ],
    [
        State("primary_temporality_drop", "value"),
        State("primary_station_drop", "value"),
        State("primary_variable_drop", "value"),
        State("date_range_picker", "start_date"),
        State("date_range_picker", "end_date"),
        State("data_report_graph", "figure"),
        State("switch-show-secondary", "value"),
        State("secondary_temporality_drop", "value"),
        State("secondary_station_drop", "value"),
        State("secondary_variable_drop", "value"),
    ],
)
def update_graph(
    relayout_data: dict,
    n_clicks: int,
    temporality: str,
    station: str,
    variable: str,
    start_time: str,
    end_time: str,
    figure: go.Figure,
    show_secondary: list[int],
    secondary_temporality: str,
    secondary_station: str,
    secondary_variable: str,
    callback_context,
) -> go.Figure:
    ctx = callback_context
    triggered_id = ctx.triggered[0]["prop_id"].split(".")[0] if ctx.triggered else ""

    if not n_clicks:
        # After the first load, n_clicks is always > 0, so zooming works
        return figure, [], True

    # This is cached, so it's not a big deal to call it multiple times
    data = get_report_data_from_db(
        station=station,
        variable=variable,
        start_time=start_time,
        end_time=end_time,
        report_type=temporality,
        whole_months=False,
    )
    if data.empty:
        # If there's no data to plot, we warn about it, to distinguish from other errors
        alert = dbc.Alert("No data to plot for this time range.", color="warning")
        return create_empty_plot(), [alert], False

    secondary_data = pd.DataFrame()
    if bool(show_secondary):
        secondary_data = get_report_data_from_db(
            station=secondary_station,
            variable=secondary_variable,
            start_time=start_time,
            end_time=end_time,
            report_type=secondary_temporality,
            whole_months=False,
        )
    secondary_available = not secondary_data.empty

    if triggered_id == "data_report_graph" and "xaxis.range[0]" in relayout_data:
        start = relayout_data["xaxis.range[0]"]
        end = relayout_data["xaxis.range[1]"]
        data = data[(data["time"] >= start) & (data["time"] <= end)]
        if secondary_available:
            secondary_data = secondary_data[
                (secondary_data["time"] >= start) & (secondary_data["time"] <= end)
            ]

    try:
        every = max(1, len(data) // settings.MAX_POINTS)
        resampled = data.iloc[::every]
        agg = get_aggregation_level(resampled["time"], every > 1)
        resampled = add_nans_for_gaps(resampled)

        plot = create_report_plot(
            data=resampled,
            variable_name=Variable.objects.get(variable_code=variable).name,
            station_code=station,
            agg=agg,
            add_secondary_axis=secondary_available,
        )

        # Resample and add secondary, if available
        if secondary_available:
            every_secondary = max(1, len(secondary_data) // MAX_POINTS)
            secondary_resampled = secondary_data.iloc[::every_secondary]
            secondary_agg = get_aggregation_level(
                secondary_resampled["time"], every_secondary > 1
            )
            secondary_resampled = add_nans_for_gaps(secondary_resampled)

            plot = create_report_plot(
                data=secondary_resampled,
                variable_name=Variable.objects.get(
                    variable_code=secondary_variable
                ).name,
                station_code=secondary_station,
                agg=secondary_agg,
                fig=plot,
            )

        if "xaxis.range[0]" in relayout_data:
            plot["layout"]["xaxis"]["range"] = [
                relayout_data["xaxis.range[0]"],
                relayout_data["xaxis.range[1]"],
            ]

        # If the user request secondary data, but there's no data to plot, we
        # warn about it.
        if bool(show_secondary) and not secondary_available:
            alert = dbc.Alert(
                "Secondary data not available for this time range.", color="warning"
            )
            return plot, [alert], False
        return plot, [], True

    except Exception as e:
        getLogger().error(e)

        # If there's an unknown error, we warn about it, giving more or less details
        # depending on the debug mode.
        if settings.DEBUG:
            alert = dbc.Alert(
                f"There was an error during plotting: {e}.",
                color="warning",
            )
        else:
            alert = dbc.Alert(
                "There was an error during plotting. "
                "Please, contact Paricia Admins for details.",
                color="warning",
            )
        return create_empty_plot(), [alert], False