# --- Site ---
SITE = 'ch-hon'
SITE_LAT = 47.41887 # CH-HON
SITE_LON = 8.491318 # CH-HON
# --- Variables to screen ---
FIELDS = [
'TA_T1_4_2',
]
MEASUREMENT = 'TA'
# --- Time range to screen ---
START = '2026-03-01 00:00:01' # included
STOP = '2026-04-01 00:00:01' # not included
# --- Data settings ---
DATA_VERSION = 'raw'
TIMEZONE_OFFSET_TO_UTC_HOURS = 1 # UTC+01:00 (CET, winter time). Must match how the raw data was logged.
DIRCONF = r'F:\dev\poet\configs' # <-- set to your config folder
# DIRCONF = r'P:\Flux\RDS_calculations\_scripts\_configs\configs'
# --- Resampling ---
RESAMPLING_FREQ = '30min' # screened high-res data is resampled to this frequency
RESAMPLING_AGG = 'mean' # 'mean' or 'sum' (use 'sum' for precipitation)
# --- Parameter help ---
SHOW_PARAM_HELP = FalseMeteoScreening from database (influxdb)
*****Notebook version: 9 (15 Jul 2026) Author**: Lukas Hörtnagl (holukas@ethz.ch)
ℹ️ About this notebook
Download raw meteo data from the InfluxDB database, quality-screen and correct it on the high-resolution data, resample to 30MIN, and upload the result back to the database. Screening uses StepwiseMeteoScreeningDb from diive; download and upload use diive’s in-house InfluxDB engine (InfluxIO, in diive/core/io/db/influx), needing only uv sync --group db.
Flow: download (InfluxIO) → screen on high-res data (diive) → resample to 30MIN → upload.
Outlier detection is stepwise: run a test, inspect its preview plot, then commit it with mscr.addflag(). Re-run with different parameters as often as you like before committing. Run only the tests a variable actually needs. At the end all committed flags are aggregated into one overall quality flag QCF.
⏱️ Timestamp convention (important)
The database stores every timestamp in UTC and as TIMESTAMP_END (the stamp marks the end of the averaging interval). Getting the timestamp right is the one thing that must not go wrong, because the day/night split during screening — and the value written back to the DB — both depend on it.
The single knob is TIMEZONE_OFFSET_TO_UTC_HOURS (set in User settings). It is applied identically on download and on upload. Here is the full round-trip, all handled for you:
| Stage | Timezone | Convention | Done by |
|---|---|---|---|
| Database | UTC | TIMESTAMP_END |
InfluxDB |
After dbc.download(..., timezone_offset_to_utc_hours=N) |
local (UTC+N) | TIMESTAMP_END |
InfluxIO |
| During screening | local | TIMESTAMP_MIDDLE (converted internally) |
StepwiseMeteoScreeningDb |
After mscr.resample() |
local | back to TIMESTAMP_END |
diive |
After dbc.upload_singlevar(..., timezone_offset_to_utc_hours=N) |
UTC | TIMESTAMP_END |
InfluxIO |
So screening runs on correct local middle-of-period timestamps, and the resampled value lands on the correct UTC end-of-period stamp in the DB. TIMEZONE_OFFSET_TO_UTC_HOURS must match the timezone the raw data was logged in (e.g. 1 for CET winter time) and must be the same value everywhere. This notebook prints the timestamps right after download and right after the verification download so you can confirm they look correct.
✏️ User settings (please adjust)
Adjust these before running. What each setting means:
Site - SITE, SITE_LAT, SITE_LON: site ID and coordinates. The coordinates set the day/night split used during screening.
Variables to screen - FIELDS: variable name(s) exactly as stored in the database (the InfluxDB _field). Multiple allowed, e.g. ['TA_T1_20_1', 'TA_NABEL_T1_35_1']. - MEASUREMENT: exactly one measurement grouping the variables, e.g. TA (air temperature), SW (short-wave radiation), SWC (soil water content).
Time range to screen - START: first timestamp to screen — is included. - STOP: upper bound — is not included.
Data settings - TIMEZONE_OFFSET_TO_UTC_HOURS: the critical timestamp knob — see the Timestamp convention section above. Must match how the raw data was logged (e.g. 1 for CET winter time). - DATA_VERSION: the source data version in the database (raw). - DIRCONF: local folder holding the database connection config.
Resampling - RESAMPLING_FREQ: the screened high-res data is resampled to this frequency. - RESAMPLING_AGG: aggregation used when resampling: 'mean' or 'sum'. ⚠️ For precipitation use 'sum'.
Parameter help - SHOW_PARAM_HELP: set True to print the full docstring (all parameters) of each screening method right before it runs. Leave False for a clean, concise notebook.
🤖 Auto settings
Buckets (do not adjust)
BUCKET_RAW = f'{SITE}_raw' # source bucket, e.g. 'ch-hon_raw'
BUCKET_PROCESSED = f'{SITE}_processed' # destination bucket, e.g. 'ch-hon_processed'
print(f'Source bucket (raw data): {BUCKET_RAW}')
print(f'Destination bucket (processed): {BUCKET_PROCESSED}')Imports
import importlib.metadata
import warnings
from datetime import datetime
import pandas as pd
import diive as dv
from diive.core.io.db.influx import InfluxIO # diive's in-house InfluxDB engine (needs: uv sync --group db)
warnings.filterwarnings(action='ignore', category=FutureWarning)
warnings.filterwarnings(action='ignore', category=UserWarning)
pd.set_option('display.max_rows', 30)
pd.set_option('display.max_columns', 30)
pd.set_option('display.width', 1000)
print(f"Last run: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"diive v{importlib.metadata.version('diive')}")⬇️ Download data from database
Connect to database
dbc = InfluxIO(dirconf=DIRCONF)Optional — list all fields available in the measurement (does not check the selected time range):
# display(dbc.show_fields_in_measurement(bucket=BUCKET_RAW, measurement=MEASUREMENT))Download
Returns three objects: - data_simple: high-res time series, one column per variable (nice to look at). - data_detailed: dict {varname: DataFrame} with each variable’s time series and its database tags — this is what the screening consumes. - assigned_measurements: the auto-detected measurement per variable (a sanity check).
%%time
data_simple, data_detailed, assigned_measurements = dbc.download(
bucket=BUCKET_RAW,
measurements=[MEASUREMENT],
fields=FIELDS,
start=START,
stop=STOP,
timezone_offset_to_utc_hours=TIMEZONE_OFFSET_TO_UTC_HOURS,
data_version=DATA_VERSION,
)Inspect downloaded data
data_simpleassigned_measurementsDrop any requested variable that has no data in this period:
vars_not_available = [v for v in FIELDS if v not in data_detailed.keys()]
for rem in vars_not_available:
FIELDS.remove(rem)
print(f'Removed {rem} from FIELDS (no data in this period).')
print(f'Data available for: {list(data_detailed.keys())}')Verify download timestamps
Confirm the timestamps look right: they should be in local time (UTC+TIMEZONE_OFFSET_TO_UTC_HOURS) and mark the end of each averaging interval. The DB itself stores UTC — InfluxIO applied the offset on download. Eyeball the first/last stamps against the START/STOP you requested.
for v in data_detailed.keys():
idx = data_detailed[v].index
print(f'{v}: index name={idx.name!r}, tz={idx.tz}, freq={idx.freqstr}')
print(f' first={idx[0]} last={idx[-1]}')
print(f'\nApplied UTC offset: +{TIMEZONE_OFFSET_TO_UTC_HOURS}h (timestamps above are local time)')Optional — save the full-resolution raw data to a file:
# data_detailed[FIELDS[0]].to_csv('rawdata_highres.csv')Plot downloaded high-res data
for varname, frame in data_detailed.items():
dv.plotting.TimeSeries(series=frame[varname]).plot()▶️ Start MeteoScreening with diive
mscr = dv.qaqc.StepwiseMeteoScreeningDb(
site=SITE,
data_detailed=data_detailed,
fields=FIELDS,
site_lat=SITE_LAT,
site_lon=SITE_LON,
utc_offset=TIMEZONE_OFFSET_TO_UTC_HOURS,
)
mscr.showplot_orig()🔍 Outlier detection
Run a test → inspect its preview → commit with mscr.addflag(). Only the committed flag of the most recent test is added. Skip any test a variable does not need.
💡 If the data already look clean, you can skip straight to Corrections or Resampling.
mscr.start_outlier_detection()Plot the current cleaned data at any point during detection:
for key, val in mscr.outlier_detection.items():
val.showplot_cleaned(interactive=False)Manual removal
Flag specific timestamps or time ranges for removal — known sensor failures, maintenance windows, etc. Give [start, stop] pairs and/or single timestamps.
if SHOW_PARAM_HELP:
help(dv.outliers.ManualRemoval)REMOVE_DATES = [
['2024-07-14 07:15:00', '2024-07-19 00:00:15'], # remove a time range
# '2022-08-23 11:45:00', # remove a single point
]
mscr.flag_manualremoval_test(remove_dates=REMOVE_DATES, showplot=True, verbose=True)mscr.addflag()Hampel filter
Sliding-window spike detection using the median absolute deviation (MAD) — robust to spikes already present in the window. Good general-purpose filter. separate_daytime_nighttime=True applies independent thresholds to day and night (n_sigma_daytime / n_sigma_nighttime).
if SHOW_PARAM_HELP:
help(dv.outliers.Hampel)mscr.flag_outliers_hampel_test(
window_length=60 * 24 * 7, # 7 days of 1-min data used for the MAD window
n_sigma_daytime=5.5, n_sigma_nighttime=5.5,
use_differencing=True, separate_daytime_nighttime=True,
repeat=True, showplot=True, verbose=True,
)mscr.addflag()Z-score
Flags values whose deviation from the mean, in standard deviations, exceeds thres_zscore. separate_daytime_nighttime=True computes separate mean/SD for day and night (day/night boundaries come from the site coordinates given at init).
⚠️ Radiation: this easily removes legitimate below-zero nighttime values — those are corrected later (Remove radiation zero offset). Don’t let it remove them here.
⚠️ Relative humidity: likewise it can remove valid values >100%, which are corrected later.
if SHOW_PARAM_HELP:
help(dv.outliers.zScore)mscr.flag_outliers_zscore_test(
thres_zscore=4.5, separate_daytime_nighttime=True,
repeat=True, showplot=True, verbose=True,
)mscr.addflag()Z-score (rolling window)
Like the z-score test but computed in a moving window of winsize records, so it adapts to slow drifts and flags only local spikes.
if SHOW_PARAM_HELP:
help(dv.outliers.zScoreRolling)mscr.flag_outliers_zscore_rolling_test(
thres_zscore=4.5, winsize=1440 * 7, # 7 days of 1-min data
repeat=True, showplot=True, verbose=True,
)mscr.addflag()Local standard deviation
Flags values beyond n_sd standard deviations of a local rolling window (winsize). separate_daytime_nighttime=True allows different n_sd for day and night. constant_sd=True uses one global SD instead of a per-window SD.
if SHOW_PARAM_HELP:
help(dv.outliers.LocalSD)mscr.flag_outliers_localsd_test(
separate_daytime_nighttime=True, n_sd=5.5, winsize=60 * 24 * 7,
constant_sd=False, repeat=False, showplot=True, verbose=True,
)mscr.addflag()Increments z-score
Flags unrealistic jumps between consecutive records (z-score of the first difference). Catches spikes and steps that absolute-value tests miss.
if SHOW_PARAM_HELP:
help(dv.outliers.zScoreIncrements)mscr.flag_outliers_increments_zcore_test(thres_zscore=40, repeat=True, showplot=True, verbose=True)mscr.addflag()Local outlier factor
Density-based detection (k-nearest neighbours): flags points isolated relative to their neighbours.
🐌 Do not run on high-resolution data (1S/10S/1MIN) — extremely slow. Fine on 30MIN or coarser.
if SHOW_PARAM_HELP:
help(dv.outliers.LocalOutlierFactor)mscr.flag_outliers_lof_test(
n_neighbors=30, contamination=0.01, separate_daytime_nighttime=False,
repeat=False, n_jobs=-1, showplot=True, verbose=True,
)mscr.addflag()Absolute limits
Flags values outside a fixed physical range [minval, maxval]. separate_daytime_nighttime=True allows separate ranges via daytime_minmax / nighttime_minmax.
if SHOW_PARAM_HELP:
help(dv.outliers.AbsoluteLimits)mscr.flag_outliers_abslim_test(minval=-18, maxval=50, showplot=True)mscr.addflag()Trim low
Flags values below lower_limit, then flags an equal number of the highest values (symmetric trim). trim_daytime / trim_nighttime restrict which period is trimmed.
if SHOW_PARAM_HELP:
help(dv.outliers.TrimLow)mscr.flag_outliers_trim_low_test(
trim_daytime=False, trim_nighttime=True, lower_limit=10,
showplot=True, verbose=True,
)mscr.addflag()Missing values
Not an outlier test — flags missing records so they are counted in the overall QCF.
mscr.flag_missingvals_test(verbose=True)Overall quality flag QCF
Aggregate all committed test flags into one overall flag QCF (0 = good, 1 = marginal, 2 = bad) and filter the series. Required before corrections and resampling.
mscr.finalize_outlier_detection()Reports
mscr.report_outlier_detection_qcf_evolution()mscr.report_outlier_detection_qcf_flags()mscr.report_outlier_detection_qcf_series()Plots
mscr.showplot_outlier_detection_qcf_heatmaps()
# mscr.showplot_outlier_detection_qcf_timeseries()🔧 Corrections
Applied to the high-res, QCF-filtered data. Run only what applies to the variable.
⚠️ The correction calls below are commented out on purpose — each one mutates the data with example parameters. Edit the parameters for your variable, then uncomment the call to apply it. This keeps a top-to-bottom Run All from silently corrupting data (e.g. clipping air temperature or deleting valid
0values).
mscr.showplot_cleaned()Remove radiation zero offset
For radiation (SW_IN, SW_OUT, PPFD_IN, PPFD_OUT): detect the per-day nighttime offset, set night to zero, and correct daytime values by the interpolated slope.
# Uncomment to apply (radiation variables only):
# mscr.correction_remove_nighttime_zero_offset()Remove relative humidity offset
For RH: remove the offset so values do not exceed 100%.
# Uncomment to apply (RH only):
# mscr.correction_remove_relativehumidity_offset()Set to max / min threshold
Clip values above (below) a threshold to the threshold value.
# Edit thresholds, then uncomment to apply:
# mscr.correction_setto_max_threshold(threshold=30)
# mscr.correction_setto_min_threshold(threshold=-5)Set time range(s) to a value
Set all records within the given date range(s) to a constant value.
# Edit DATES/value, then uncomment to apply:
DATES = [
['2022-04-01', '2022-04-05'],
['2022-09-05', '2022-09-07'],
]
# mscr.correction_setto_value(dates=DATES, value=3.7, verbose=1)
# mscr.showplot_cleaned(interactive=False)Set exact values to missing
Set records exactly equal to given value(s) to NaN — e.g. a stuck 0. Inspect the most frequent values first (the inspection below is read-only and safe to run).
for ff in mscr.fields:
vc = mscr.series_hires_cleaned[ff].value_counts()
print(f'--- {ff} (top 20 of {mscr.series_hires_cleaned[ff].count()} records) ---')
print(vc.head(20))# Edit values, then uncomment to apply:
# mscr.correction_set_exact_value_to_missing(values=[0])
# mscr.showplot_cleaned(interactive=False)📈 Analyses (optional)
Check for timestamp shifts (radiation only)
For SW_IN, SW_OUT, PPFD_IN, PPFD_OUT: compare the measured radiation to potential (clear-sky) radiation. A consistent offset between the two points to a timestamp shift.
# _ = mscr.analysis_potential_radiation_correlation(utc_offset=TIMEZONE_OFFSET_TO_UTC_HOURS, mincorr=0.7, showplot=True)🔁 Resampling
Resample to 30MIN
Resample the screened high-res series to RESAMPLING_FREQ. The output timestamp is TIMESTAMP_END again (see Timestamp convention), ready for upload.
mscr.resample(to_freqstr=RESAMPLING_FREQ, agg=RESAMPLING_AGG, mincounts_perc=.25)
mscr.showplot_resampled()Check the resampled time resolution
for v in mscr.resampled_detailed.keys():
freq = dv.times.DetectFrequency(index=mscr.resampled_detailed[v].index, verbose=True).get()
status = 'PASSED' if freq == RESAMPLING_FREQ else '(!) FAILED'
print(f'{status} - {v}: {freq}')⬆️ Upload data to database
Re-uploading overwrites the same variant — safe to re-run. With delete_from_db_before_upload=True (below), the upload first deletes, then writes. The delete is scoped to the exact match _measurement + varname + data_version (meteoscreening_diive) over the uploaded time range, so re-screening a period replaces only its previous screened result. It never touches the raw data (different data_version, and a different _raw bucket), other variables, or other data versions. The delete-first step (rather than a plain overwrite) matters because InfluxDB keys a point by its full tag set: if a tag changed between runs (e.g. units, gain, offset), a plain write would leave the old point as a duplicate — the delete removes it regardless of tags.
print(f'Uploading to bucket {BUCKET_PROCESSED}')
for v in mscr.resampled_detailed.keys():
dbc.upload_singlevar(
to_bucket=BUCKET_PROCESSED,
to_measurement=assigned_measurements[v],
var_df=mscr.resampled_detailed[v],
timezone_offset_to_utc_hours=TIMEZONE_OFFSET_TO_UTC_HOURS,
delete_from_db_before_upload=True,
)Verify upload
Download the just-uploaded data back and confirm the time resolution and timestamps. The offset is the same TIMEZONE_OFFSET_TO_UTC_HOURS, so the timestamps below should again be local TIMESTAMP_END — matching what you screened.
# Fresh variable names so the screened originals (data_detailed etc.) are not overwritten:
check_simple, check_detailed, check_measurements = dbc.download(
bucket=BUCKET_PROCESSED,
measurements=[MEASUREMENT],
fields=FIELDS,
start=START,
stop=STOP,
timezone_offset_to_utc_hours=TIMEZONE_OFFSET_TO_UTC_HOURS,
data_version='meteoscreening_diive',
)
check_simplefor v in check_detailed.keys():
idx = check_detailed[v].index
freq = dv.times.DetectFrequency(index=idx, verbose=True).get()
status = 'PASSED' if freq == RESAMPLING_FREQ else '(!) FAILED'
print(f'{status} - {v}: freq={freq}, first={idx[0]}, last={idx[-1]}')✅ End of notebook
print(f"Finished: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")