From 5752e6bc3138951c5fce0b6fc3205708012b5149 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Tue, 13 Jan 2026 14:19:30 +0100 Subject: [PATCH 01/57] duplicate timestamp in sperate module --- .../qc_collection/duplicated_timestamp.py | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/metobs_toolkit/qc_collection/duplicated_timestamp.py diff --git a/src/metobs_toolkit/qc_collection/duplicated_timestamp.py b/src/metobs_toolkit/qc_collection/duplicated_timestamp.py new file mode 100644 index 00000000..28aacbfa --- /dev/null +++ b/src/metobs_toolkit/qc_collection/duplicated_timestamp.py @@ -0,0 +1,81 @@ +import logging + +import pandas as pd + + +from .common_functions import create_qcresult_flags +from metobs_toolkit.backend_collection.decorators import log_entry +from metobs_toolkit.qcresult import ( + QCresult, + pass_cond, + flagged_cond, + +) + +logger = logging.getLogger("") + + +@log_entry +def duplicated_timestamp_check(records: pd.Series) -> QCresult: + """Check for duplicated timestamps in a time series. + + Identifies all records that share the same timestamp. All occurrences of + duplicated timestamps are flagged as outliers (not just subsequent ones). + This check is performed before invalid value checking. + + Parameters + ---------- + records : pd.Series + Series with a datetime-like index to check for duplicates. + + Returns + ------- + QCresult + Quality control result object containing flags, outliers, and details + for the duplicated timestamp check. + + Notes + ----- + * All records with duplicated timestamps are flagged, including the first occurrence. + * Values are coerced to numeric during this check to ensure compatibility with + downstream processing. + * Details include a list of all values sharing each duplicated timestamp. + """ + # find all duplicates + duplicates = records[records.index.duplicated(keep=False)] + + #Drop dulicates from series, they are a mess to take along + no_dup_records = records[~records.index.duplicated(keep='first')] + + #create flags (no duplicates in the index!) + flags = create_qcresult_flags( + all_input_records=no_dup_records, #NO duplicates here + unmet_cond_idx = pd.DatetimeIndex([]), + outliers_before_white_idx=duplicates.index.unique(), + outliers_after_white_idx=duplicates.index.unique(), + ) + + + + #Special case: this check is performed before the invalid check, so values + #must be cast to numeric to avoid issues when combining them in the outliersdf + duplicates = pd.to_numeric(duplicates, errors='coerce') + + qcresult = QCresult( + checkname="duplicated_timestamp", + checksettings={}, + flags=flags, + outliers =duplicates[~duplicates.index.duplicated(keep="first")], + detail='no details') + + #Create and add details + if not duplicates.empty: + # For each duplicated timestamp, join all values as a comma-separated string + details = ( + duplicates.groupby(duplicates.index) + .apply(lambda x:"duplicated timestamp with: " + ", ".join(map(str, x.values))) + ) + qcresult.add_details_by_series(detail_series = details) + + + return qcresult \ No newline at end of file From 647454d12560d94297598c5cb2bf6f0c9532bcfd Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Tue, 13 Jan 2026 14:19:44 +0100 Subject: [PATCH 02/57] invalid check in seperate module --- .../qc_collection/invalid_check.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/metobs_toolkit/qc_collection/invalid_check.py diff --git a/src/metobs_toolkit/qc_collection/invalid_check.py b/src/metobs_toolkit/qc_collection/invalid_check.py new file mode 100644 index 00000000..01d086dc --- /dev/null +++ b/src/metobs_toolkit/qc_collection/invalid_check.py @@ -0,0 +1,72 @@ +import logging + +import pandas as pd + + +from .whitelist import SensorWhiteSet +from metobs_toolkit.backend_collection.decorators import log_entry +from metobs_toolkit.qcresult import ( + QCresult, + pass_cond, + flagged_cond, + +) + +logger = logging.getLogger("") + + +@log_entry +def drop_invalid_values(records: pd.Series, skip_records: pd.DatetimeIndex) -> pd.Series: + """Remove invalid (non-numeric) values from a time series. + + Filters out values that could not be cast to numeric types. Invalid timestamps + are treated as gaps and removed from the series rather than being flagged as + outliers. This allows the gap detection mechanism to handle them appropriately. + + Parameters + ---------- + records : pd.Series + Series with a datetime-like index containing values to validate. + skip_records : pd.DatetimeIndex + Records to temporarily exclude from the check (typically duplicated timestamps). + These records are preserved regardless of validity and added back after filtering. + + Returns + ------- + pd.Series + Filtered series containing only records with valid numeric values, + plus all skipped records. + + Notes + ----- + * Invalid values are interpreted as missing data (gaps) rather than outliers. + * Skipped records are preserved to avoid interfering with prior QC checks. + * This function does not raise an error if the check was previously applied. + """ + skipped_data = records.loc[skip_records] + targets = records.drop(skip_records) + + # Option 1: Create a outlier label for these invalid inputs, + # and treath them as outliers + # outlier_timestamps = targets[~targets.notnull()] + + # self._update_outliers( + # qccheckname="invalid_input", + # outliertimestamps=outlier_timestamps.index, + # check_kwargs={}, + # extra_columns={}, + # overwrite=False, + # ) + + # Option 2: Since there is not numeric value present, these timestamps are + # interpreted as gaps --> remove the timestamp, so that it is captured by the + # gap finder. + + # Note: do not treat the first/last timestamps differently. That is + # a philosiphycal choice. + + validrecords = targets[targets.notnull()] # subset to numerical casted values + # add the skipped records back + validrecords = pd.concat([validrecords, skipped_data]).sort_index() + return validrecords + \ No newline at end of file From 593d8aab22563e54ae6de2e6491ca3d6a6093f7c Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Tue, 13 Jan 2026 14:20:48 +0100 Subject: [PATCH 03/57] regular checks now use the QCresult class per sensor --- .../qc_collection/common_functions.py | 48 +++ .../qc_collection/grossvalue_check.py | 46 ++- .../qc_collection/persistence_check.py | 86 ++++- .../qc_collection/repetitions_check.py | 62 +++- .../qc_collection/step_check.py | 49 ++- .../qc_collection/window_variation_check.py | 76 +++- src/metobs_toolkit/qcresult.py | 182 ++++++++++ src/metobs_toolkit/sensordata.py | 335 +++++++----------- 8 files changed, 610 insertions(+), 274 deletions(-) create mode 100644 src/metobs_toolkit/qcresult.py diff --git a/src/metobs_toolkit/qc_collection/common_functions.py b/src/metobs_toolkit/qc_collection/common_functions.py index a9fed301..ea8d32f1 100644 --- a/src/metobs_toolkit/qc_collection/common_functions.py +++ b/src/metobs_toolkit/qc_collection/common_functions.py @@ -2,6 +2,13 @@ import logging from metobs_toolkit.backend_collection.decorators import log_entry +from metobs_toolkit.qcresult import ( + pass_cond, + flagged_cond, + saved_cond, + unchecked_cond, + unmet_cond +) logger = logging.getLogger("") @@ -50,3 +57,44 @@ def test_moving_window_condition( ismet = (windowsize / freq) >= min_records_per_window logger.debug("Exiting function test_moving_window_condition.") return ismet + + +def create_qcresult_flags( + all_input_records: pd.Series, + unmet_cond_idx: pd.DatetimeIndex, + outliers_before_white_idx: pd.DatetimeIndex, + outliers_after_white_idx: pd.DatetimeIndex, +) -> pd.Series: + """Create quality control flags series for all input records. + + This function generates a pandas Series containing QC flags for all timestamps + in the input records. Records are categorized as: unchecked (NaN values), + passed (valid non-outliers), unmet condition, saved (whitelisted outliers), + or flagged (detected outliers). + + Parameters + ---------- + all_input_records : pd.Series + Complete series of records with datetime index to flag. + unmet_cond_idx : pd.DatetimeIndex + Timestamps where QC check conditions were not met. + outliers_before_white_idx : pd.DatetimeIndex + Timestamps of all detected outliers before whitelist filtering. + outliers_after_white_idx : pd.DatetimeIndex + Timestamps of outliers remaining after whitelist filtering. + + Returns + ------- + pd.Series + Series with same index as all_input_records containing QC flag strings: + 'unchecked', 'passed', 'condition_unmet', 'saved', or 'flagged'. + """ + flags = pd.Series(data=unchecked_cond, index=all_input_records.index) + flags.loc[all_input_records.dropna().index] = pass_cond + flags.loc[unmet_cond_idx] = unmet_cond + + saved_records_idx = outliers_before_white_idx.difference(outliers_after_white_idx) + flags.loc[saved_records_idx] = saved_cond + flags.loc[outliers_after_white_idx] = flagged_cond + + return flags \ No newline at end of file diff --git a/src/metobs_toolkit/qc_collection/grossvalue_check.py b/src/metobs_toolkit/qc_collection/grossvalue_check.py index ba22d412..14ffb540 100644 --- a/src/metobs_toolkit/qc_collection/grossvalue_check.py +++ b/src/metobs_toolkit/qc_collection/grossvalue_check.py @@ -2,9 +2,10 @@ from typing import Union import pandas as pd - +from .common_functions import create_qcresult_flags from .whitelist import SensorWhiteSet from metobs_toolkit.backend_collection.decorators import log_entry +from metobs_toolkit.qcresult import QCresult logger = logging.getLogger("") @@ -15,7 +16,7 @@ def gross_value_check( lower_threshold: Union[int, float], upper_threshold: Union[int, float], sensorwhiteset: SensorWhiteSet, -) -> pd.DatetimeIndex: +) -> QCresult: """ Identify outliers in a time series based on lower and upper thresholds. @@ -34,21 +35,42 @@ def gross_value_check( Returns ------- - pd.DatetimeIndex - Timestamps of outlier records. - - + QCresult + Quality control result object containing flags, outliers, and details + for the gross value check. """ # Drop NaN values - records = records.dropna() + to_check_records = records.dropna() # Identify outliers - outliers_idx = records[ - (records < lower_threshold) | (records > upper_threshold) + outliers_idx = to_check_records[ + (to_check_records < lower_threshold) | (to_check_records > upper_threshold) ].index # Exclude white records if provided - outliers_idx = sensorwhiteset.catch_white_records(outliers_idx=outliers_idx) + outliers_after_white_idx = sensorwhiteset.catch_white_records(outliers_idx=outliers_idx) + + # Create QCresult flags + flags = create_qcresult_flags( + all_input_records=records, + unmet_cond_idx = pd.DatetimeIndex([]), + outliers_before_white_idx=outliers_idx, + outliers_after_white_idx=outliers_after_white_idx, + ) - logger.debug("Exiting function gross_value_check.") - return outliers_idx + qcresult = QCresult( + checkname="gross_value", + checksettings=locals().pop('records', None), + flags=flags, + outliers = records.loc[outliers_after_white_idx], + detail='no details' + ) + + #Create and add details + if not outliers_after_white_idx.empty: + detailseries = pd.Series( + data = 'value outside gross value thresholds [' + str(lower_threshold) + ', ' + str(upper_threshold) + ']', + index = outliers_after_white_idx + ) + qcresult.add_details_by_series(detail_series = detailseries) + return qcresult diff --git a/src/metobs_toolkit/qc_collection/persistence_check.py b/src/metobs_toolkit/qc_collection/persistence_check.py index 315c168d..5e6099f6 100644 --- a/src/metobs_toolkit/qc_collection/persistence_check.py +++ b/src/metobs_toolkit/qc_collection/persistence_check.py @@ -3,8 +3,14 @@ import numpy as np import pandas as pd -from .common_functions import test_moving_window_condition +from .common_functions import test_moving_window_condition, create_qcresult_flags from .whitelist import SensorWhiteSet +from metobs_toolkit.qcresult import ( + QCresult, + pass_cond, + flagged_cond, + unmet_cond, +) from metobs_toolkit.backend_collection.decorators import log_entry from metobs_toolkit.backend_collection.datetime_collection import ( timestamps_to_datetimeindex, @@ -19,7 +25,7 @@ def persistence_check( timewindow: pd.Timedelta, min_records_per_window: int, sensorwhiteset: SensorWhiteSet, -) -> pd.DatetimeIndex: +) -> QCresult: """ Check if values are not constant in a moving time window. @@ -35,15 +41,16 @@ def persistence_check( The size of the rolling time window to check for persistence. min_records_per_window : int The minimum number of non-NaN records required within the time window for the check to be valid. - sensorwhiteset : SensorWhiteSet, optional + sensorwhiteset : SensorWhiteSet A SensorWhiteSet instance containing timestamps that should be excluded from outlier detection. Records matching the whiteset criteria will not be flagged as outliers even if they meet the persistence criteria. Returns ------- - pd.DatetimeIndex - Timestamps of outlier records. + QCresult + Quality control result object containing flags, outliers, and details + for the persistence check. Notes ----- @@ -60,9 +67,10 @@ def persistence_check( returns an empty DatetimeIndex. """ + to_check_records = records.dropna() # Exclude outliers and gaps # Test if the conditions for the moving window are met by the records frequency is_met = test_moving_window_condition( - records=records, + records=records, #pass records, because freq is estimated windowsize=timewindow, min_records_per_window=min_records_per_window, ) @@ -70,12 +78,26 @@ def persistence_check( logger.warning( "The minimum number of window members for the persistence check is not met!" ) - return timestamps_to_datetimeindex( - name="datetime", timestamps=[], current_tz=None + flags = create_qcresult_flags( + all_input_records=records, + unmet_cond_idx=to_check_records.index, + outliers_before_white_idx=pd.DatetimeIndex([]), + outliers_after_white_idx=pd.DatetimeIndex([]), + ) + qcresult = QCresult( + checkname="persistence", + checksettings=locals().pop('records', None), + flags=flags, + outliers=timestamps_to_datetimeindex( + name="datetime", timestamps=[], current_tz=None + ), + detail="Minimum number of records per window not met.", ) + return qcresult + # Apply persistence - @log_entry + def is_unique(window: pd.Series) -> bool: """ Check if all non-NaN values in the window are identical. @@ -95,8 +117,9 @@ def is_unique(window: pd.Series) -> bool: return (a[0] == a).all() if len(a) > 0 else False # This is very expensive if no coarsening is applied! Can we speed this up? - window_is_constant = ( - records.dropna() # Exclude outliers and gaps + + window_flags = ( + to_check_records .rolling( window=timewindow, closed="both", @@ -105,13 +128,38 @@ def is_unique(window: pd.Series) -> bool: ) .apply(is_unique) ) - # The returns are numeric values (0 --> False, NaN --> not checked (members/window condition not met), 1 --> outlier) - window_is_constant = window_is_constant.map({0.0: False, np.nan: False, 1.0: True}) - - outliers_idx = window_is_constant[window_is_constant].index + # The returns are numeric values (0 --> oke, NaN --> not checked (members/window condition not met), 1 --> outlier) + window_flags = window_flags.map( + {0.0: pass_cond, + np.nan: unmet_cond, + 1.0: flagged_cond}) + + outliers_idx = window_flags[window_flags == flagged_cond].index # Catch the white records - outliers_idx = sensorwhiteset.catch_white_records(outliers_idx=outliers_idx) - - logger.debug("Exiting function persistence_check") - return outliers_idx + outliers_after_white_idx = sensorwhiteset.catch_white_records(outliers_idx=outliers_idx) + + #Create flags + flags = create_qcresult_flags( + all_input_records=records, + unmet_cond_idx=window_flags[window_flags == unmet_cond].index, + outliers_before_white_idx=outliers_idx, + outliers_after_white_idx=outliers_after_white_idx) + + qcresult = QCresult( + checkname="persistence", + checksettings=locals().pop('records', None), + flags=flags, + outliers = records.loc[outliers_after_white_idx], + detail='no details' + ) + + #Create and add details + if not outliers_after_white_idx.empty: + detailseries = pd.Series( + data = 'constant values in timewindow of ' + str(timewindow), + index = outliers_after_white_idx + ) + qcresult.add_details_by_series(detail_series = detailseries) + + return qcresult diff --git a/src/metobs_toolkit/qc_collection/repetitions_check.py b/src/metobs_toolkit/qc_collection/repetitions_check.py index f511a840..5304d09b 100644 --- a/src/metobs_toolkit/qc_collection/repetitions_check.py +++ b/src/metobs_toolkit/qc_collection/repetitions_check.py @@ -1,6 +1,8 @@ import logging import pandas as pd +from metobs_toolkit.qcresult import QCresult +from .common_functions import create_qcresult_flags from metobs_toolkit.backend_collection.decorators import log_entry from metobs_toolkit.backend_collection.datetime_collection import ( timestamps_to_datetimeindex, @@ -15,7 +17,7 @@ def repetitions_check( records: pd.Series, max_N_repetitions: int, sensorwhiteset: SensorWhiteSet, -) -> pd.DatetimeIndex: +) -> QCresult: """ Test if an observation changes after a number of repetitions. @@ -33,15 +35,16 @@ def repetitions_check( max_N_repetitions : int The maximum number of repetitions allowed before the records are flagged as outliers. If the number of repetitions exceeds this value, all repeated records are flagged as outliers. - sensorwhiteset : SensorWhiteSet, optional + sensorwhiteset : SensorWhiteSet A SensorWhiteSet instance containing timestamps that should be excluded from outlier detection. Records matching the whiteset criteria will not be flagged as outliers even if they exceed the max_N_repetitions threshold. Returns ------- - pd.DatetimeIndex - Timestamps of outlier records. + QCresult + Quality control result object containing flags, outliers, and details + for the repetitions check. Notes ----- @@ -51,13 +54,13 @@ def repetitions_check( """ # Drop outliers from the series (these are NaNs) - input_series = records.dropna() + to_check_records = records.dropna() # Create group definitions for repeating values that do not change - persistence_filter = ((input_series.shift() != input_series)).cumsum() + persistence_filter = ((to_check_records.shift() != to_check_records)).cumsum() persdf = pd.DataFrame( - data={"value": input_series, "persistgroup": persistence_filter}, - index=input_series.index, + data={"value": to_check_records, "persistgroup": persistence_filter}, + index=to_check_records.index, ) # Find outlier groups @@ -66,13 +69,15 @@ def repetitions_check( group_sizes = groups.size() outlier_groups = group_sizes[group_sizes > max_N_repetitions] + + # if outlier_groups.empty: + # logger.debug("No outliers detected. Exiting repetitions_check function.") + + # return timestamps_to_datetimeindex( + # timestamps=[], name="datetime", current_tz=None + # ) + # Combine all outlier groups - if outlier_groups.empty: - logger.debug("No outliers detected. Exiting repetitions_check function.") - return timestamps_to_datetimeindex( - timestamps=[], name="datetime", current_tz=None - ) - outliers = pd.concat( [ groups.get_group( @@ -84,5 +89,30 @@ def repetitions_check( logger.debug("Outliers detected. Exiting repetitions_check function.") # Catch the white records - outliers_idx = sensorwhiteset.catch_white_records(outliers.index) - return outliers_idx + outliers_after_white_idx = sensorwhiteset.catch_white_records(outliers.index) + + # Create flags + flags = create_qcresult_flags( + all_input_records=records, + unmet_cond_idx = pd.DatetimeIndex([]), + outliers_before_white_idx=outliers.index, + outliers_after_white_idx=outliers_after_white_idx, + ) + + qcresult = QCresult( + checkname="repetitions", + checksettings=locals().pop('records', None), + flags=flags, + outliers = records.loc[outliers_after_white_idx], + detail='no details' + ) + + #Create and add details + if not outliers_after_white_idx.empty: + detailseries = pd.Series( + data = 'More than ' + str(max_N_repetitions) + ' repeated values', + index = outliers_after_white_idx + ) + qcresult.add_details_by_series(detail_series = detailseries) + + return qcresult \ No newline at end of file diff --git a/src/metobs_toolkit/qc_collection/step_check.py b/src/metobs_toolkit/qc_collection/step_check.py index 9c563cab..2644185a 100644 --- a/src/metobs_toolkit/qc_collection/step_check.py +++ b/src/metobs_toolkit/qc_collection/step_check.py @@ -3,7 +3,9 @@ import pandas as pd from .whitelist import SensorWhiteSet +from .common_functions import create_qcresult_flags from metobs_toolkit.backend_collection.decorators import log_entry +from metobs_toolkit.qcresult import QCresult logger = logging.getLogger("") @@ -14,7 +16,7 @@ def step_check( max_increase_per_second: Union[int, float], max_decrease_per_second: Union[int, float], sensorwhiteset: SensorWhiteSet, -) -> pd.DatetimeIndex: +) -> QCresult: """ Check for 'spikes' and 'dips' in a time series. @@ -35,15 +37,16 @@ def step_check( max_decrease_per_second : int or float The maximum allowed decrease (per second). This value is extrapolated to the time resolution of records. This value must be negative. - sensorwhiteset : SensorWhiteSet, optional + sensorwhiteset : SensorWhiteSet A SensorWhiteSet instance containing timestamps that should be excluded from outlier detection. Records matching the whiteset criteria will not be flagged as outliers even if they meet the step check criteria. Returns ------- - pd.DatetimeIndex - Timestamps of outlier records. + QCresult + Quality control result object containing flags, outliers, and details + for the step check. Notes ----- @@ -59,22 +62,22 @@ def step_check( raise ValueError("max_increase_per_second must be positive!") # Drop outliers from the series (these are NaNs) - input_series = records.dropna() + to_check_records = records.dropna() # Calculate timedelta between rows - time_diff = input_series.index.to_series().diff() + time_diff = to_check_records.index.to_series().diff() # Define filter step_filter = ( # Step increase ( - (input_series - input_series.shift(1)) + (to_check_records - to_check_records.shift(1)) > (float(max_increase_per_second) * time_diff.dt.total_seconds()) ) # or | # Step decrease ( - (input_series - input_series.shift(1)) + (to_check_records - to_check_records.shift(1)) < (max_decrease_per_second * time_diff.dt.total_seconds()) ) ) @@ -82,7 +85,31 @@ def step_check( outliers_idx = step_filter[step_filter].index # Catch the white records - outliers_idx = sensorwhiteset.catch_white_records(outliers_idx) + outliers_after_white_idx = sensorwhiteset.catch_white_records(outliers_idx) - logger.debug("Exiting function step_check") - return outliers_idx + flags = create_qcresult_flags( + all_input_records=records, + unmet_cond_idx = pd.DatetimeIndex([]), + outliers_before_white_idx=outliers_idx, + outliers_after_white_idx=outliers_after_white_idx, + ) + + + qcresult = QCresult( + checkname="step", + checksettings=locals().pop('records', None), + flags=flags, + outliers = records.loc[outliers_after_white_idx], + detail='no details' + ) + + #Create and add details + if not outliers_after_white_idx.empty: + detailseries = pd.Series( + data = 'step >' + str(max_increase_per_second) + ' per second or step <' + str(max_decrease_per_second) + ' per second', + index = outliers_after_white_idx + ) + qcresult.add_details_by_series(detail_series = detailseries) + return qcresult + + \ No newline at end of file diff --git a/src/metobs_toolkit/qc_collection/window_variation_check.py b/src/metobs_toolkit/qc_collection/window_variation_check.py index 93f36b1c..c8829b00 100644 --- a/src/metobs_toolkit/qc_collection/window_variation_check.py +++ b/src/metobs_toolkit/qc_collection/window_variation_check.py @@ -1,9 +1,11 @@ import logging from typing import Union import pandas as pd +from numpy import nan -from .common_functions import test_moving_window_condition +from .common_functions import test_moving_window_condition, create_qcresult_flags from .whitelist import SensorWhiteSet +from metobs_toolkit.qcresult import QCresult from metobs_toolkit.backend_collection.decorators import log_entry from metobs_toolkit.backend_collection.datetime_collection import ( timestamps_to_datetimeindex, @@ -20,11 +22,11 @@ def window_variation_check( max_increase_per_second: Union[int, float], max_decrease_per_second: Union[int, float], sensorwhiteset: SensorWhiteSet, -) -> pd.DatetimeIndex: +) -> QCresult: """ Test if the increase or decrease in a time window exceeds a threshold. - This function checks if the variation of observations in time does not exceed a threshold. + This function checks if the variation of observations in time does exceeds a threshold. It applies a moving window over the time series, defined by a duration (`timewindow`), and tests if the window contains at least a minimum number of records (`min_records_per_window`). @@ -74,9 +76,12 @@ def window_variation_check( if max_increase_per_second < 0: raise ValueError("max_increase_per_second must be positive!") + # Drop outliers from the series (these are NaNs) + to_check_records = records.dropna() + # Test if the conditions for the moving window are met by the records frequency is_met = test_moving_window_condition( - records=records, + records=records, #pass records, because freq is estimated windowsize=timewindow, min_records_per_window=min_records_per_window, ) @@ -84,10 +89,25 @@ def window_variation_check( logger.warning( "The minimum number of window members for the window variation check is not met!" ) - return timestamps_to_datetimeindex(timestamps=[], name="datetime") + flags = create_qcresult_flags( + all_input_records=records, + unmet_cond_idx=to_check_records.index, + outliers_before_white_idx=pd.DatetimeIndex([]), + outliers_after_white_idx=pd.DatetimeIndex([]), + ) + + qcresult = QCresult( + checkname="window_variation", + checksettings=locals().pop('records', None), + flags=flags, + outliers=timestamps_to_datetimeindex( + name="datetime", timestamps=[], current_tz=None + ), + detail="Minimum number of records per window not met.", + ) + return qcresult - # Drop outliers from the series (these are NaNs) - input_series = records.dropna() + # Calculate window thresholds (by linear extrapolation) max_window_increase = ( @@ -126,17 +146,45 @@ def variation_test(window: pd.Series) -> int: return 0 # Apply rolling window - window_outliers = input_series.rolling( + window_flags = to_check_records.rolling( window=timewindow, closed="both", center=True, min_periods=min_records_per_window, ).apply(variation_test) - outliers_idx = window_outliers.loc[window_outliers == 1].index - + # The returns are numeric values (0 --> oke, NaN --> not checked (members/window condition not met), 1 --> outlier) + window_flags = window_flags.map( + {0.0: 'pass', #Dummy label + nan: 'unmet', #Dummy label + 1.0: 'flagged'}) #Dummy label + + # Filter outliers + outliers_idx = window_flags.loc[window_flags == 'flagged'].index # Catch the white records - outliers_idx = sensorwhiteset.catch_white_records(outliers_idx=outliers_idx) - - logger.debug("Exiting function window_variation_check") - return outliers_idx + outliers_after_white_idx = sensorwhiteset.catch_white_records(outliers_idx=outliers_idx) + + #Create flags + flags = create_qcresult_flags( + all_input_records=records, + unmet_cond_idx=window_flags[window_flags == 'unmet'].index, + outliers_before_white_idx=outliers_idx, + outliers_after_white_idx=outliers_after_white_idx) + + qcresult = QCresult( + checkname="window_variation", + checksettings=locals().pop('records', None), + flags=flags, + outliers = records.loc[outliers_after_white_idx], + detail='no details' + ) + + #Create and add details + if not outliers_after_white_idx.empty: + detailseries = pd.Series( + data = f'Variation in {timewindow} window exceeds max increase of {max_window_increase} or max decrease of {max_window_decrease}.', + index = outliers_after_white_idx + ) + qcresult.add_details_by_series(detail_series = detailseries) + + return qcresult diff --git a/src/metobs_toolkit/qcresult.py b/src/metobs_toolkit/qcresult.py new file mode 100644 index 00000000..f35d6720 --- /dev/null +++ b/src/metobs_toolkit/qcresult.py @@ -0,0 +1,182 @@ +from __future__ import annotations +import logging +from typing import Literal, Union, TYPE_CHECKING + +import numpy as np +import pandas as pd + +from metobs_toolkit.backend_collection.decorators import log_entry +from metobs_toolkit.settings_collection.settings import Settings +logger = logging.getLogger("") + + +pass_cond = 'passed' #checked and successfull pass +flagged_cond = 'flagged' # checked and flagged as outlier +unmet_cond = 'condition_unmet' #not checked due to unmet specific conditions +saved_cond = 'saved' #checked and flagged but saved due to whitelist +unchecked_cond = 'unchecked' #not checked (was nan/gap before check) + +class QCresult: + """Store results of a quality control check. + + This class encapsulates the results of a single QC check including flags, + detected outliers, and detailed information about the check outcome for + all timestamps. + + Parameters + ---------- + checkname : str + Name identifying the quality control check (e.g., 'gross_value', 'persistence'). + checksettings : dict + Dictionary of parameters and settings used for this QC check. + flags : pd.Series + Series with datetime index containing QC flag strings for all timestamps: + 'passed', 'flagged', 'condition_unmet', 'saved', or 'unchecked'. + outliers : pd.Series + Series with datetime index containing outlier values. Index should be + a subset of the flags index. + detail : str, optional + Default detail string for all timestamps. Can be updated for specific + timestamps using add_details_by_series. Default is empty string. + + Attributes + ---------- + checkname : str + Name of the QC check. + checksettings : dict + Settings used for the check. + flags : pd.Series + QC flags for all timestamps. + outliers : pd.Series + Detected outlier values. + details : pd.Series + Detailed information for each timestamp. + """ + + def __init__( + self, + checkname: str, + checksettings: dict, + flags: pd.Series, # index: timestamps, values: 'passed', 'flagged', 'condition_unmet', 'saved' + outliers: pd.Series, # index: timestamps, values: outlier values + detail: str = "", + ): + self.checkname = checkname + self.checksettings = checksettings + + if not isinstance(flags.index, pd.DatetimeIndex): + raise TypeError("The index of 'flags' must be a pandas.DatetimeIndex.") + self.flags = flags + + + + if not isinstance(outliers.index, pd.DatetimeIndex): + raise TypeError("The index of 'outliers' must be a pandas.DatetimeIndex.") + self.outliers = outliers + + #Set details (Index is Flags thus includes all timestamps!) + self.details = pd.Series([detail] * len(flags), + index=flags.index) + + + def __repr__(self) -> str: + return f"QCresult(checkname={self.checkname})" + + + @log_entry + def add_details_by_series(self, detail_series: pd.Series) -> None: + """Update the details attribute with values from a detail_series. + + This method updates the details attribute (a pandas Series with datetime + index) using index-value pairs from the provided detail_series. The + detail_series index must be a subset of the details attribute index. + + Parameters + ---------- + detail_series : pd.Series + A pandas Series with datetime index containing detail values to + update. The index should be a subset of the details attribute index. + + Raises + ------ + TypeError + If detail_series is not a pandas Series or if its index is not a + pandas DatetimeIndex. + """ + + # Update details using the index-value pairs from detail_series + self.details.update(detail_series) + + def get_outlier_timestamps(self) -> pd.DatetimeIndex: + """Return the timestamps of the outliers.""" + return self.outliers.index + + + def remap_timestamps(self, mapping: dict) -> None: + """Remap the timestamps of flags, outliers, and details using a mapping dictionary. + + Parameters + ---------- + mapping : dict + A dictionary where keys are original timestamps and values are the + new timestamps to map to. + """ + self.flags.index = self.flags.index.map(lambda ts: mapping.get(ts, ts)) + self.outliers.index = self.outliers.index.map(lambda ts: mapping.get(ts, ts)) + self.details.index = self.details.index.map(lambda ts: mapping.get(ts, ts)) + + def _flags_to_labels_map(self) -> dict: + """Create mapping from QC flag values to display labels. + + Constructs a dictionary mapping internal flag values ('passed', 'flagged', etc.) + to user-facing label strings defined in Settings. Flagged records use the + check-specific label, while all other statuses use the 'goodrecord' label. + + Returns + ------- + dict + Mapping from flag strings to label strings. + """ + label_mapping = { + pass_cond: Settings.get('label_def.goodrecord.label'), + flagged_cond: Settings.get(f'label_def.{self.checkname}.label'), + unmet_cond: Settings.get('label_def.goodrecord.label'), + saved_cond: Settings.get('label_def.goodrecord.label'), + unchecked_cond: Settings.get('label_def.goodrecord.label') + } + return label_mapping + def create_outliersdf(self) -> pd.DataFrame: + """Create a DataFrame summarizing detected outliers. + + Constructs a DataFrame containing outlier values, their corresponding labels, + and detailed information for each outlier timestamp. This format is compatible + with the Dataset.outliersdf property. + + Returns + ------- + pd.DataFrame + DataFrame with datetime index and columns: + - 'value': outlier values + - 'label': human-readable QC check labels + - 'details': descriptive information about each outlier + Returns empty DataFrame with correct structure if no outliers exist. + """ + if self.outliers.empty: + # return empty dataframe + return pd.DataFrame( + columns=["value", "label", "details"], + index=pd.DatetimeIndex([], name="datetime"), + ) + + labels = self.flags.loc[self.outliers.index].map(self._flags_to_labels_map()) + + + outliers_df = pd.DataFrame({ + 'datetime': self.outliers.index, + 'value': self.outliers.values, + 'label': labels.values, + 'details': self.details.loc[self.outliers.index].values, + + }) + outliers_df.set_index('datetime', inplace=True) + return outliers_df \ No newline at end of file diff --git a/src/metobs_toolkit/sensordata.py b/src/metobs_toolkit/sensordata.py index 0718f57e..75faf957 100644 --- a/src/metobs_toolkit/sensordata.py +++ b/src/metobs_toolkit/sensordata.py @@ -33,6 +33,13 @@ MetObsAdditionError, MetObsInternalError, ) +from metobs_toolkit.qcresult import ( + QCresult, + pass_cond, + flagged_cond, + unmet_cond, + saved_cond, +) from metobs_toolkit.plot_collection import sensordata_simple_pd_plot import metobs_toolkit.backend_collection.printing_collection as printing @@ -108,7 +115,7 @@ def __init__( self.series = data # datetime as index # outliers - self.outliers = [] # List of {'checkname': ..., 'df': ....., 'settings': } + self.outliers = [] # List of QCresult # gaps self.gaps = [] # list of Gap's @@ -143,6 +150,7 @@ def __str__(self) -> str: """Return a string representation of the SensorData object.""" return f"{self.obstype.name} data of station {self.stationname}." + #TODO: update this method to handle QCresult outliers def __add__(self, other: "SensorData") -> "SensorData": """ Combine two SensorData objects for the same station and obstype. @@ -245,36 +253,14 @@ def df(self) -> pd.DataFrame: def to_xr(self) -> xrDataset: return sensordata_to_xr(self, fmt_datetime_coordinate=True) + #TODO: update this method to handle QCresult outliers @property def outliersdf(self) -> pd.DataFrame: """Return a DataFrame of the outlier records.""" logger.debug("Creating outliers DataFrame for %s", self.stationname) to_concat = [] - for outlierinfo in self.outliers: - checkname = outlierinfo["checkname"] - checkdf = outlierinfo["df"].copy() - checkdf["label"] = Settings.get(f"label_def.{checkname}.label") - - # Create details column from all columns except 'value' and 'label' - detail_cols = [ - col for col in checkdf.columns if col not in ["value", "label"] - ] - if detail_cols: - # Build details string for each row - details_list = [] - for _, row in checkdf.iterrows(): - parts = [ - f"{col}: {row[col]}" - for col in detail_cols - if pd.notna(row[col]) - ] - details_list.append(", ".join(parts)) - checkdf["details"] = details_list - # Drop the original detail columns - checkdf = checkdf.drop(columns=detail_cols) - else: - checkdf["details"] = "" - + for qcresult in self.outliers: + checkdf = qcresult.create_outliersdf() to_concat.append(checkdf) totaldf = save_concat(to_concat) @@ -285,8 +271,8 @@ def outliersdf(self) -> pd.DataFrame: columns=["value", "label", "details"], index=pd.DatetimeIndex([], name="datetime"), ) - else: - totaldf.sort_index(inplace=True) + + totaldf.sort_index(inplace=True) logger.debug("Outliers DataFrame created successfully for %s", self.stationname) return totaldf @@ -401,10 +387,15 @@ def _setup( self.duplicated_timestamp_check() if apply_invalid_check: - # invalid check - self.invalid_value_check( - skip_records=self.outliers[0]["df"].index - ) # skip the records already labeled as duplicates + # get the records that are flagged by the + if self.outliers[0].checkname =='duplicated_timestamp': + dup_outl_ti = self.outliers[0].get_outlier_timestamps() + else: + dup_outl_ti = pd.DatetimeIndex([]) + # invalid check (no qcresult, these timestamps are removed, and catched by gapcheck) + valid_records = qc.drop_invalid_values(records=self.series, + skip_records=dup_outl_ti) + self.series = valid_records if apply_unit_conv: # convert units to standard units @@ -427,16 +418,8 @@ def _setup( # update the outliers (replace the raw timestamps with the new) outl_datetime_map = timestamp_matcher.get_outlier_map() - for outlinfo in self.outliers: - outlinfo["df"]["new_datetime"] = outlinfo["df"].index.map(outl_datetime_map) - outlinfo["df"] = ( - outlinfo["df"] - .reset_index() - .rename( - columns={"datetime": "raw_timestamp", "new_datetime": "datetime"} - ) - .set_index("datetime") - ) + for qcresult in self.outliers: + qcresult.remap_timestamps(mapping=outl_datetime_map) # create gaps @@ -451,61 +434,73 @@ def _setup( missingrecords=timestamp_matcher.gap_records, target_freq=pd.to_timedelta(timestamp_matcher.target_freq), ) - - def _update_outliers( - self, - qccheckname: str, - outliertimestamps: pd.DatetimeIndex, - check_kwargs: dict, - extra_columns: dict = {}, - overwrite: bool = False, - ) -> None: - """ - Update the outliers attribute. - - Parameters - ---------- - qccheckname : str - Name of the quality control check. - outliertimestamps : pd.DatetimeIndex - Datetime index of the outliers. - check_kwargs : dict - Additional arguments for the check. - extra_columns : dict, optional - Extra columns to add to the outliers DataFrame, by default {}. - overwrite : bool, optional - Whether to overwrite existing outliers, by default False. - - Raises - ------ - MetobsQualityControlError - If the check is already applied and overwrite is False. - """ - logger.debug( - "Entering _update_outliers for %s with check %s", self, qccheckname - ) - - for applied_qc_info in self.outliers: - if qccheckname == applied_qc_info.keys(): - if overwrite: - self.outliers.remove(applied_qc_info) - else: - raise MetObsQualityControlError( - f"The {qccheckname} is already applied on {self}. Fix error or set overwrite=True" - ) - - outlier_values = self.series.loc[outliertimestamps] - outlier_values = outlier_values[~outlier_values.index.duplicated(keep="first")] - - datadict = {"value": outlier_values.to_numpy()} - datadict.update(extra_columns) - df = pd.DataFrame(data=datadict, index=outlier_values.index) - - self.outliers.append( - {"checkname": qccheckname, "df": df, "settings": check_kwargs} - ) - - self.series.loc[outliertimestamps] = np.nan + + def _update_outliers_NEW(self, + qcresult: QCresult, + overwrite: bool = False) -> None: + + #add the results to the outliers list + self.outliers.append(qcresult) + + #convert the outlier timestamps to NaN in the series + self.series.loc[qcresult.get_outlier_timestamps()] = np.nan + + + # #TODO: delete this method + # def _update_outliers( + # self, + # qccheckname: str, + # outliertimestamps: pd.DatetimeIndex, + # check_kwargs: dict, + # extra_columns: dict = {}, + # overwrite: bool = False, + # ) -> None: + # """ + # Update the outliers attribute. + + # Parameters + # ---------- + # qccheckname : str + # Name of the quality control check. + # outliertimestamps : pd.DatetimeIndex + # Datetime index of the outliers. + # check_kwargs : dict + # Additional arguments for the check. + # extra_columns : dict, optional + # Extra columns to add to the outliers DataFrame, by default {}. + # overwrite : bool, optional + # Whether to overwrite existing outliers, by default False. + + # Raises + # ------ + # MetobsQualityControlError + # If the check is already applied and overwrite is False. + # """ + # logger.debug( + # "Entering _update_outliers for %s with check %s", self, qccheckname + # ) + + # for applied_qc_info in self.outliers: + # if qccheckname == applied_qc_info.keys(): + # if overwrite: + # self.outliers.remove(applied_qc_info) + # else: + # raise MetObsQualityControlError( + # f"The {qccheckname} is already applied on {self}. Fix error or set overwrite=True" + # ) + + # outlier_values = self.series.loc[outliertimestamps] + # outlier_values = outlier_values[~outlier_values.index.duplicated(keep="first")] + + # datadict = {"value": outlier_values.to_numpy()} + # datadict.update(extra_columns) + # df = pd.DataFrame(data=datadict, index=outlier_values.index) + + # self.outliers.append( + # {"checkname": qccheckname, "df": df, "settings": check_kwargs} + # ) + + # self.series.loc[outliertimestamps] = np.nan def _find_gaps(self, missingrecords: pd.Series, target_freq: pd.Timedelta) -> list: """ @@ -553,6 +548,7 @@ def _rename(self, trgname: str) -> None: gap.name = str(trgname) @log_entry + #TODO: update this method to handle QCresult outliers def convert_outliers_to_gaps(self) -> None: """ Convert all outliers to gaps. @@ -646,19 +642,20 @@ def resample( # update the outliers (replace the raw timestamps with the new) outl_datetime_map = timestampmatcher.get_outlier_map() for outlinfo in self.outliers: - # add mapped timestamps - outlinfo["df"]["new_datetime"] = outlinfo["df"].index.map(outl_datetime_map) - # reformat the dataframe - outlinfo["df"] = ( - outlinfo["df"] - .reset_index() - .rename( - columns={"datetime": "raw_timestamp", "new_datetime": "datetime"} - ) - .set_index("datetime") - ) - # Drop references to NaT datetimes (when qc is applied before resampling) - outlinfo["df"] = outlinfo["df"].loc[outlinfo["df"].index.notnull()] + outlinfo.remap_timestamps(mapping=outl_datetime_map) + # # add mapped timestamps + # outlinfo["df"]["new_datetime"] = outlinfo["df"].index.map(outl_datetime_map) + # # reformat the dataframe + # outlinfo["df"] = ( + # outlinfo["df"] + # .reset_index() + # .rename( + # columns={"datetime": "raw_timestamp", "new_datetime": "datetime"} + # ) + # .set_index("datetime") + # ) + # # Drop references to NaT datetimes (when qc is applied before resampling) + # outlinfo["df"] = outlinfo["df"].loc[outlinfo["df"].index.notnull()] # create gaps orig_gapsdf = self.gapsdf @@ -784,49 +781,7 @@ def pd_plot(self, show_labels: list = ["ok"], **pdplotkwargs) -> Axes: # Quality Control (technical qc + value-based qc) # ------------------------------------------ - @log_entry - def invalid_value_check(self, skip_records: pd.DatetimeIndex) -> None: - """ - Check for invalid values in the series. - - Invalid values are those that could not be cast to numeric. - - Parameters - ---------- - skip_records : pd.DatetimeIndex - Records to skip during the check. - - Raises - ------ - MetobsQualityControlError - If the check is already applied. - """ - - skipped_data = self.series.loc[skip_records] - targets = self.series.drop(skip_records) - - # Option 1: Create a outlier label for these invalid inputs, - # and treath them as outliers - # outlier_timestamps = targets[~targets.notnull()] - - # self._update_outliers( - # qccheckname="invalid_input", - # outliertimestamps=outlier_timestamps.index, - # check_kwargs={}, - # extra_columns={}, - # overwrite=False, - # ) - - # Option 2: Since there is not numeric value present, these timestamps are - # interpreted as gaps --> remove the timestamp, so that it is captured by the - # gap finder. - - # Note: do not treat the first/last timestamps differently. That is - # a philosiphycal choice. - - self.series = targets[targets.notnull()] # subset to numerical casted values - # add the skipped records back - self.series = pd.concat([self.series, skipped_data]).sort_index() + @log_entry def duplicated_timestamp_check(self) -> None: @@ -838,22 +793,17 @@ def duplicated_timestamp_check(self) -> None: MetobsQualityControlError If the check is already applied. """ - - duplicates = pd.Series( - data=self.series.index.duplicated(keep=False), index=self.series.index - ) - duplicates = duplicates.loc[duplicates] - duplicates = duplicates[duplicates.index.duplicated(keep="first")] - - self._update_outliers( - qccheckname="duplicated_timestamp", - outliertimestamps=duplicates.index, - check_kwargs={}, - extra_columns={}, - overwrite=False, - ) - + qcresult = qc.duplicated_timestamp_check(records=self.series) + + #drop duplicates for the series, keep only the first occurrence + #(These values will then be put to Nan in the update) self.series = self.series[~self.series.index.duplicated(keep="first")] + + #Update the outliers + self._update_outliers_NEW(qcresult=qcresult, overwrite=False) + + + @log_entry def gross_value_check(self, **qckwargs) -> None: @@ -866,12 +816,9 @@ def gross_value_check(self, **qckwargs) -> None: Additional keyword arguments for the check. """ - outlier_timestamps = qc.gross_value_check(records=self.series, **qckwargs) - self._update_outliers( - qccheckname="gross_value", - outliertimestamps=outlier_timestamps, - check_kwargs={**qckwargs}, - extra_columns={}, + qcresult = qc.gross_value_check(records=self.series, **qckwargs) + self._update_outliers_NEW( + qcresult=qcresult, overwrite=False, ) @@ -886,13 +833,9 @@ def persistence_check(self, **qckwargs) -> None: Additional keyword arguments for the check. """ - outlier_timestamps = qc.persistence_check(records=self.series, **qckwargs) - - self._update_outliers( - qccheckname="persistence", - outliertimestamps=outlier_timestamps, - check_kwargs={**qckwargs}, - extra_columns={}, + qcresult = qc.persistence_check(records=self.series, **qckwargs) + self._update_outliers_NEW( + qcresult=qcresult, overwrite=False, ) @@ -907,13 +850,9 @@ def repetitions_check(self, **qckwargs) -> None: Additional keyword arguments for the check. """ - outlier_timestamps = qc.repetitions_check(records=self.series, **qckwargs) - - self._update_outliers( - qccheckname="repetitions", - outliertimestamps=outlier_timestamps, - check_kwargs={**qckwargs}, - extra_columns={}, + qcresult = qc.repetitions_check(records=self.series, **qckwargs) + self._update_outliers_NEW( + qcresult=qcresult, overwrite=False, ) @@ -928,13 +867,9 @@ def step_check(self, **qckwargs) -> None: Additional keyword arguments for the check. """ - outlier_timestamps = qc.step_check(records=self.series, **qckwargs) - - self._update_outliers( - qccheckname="step", - outliertimestamps=outlier_timestamps, - check_kwargs={**qckwargs}, - extra_columns={}, + qcresult = qc.step_check(records=self.series, **qckwargs) + self._update_outliers_NEW( + qcresult=qcresult, overwrite=False, ) @@ -949,13 +884,9 @@ def window_variation_check(self, **qckwargs) -> None: Additional keyword arguments for the check. """ - outlier_timestamps = qc.window_variation_check(records=self.series, **qckwargs) - - self._update_outliers( - qccheckname="window_variation", - outliertimestamps=outlier_timestamps, - check_kwargs={**qckwargs}, - extra_columns={}, + qcresult = qc.window_variation_check(records=self.series, **qckwargs) + self._update_outliers_NEW( + qcresult=qcresult, overwrite=False, ) From fff82124936949f78553546f9b7d905356ae9d0e Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Tue, 13 Jan 2026 14:21:32 +0100 Subject: [PATCH 04/57] Refactor the buddy check over multiple modules and catch logs using a wrapped Station class --- src/metobs_toolkit/dataset.py | 135 +- src/metobs_toolkit/qc_collection/__init__.py | 4 +- .../qc_collection/buddy_check.py | 1127 ----------------- .../spatial_checks/buddy_check.py | 390 ++++++ .../spatial_checks/buddywrapstation.py | 800 ++++++++++++ .../spatial_checks/methods/__init__.py | 7 + .../spatial_checks/methods/findbuddies.py | 79 ++ .../methods/lapsratecorrection.py | 45 + .../spatial_checks/methods/pdmethods.py | 137 ++ .../spatial_checks/methods/safetynets.py | 179 +++ .../spatial_checks/methods/samplechecks.py | 297 +++++ .../spatial_checks/methods/whitesaving.py | 134 ++ 12 files changed, 2087 insertions(+), 1247 deletions(-) delete mode 100644 src/metobs_toolkit/qc_collection/buddy_check.py create mode 100644 src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py create mode 100644 src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py create mode 100644 src/metobs_toolkit/qc_collection/spatial_checks/methods/__init__.py create mode 100644 src/metobs_toolkit/qc_collection/spatial_checks/methods/findbuddies.py create mode 100644 src/metobs_toolkit/qc_collection/spatial_checks/methods/lapsratecorrection.py create mode 100644 src/metobs_toolkit/qc_collection/spatial_checks/methods/pdmethods.py create mode 100644 src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py create mode 100644 src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py create mode 100644 src/metobs_toolkit/qc_collection/spatial_checks/methods/whitesaving.py diff --git a/src/metobs_toolkit/dataset.py b/src/metobs_toolkit/dataset.py index 92d5445b..6f622355 100644 --- a/src/metobs_toolkit/dataset.py +++ b/src/metobs_toolkit/dataset.py @@ -2068,19 +2068,17 @@ def buddy_check( * The altitude of the stations can be extracted from GEE by using the `Dataset.get_altitude()` method. * White-listed records from the WhiteSet participate in all buddy check calculations but are not flagged as outliers in the final results. - """ - - instantaneous_tolerance = fmt_timedelta_arg(instantaneous_tolerance) - if (lapserate is not None) | (max_alt_diff is not None): - if not all([sta.site.flag_has_altitude() for sta in self.stations]): - raise MetObsMetadataNotFound( - "Not all stations have altitude data, lapserate correction and max_alt_diff filtering could not be applied." - ) + See Also + -------- + buddy_check_with_safetynets : Buddy check with configurable safety nets. - qc_kwargs = dict( + """ + # Delegate to buddy_check_with_safetynets with no safety nets + self.buddy_check_with_safetynets( obstype=obstype, spatial_buddy_radius=spatial_buddy_radius, - spatial_min_sample_size=min_sample_size, + safety_net_configs=None, + min_sample_size=min_sample_size, max_alt_diff=max_alt_diff, min_std=min_std, spatial_z_threshold=spatial_z_threshold, @@ -2088,69 +2086,9 @@ def buddy_check( instantaneous_tolerance=instantaneous_tolerance, lapserate=lapserate, whiteset=whiteset, - safety_net_configs=None, # No safety nets for basic buddy_check - # technical use_mp=use_mp, ) - # Locate stations with the obstype - target_stations, skip_stations = filter_to_stations_with_target_obstype( - stations=self.stations, obstype=obstype - ) - metadf = self.metadf.loc[[sta.name for sta in target_stations]] - - outlierslist, timestamp_map = toolkit_buddy_check( - target_stations=target_stations, metadf=metadf, **qc_kwargs - ) - # outlierslist is a list of tuples (stationname, datetime, msg) that are outliers - # timestamp_map is a dict with keys the stationname and values a series to map the syncronized - # timestamps to the original timestamps - - # convert to a dataframe - alloutliersdf = pd.DataFrame( - data=outlierslist, columns=["name", "datetime", "detail_msg"] - ) - - # Handle duplicates - # Note: duplicates can occur when a specific record was part of more than one - # outlier group, and is flagged by more than one group. If so, keep the - # first row, but concat the detail_msg's (since they describe the outlier group) - - if not alloutliersdf.empty: - # Group by name and datetime, concatenate detail_msg for duplicates - alloutliersdf = ( - alloutliersdf.groupby(["name", "datetime"], as_index=False) - .agg({"detail_msg": lambda x: " | ".join(x)}) - .reset_index(drop=True) - ) - - # update all the sensordata - for station in target_stations: - # Get the sensordata object - sensorddata = station.get_sensor(obstype) - - # get outlier datetimeindex - outldt = pd.DatetimeIndex( - alloutliersdf[alloutliersdf["name"] == station.name]["datetime"] - ) - - if not outldt.empty: - # convert to original timestamps - dtmap = timestamp_map[station.name] - outldt = outldt.map(dtmap) - - # update the sensordata - sensorddata._update_outliers( - qccheckname="buddy_check", - outliertimestamps=outldt, - check_kwargs=qc_kwargs, - extra_columns={ - "detail_msg": alloutliersdf[alloutliersdf["name"] == station.name][ - "detail_msg" - ].to_numpy() - }, - ) - @log_entry def buddy_check_with_LCZ_safety_net(*args): raise DeprecationWarning( @@ -2361,9 +2299,11 @@ def buddy_check_with_safetynets( """ instantaneous_tolerance = fmt_timedelta_arg(instantaneous_tolerance) - + # Validate that the required metadata columns exist if safety_net_configs: + if not isinstance(safety_net_configs, list): + raise TypeError("safety_net_configs must be a list of dicts.") required_categories = set(cfg["category"] for cfg in safety_net_configs) for category in required_categories: if category == "LCZ": @@ -2409,58 +2349,15 @@ def buddy_check_with_safetynets( ) metadf = self.metadf.loc[[sta.name for sta in target_stations]] - outlierslist, timestamp_map = toolkit_buddy_check( + qcresuldict = toolkit_buddy_check( target_stations=target_stations, metadf=metadf, **qc_kwargs ) - # outlierslist is a list of tuples (stationname, datetime, msg) that are outliers - # timestamp_map is a dict with keys the stationname and values a series to map the syncronized - # timestamps to the original timestamps - - # convert to a dataframe - alloutliersdf = pd.DataFrame( - data=outlierslist, columns=["name", "datetime", "detail_msg"] - ) - - # Handle duplicates - # Note: duplicates can occur when a specific record was part of more than one - # outlier group, and is flagged by more than one group. If so, keep the - # first row, but concat the detail_msg's (since they describe the outlier group) - - if not alloutliersdf.empty: - # Group by name and datetime, concatenate detail_msg for duplicates - alloutliersdf = ( - alloutliersdf.groupby(["name", "datetime"], as_index=False) - .agg({"detail_msg": lambda x: " | ".join(x)}) - .reset_index(drop=True) - ) - - # update all the sensordata - for station in target_stations: - # Get the sensordata object - sensorddata = station.get_sensor(obstype) + for staname, qcres in qcresuldict.items(): + sensordata = self.get_station(staname).get_sensor(obstype) + sensordata._update_outliers_NEW(qcresult=qcres, overwrite=False) + - # get outlier datetimeindex - outldt = pd.DatetimeIndex( - alloutliersdf[alloutliersdf["name"] == station.name]["datetime"] - ) - - if not outldt.empty: - # convert to original timestamps - dtmap = timestamp_map[station.name] - outldt = outldt.map(dtmap) - - # update the sensordata - sensorddata._update_outliers( - qccheckname="buddy_check_with_safetynets", - outliertimestamps=outldt, - check_kwargs=qc_kwargs, - extra_columns={ - "detail_msg": alloutliersdf[alloutliersdf["name"] == station.name][ - "detail_msg" - ].to_numpy() - }, - ) @copy_doc(Station.get_qc_stats) @log_entry diff --git a/src/metobs_toolkit/qc_collection/__init__.py b/src/metobs_toolkit/qc_collection/__init__.py index 5e668ee7..1752d14e 100644 --- a/src/metobs_toolkit/qc_collection/__init__.py +++ b/src/metobs_toolkit/qc_collection/__init__.py @@ -1,8 +1,10 @@ # flake8: noqa: F401 +from .duplicated_timestamp import duplicated_timestamp_check +from .invalid_check import drop_invalid_values from .grossvalue_check import gross_value_check from .persistence_check import persistence_check from .repetitions_check import repetitions_check from .step_check import step_check from .window_variation_check import window_variation_check -from .buddy_check import toolkit_buddy_check +from .spatial_checks.buddy_check import toolkit_buddy_check diff --git a/src/metobs_toolkit/qc_collection/buddy_check.py b/src/metobs_toolkit/qc_collection/buddy_check.py deleted file mode 100644 index 3f3d52c2..00000000 --- a/src/metobs_toolkit/qc_collection/buddy_check.py +++ /dev/null @@ -1,1127 +0,0 @@ -from __future__ import annotations - -import os -import logging -import concurrent.futures -from typing import Union, List, Dict, Tuple, TYPE_CHECKING - -import numpy as np -import pandas as pd - -from metobs_toolkit.backend_collection.datetime_collection import to_timedelta -from metobs_toolkit.backend_collection.decorators import log_entry -from metobs_toolkit.qc_collection.distancematrix_func import generate_distance_matrix -from .whitelist import WhiteSet - -if TYPE_CHECKING: - from metobs_toolkit.station import Station - -logger = logging.getLogger("") - - -@log_entry -def synchronize_series( - series_list: List[pd.Series], max_shift: pd.Timedelta -) -> Tuple[pd.DataFrame, Dict]: - """ - Synchronize a list of pandas Series with datetime indexes. - - The target timestamps are defined by: - - - * freq: the highest frequency present in the input series - * origin: the earliest timestamp found, rounded down by the freq - * closing: the latest timestamp found, rounded up by the freq. - - Parameters - ---------- - series_list : list of pandas.Series - List of pandas Series with datetime indexes. - max_shift : pandas.Timedelta - Maximum shift in time that can be applied to each timestamp - in synchronization. - - Returns - ------- - pandas.DataFrame - DataFrame with synchronized Series. - dict - Dictionary mapping each synchronized timestamp to its - original timestamp. - """ - - # find highest frequency - frequencies = [to_timedelta(s.index.inferred_freq) for s in series_list] - trg_freq = min(frequencies) - - # find origin and closing timestamp (earliest/latest) - origin = min([s.index.min() for s in series_list]).floor(trg_freq) - closing = max([s.index.max() for s in series_list]).ceil(trg_freq) - - # Create target datetime axes - target_dt = pd.date_range(start=origin, end=closing, freq=trg_freq) - - # Synchronize (merge with tolerance) series to the common index - synchronized_series = [] - timestamp_mapping = {} - for s in series_list: - targetdf = ( - s.to_frame() - .assign(orig_datetime=s.index) - .reindex( - index=pd.DatetimeIndex(target_dt), - method="nearest", - tolerance=max_shift, - limit=1, - ) - ) - - # extract the mapping (new -> original) - orig_timestampseries = targetdf["orig_datetime"] - orig_timestampseries.name = "original_timestamp" - timestamp_mapping[s.name] = orig_timestampseries - - synchronized_series.append(s) - - return pd.concat(synchronized_series, axis=1), timestamp_mapping - - -def _validate_safety_net_configs(safety_net_configs: List[Dict]) -> None: - """ - Validate that all required keys are present in safety_net_configs. - - Parameters - ---------- - safety_net_configs : list of dict - List of safety net configuration dictionaries. - - Raises - ------ - ValueError - If safety_net_configs is not a list or contains non-dict elements. - KeyError - If any required key is missing from a safety net configuration. - """ - if safety_net_configs is None: - return None - - required_keys = {"category", "buddy_radius", "z_threshold", "min_sample_size"} - - if not isinstance(safety_net_configs, list): - raise ValueError( - f"safety_net_configs must be a list, got {type(safety_net_configs).__name__}" - ) - - for i, config in enumerate(safety_net_configs): - if not isinstance(config, dict): - raise ValueError( - f"Each safety net config must be a dict, but config at index {i} " - f"is {type(config).__name__}" - ) - - missing_keys = required_keys - set(config.keys()) - if missing_keys: - raise KeyError( - f"Safety net config at index {i} is missing required key(s): " - f"{', '.join(sorted(missing_keys))}. " - f"Required keys are: {', '.join(sorted(required_keys))}" - ) - - -def _find_buddies_by_distance( - distance_df: pd.DataFrame, buddy_radius: Union[int, float] -) -> Dict: - """ - Get neighbouring stations using buddy radius (distance only). - - This is the core distance-based buddy finding function used internally - by other buddy-finding functions. - - Parameters - ---------- - distance_df : pandas.DataFrame - DataFrame containing distances between stations. - buddy_radius : int or float - Maximum distance (in meters) to consider as a buddy. - - Returns - ------- - dict - Dictionary mapping each station to a list of its buddies within the radius. - """ - - buddies = {} - for refstation, distances in distance_df.iterrows(): - bud_stations = distances[distances <= buddy_radius].index.to_list() - bud_stations.remove(refstation) - buddies[refstation] = bud_stations - - return buddies - - -def _find_category_buddies( - metadf: pd.DataFrame, - category_column: str, - max_dist: Union[int, float], - distance_df: pd.DataFrame, -) -> Dict: - """ - Get neighbouring stations using a categorical column and spatial distance. - - This function identifies buddy stations that share the same categorical - value (e.g., LCZ, network, region) and are within a specified distance. - - Parameters - ---------- - metadf : pandas.DataFrame - DataFrame containing metadata for stations. Must include the specified - category column. - category_column : str - The name of the categorical column to group stations by (e.g., 'LCZ', - 'network', 'region'). - max_dist : int or float - Maximum distance (in meters) to consider as a category buddy. - distance_df : pandas.DataFrame - DataFrame containing distances between stations. - - Returns - ------- - dict - Dictionary mapping each station to a list of its category buddies that - are also within the specified distance. - - Notes - ----- - - Category buddies are stations with the same category value as the reference - station. - - The final buddies are the intersection of category buddies and spatial - buddies within `max_dist`. - - Stations with NaN values in the category column are handled: they will not - match with any other station (including other NaN stations). - """ - category_buddies = {} - # Find buddies by category - for refstation in metadf.index: - ref_category = metadf.loc[refstation, category_column] - # Handle NaN values - they should not match with anything - if pd.isna(ref_category): - logger.warning( - "Station %s has NaN value for category '%s' - no category buddies assigned", - refstation, - category_column, - ) - category_buddies[refstation] = [] - else: - ref_buddies = metadf.loc[ - metadf[category_column] == ref_category - ].index.to_list() - category_buddies[refstation] = ref_buddies - - # Find buddies by distance - spatial_buddies = _find_buddies_by_distance(distance_df, max_dist) - - # Intersection of both buddy definitions - final_buddies = {} - for refstation in category_buddies.keys(): - final_buddies[refstation] = list( - set(category_buddies[refstation]).intersection( - set(spatial_buddies[refstation]) - ) - ) - - return final_buddies - - -def _find_spatial_buddies( - distance_df: pd.DataFrame, - metadf: pd.DataFrame, - buddy_radius: Union[int, float], -) -> Dict: - """ - Get neighbouring stations using buddy radius (spatial distance only). - - This function is a wrapper around `_find_category_buddies` that finds - buddies based purely on spatial distance, without any categorical - constraints. It works by creating a dummy category column where all - stations have the same value. - - Parameters - ---------- - distance_df : pandas.DataFrame - DataFrame containing distances between stations. - metadf : pandas.DataFrame - DataFrame containing metadata for stations. The index should be - station names. - buddy_radius : int or float - Maximum distance (in meters) to consider as a buddy. - - Returns - ------- - dict - Dictionary mapping each station to a list of its buddies within - the specified radius. - - See Also - -------- - _find_category_buddies : Find buddies by category and distance. - _find_buddies_by_distance : Core distance-based buddy finding function. - """ - - # Create a temporary metadf with a dummy category column where all - # stations have the same value, so _find_category_buddies will not - # filter by category - temp_metadf = metadf.copy() - temp_metadf["_all_same_category"] = 1 - - return _find_category_buddies( - metadf=temp_metadf, - category_column="_all_same_category", - max_dist=buddy_radius, - distance_df=distance_df, - ) - - -def _filter_to_altitude_buddies( - buddies: Dict, altitudes: pd.Series, max_altitude_diff: Union[int, float] -) -> Dict: - """ - Filter neighbours by maximum altitude difference. - - Parameters - ---------- - buddies : dict - Dictionary mapping each station to a list of its spatial buddies. - altitudes : pandas.Series - Series containing altitudes for each station. - max_altitude_diff : int or float - Maximum allowed altitude difference. - - Returns - ------- - dict - Dictionary mapping each station to a list of altitude-filtered buddies. - """ - - alt_buddies_dict = {} - for refstation, buddylist in buddies.items(): - alt_diff = abs((altitudes.loc[buddylist]) - altitudes.loc[refstation]) - - alt_buddies = alt_diff[alt_diff <= max_altitude_diff].index.to_list() - alt_buddies_dict[refstation] = alt_buddies - return alt_buddies_dict - - -def _filter_to_minimum_samplesize(buddydict: Dict, min_sample_size: int) -> Dict: - """ - Filter stations that are too isolated using minimum sample size. - - Parameters - ---------- - buddydict : dict - Dictionary mapping each station to a list of its buddies. - min_sample_size : int - Minimum number of buddies required. - - Returns - ------- - dict - Dictionary mapping each station to a list of buddies meeting the - minimum sample size. - """ - - to_check_stations = {} - for refstation, buddies in buddydict.items(): - if len(buddies) < min_sample_size: - # not enough buddies - to_check_stations[refstation] = [] # remove buddies - else: - to_check_stations[refstation] = buddies - return to_check_stations - - -@log_entry -def create_groups_of_buddies(buddydict: Dict) -> List[Tuple]: - """ - Create unique groups of buddies from a buddy dictionary. - - Parameters - ---------- - buddydict : dict - Dictionary mapping each station to a list of its buddies. - - Returns - ------- - list of tuple - List of tuples, each containing a group of station names. - """ - - grouped_stations = [] - groups = [] - for refstation, buddies in buddydict.items(): - if not bool(buddies): - continue - if refstation in grouped_stations: - continue - group = tuple([refstation, *buddies]) - - grouped_stations.extend([*group]) - groups.append(group) - - return groups - - -@log_entry -def toolkit_buddy_check( - target_stations: list[Station], - metadf: pd.DataFrame, - obstype: str, - spatial_buddy_radius: Union[int, float], - spatial_min_sample_size: int, - max_alt_diff: Union[int, float, None], - min_std: Union[int, float], - spatial_z_threshold: Union[int, float], - N_iter: int, - instantaneous_tolerance: pd.Timedelta, - # Whitelist arguments - whiteset: WhiteSet, - # Safety nets - safety_net_configs: List[Dict] = None, - # Technical - lapserate: Union[float, None] = None, # -0.0065 for temperature - use_mp: bool = True, -) -> Tuple[list, dict]: - """ - Spatial buddy check. - - The buddy check compares an observation against its neighbors - (i.e. spatial buddies). The check loops over all the groups, which are stations - within a radius of each other. For each group, the z-value of the reference - observation is computed given the sample of spatial buddies. If one (or more) - exceeds the `spatial_z_threshold`, the most extreme (=baddest) observation of - that group is labeled as an outlier. - - Multiple iterations of this checks can be done using the N_iter. - - Optionally, one or more safety nets can be applied. A safety net tests - potential outliers against a sample of stations that share a categorical - attribute (e.g., LCZ, network). If the z-value computed using the safety - net sample is below the specified threshold, the outlier is "saved" and - removed from the outlier set for the current iteration. - - Safety nets are applied in the order they are specified, allowing for - multi-level filtering (e.g., first test against LCZ buddies, then against - network buddies). - - A schematic step-by-step description of the buddy check: - - #. A distance matrix is constructed for all interdistances between - the stations. This is done using the haversine approximation. - #. Groups of spatial buddies (neighbours) are created by using the - `spatial_buddy_radius.` These groups are further filtered by: - - * removing stations from the groups that differ to much in altitude - (based on the `max_alt_diff`) - * removing groups of buddies that are too small (based on the - `min_sample_size`) - - #. Observations per group are synchronized in time (using the - `instantaneous_tolerance` for allignment). - #. If a `lapsrate` is specified, the observations are corrected for - altitude differences. - #. The following steps are repeated for `N-iter` iterations: - - #. The values of outliers flagged by a previous iteration are converted to - NaN's. Therefore they are not used in any following score or sample. - #. For each buddy group: - - * The mean, standard deviation (std), and sample size are computed. - * If the std is lower than the `minimum_std`, it is replaced by the - minimum std. - * Chi values are calculated for all records. - * For each timestamp the record with the highest Chi is tested if - it is larger then spatial_z_threshold. - If so, that record is flagged as an outlier. It will be ignored - in the next iteration. - - #. If `safety_net_configs` is provided, the following steps are applied - on the outliers flagged by the current iteration, for each safety net - in order: - - * Category buddies (stations sharing the same category value within - the specified radius) are identified. - * The safety net sample is tested in size (sample size must be at - least `min_sample_size`). If the condition is not met, the safety - net test is not applied. - * The safety net test is applied: - - * The mean and std are computed of the category-buddy sample. If - the std is smaller than `min_std`, the latter is used. - * The z-value is computed for the target record (= flagged outlier). - * If the z-value is smaller than the safety net's `z_threshold`, - the tested outlier is "saved" and removed from the set of outliers - for the current iteration. - - #. If `whiteset` contains records, any outliers that match the white-listed - timestamps (and optionally station names) are removed from the outlier set - for the current iteration. White-listed records participate in all buddy - check calculations but are not flagged as outliers in the final results. - - Parameters - ---------- - target_stations : list[Station] - A list of Station objects to apply the buddy check on. These should be - stations that contain the target observation type. - metadf : pandas.DataFrame - DataFrame containing station metadata including coordinates (geometry) - and altitude information for all stations. - obstype : str - The observation type that has to be checked. - spatial_buddy_radius : int or float - The radius to define spatial neighbors in meters. - spatial_min_sample_size : int - The minimum sample size to calculate statistics on used by - spatial-buddy samples. - max_alt_diff : int, float, or None - The maximum altitude difference allowed for buddies. If None, - no altitude filter is applied. - min_std : int or float - The minimum standard deviation for sample statistics. This should - represent the accuracy of the observations. - spatial_z_threshold : int or float - The threshold, tested with z-scores, for flagging observations as outliers. - N_iter : int - The number of iterations to perform the buddy check. - instantaneous_tolerance : pandas.Timedelta - The maximum time difference allowed for synchronizing observations. - whiteset : WhiteSet - A WhiteSet instance containing records that should be excluded from - outlier detection. Records in the WhiteSet undergo the buddy check - iterations as regular records but are removed from the outlier set - at the end of each iteration. - safety_net_configs : list of dict, optional - List of safety net configurations to apply in order. Each dict must - contain: - - * 'category': str, the metadata column name to group by (e.g., 'LCZ', - 'network') - * 'buddy_radius': int or float, maximum distance for category buddies - (in meters) - * 'z_threshold': int or float, z-value threshold for saving outliers - * 'min_sample_size': int, minimum number of buddies required for the - safety net test - - The default is None. - lapserate : float or None, optional - Describes how the obstype changes with altitude (in meters). If - None, no altitude correction is applied. For temperature, a - common value is -0.0065. - use_mp : bool, optional - Use multiprocessing to speed up the buddy check. The default is True. - - Returns - ------- - list - A list of tuples containing the outlier station, timestamp, - and detail message. Each tuple is in the form (station_name, - timestamp, message). - dict - A dictionary mapping each synchronized timestamp to its original - timestamp. - - Notes - ----- - - * The altitude of the stations can be extracted from GEE by using the - `Dataset.get_altitude()` method. - * The LCZ of the stations can be extracted from GEE by using the - `Dataset.get_LCZ()` method. - - """ - - # Validate safety net configs if provided - _validate_safety_net_configs(safety_net_configs) - - # ----- Part 1: construct buddy groups ------ - # compute distance metric - logger.debug("Calculating distance matrix with Haversine formula") - dist_matrix = generate_distance_matrix(metadf) - - # find potential buddies by distance - logger.debug( - "Finding spatial buddies within radius of %s meters", spatial_buddy_radius - ) - spatial_buddies = _find_spatial_buddies( - distance_df=dist_matrix, metadf=metadf, buddy_radius=spatial_buddy_radius - ) - - # filter buddies by altitude difference - if max_alt_diff is not None: - logger.debug( - "Filtering buddies by maximum altitude difference of %s meters", - max_alt_diff, - ) - if metadf["altitude"].isna().any(): - raise ValueError( - "At least one station has a NaN \ -value for 'altitude'" - ) - # Filter by altitude difference - spatial_buddies = _filter_to_altitude_buddies( - buddies=spatial_buddies, - altitudes=metadf["altitude"], - max_altitude_diff=max_alt_diff, - ) - - # Filter by sample size (based on the number of buddy stations) - logger.debug( - "Filtering buddies by minimum sample size of %s", spatial_min_sample_size - ) - spatial_buddies = _filter_to_minimum_samplesize( - buddydict=spatial_buddies, min_sample_size=spatial_min_sample_size - ) - - # create unique groups of buddies (list of tuples) - logger.debug("Creating groups of buddies") - buddygroups = create_groups_of_buddies(spatial_buddies) - logger.debug("Number of buddy groups created: %s", len(buddygroups)) - - # ---- Part 2: Preparing the records ----- - - # construct a wide observation dataframe - logger.debug("Constructing wide observation DataFrame for obstype: %s", obstype) - concatlist = [] - for sta in target_stations: - if obstype in sta.sensordata.keys(): - records = sta.get_sensor(obstype).series - records.name = sta.name - concatlist.append(records) - - # synchronize the timestamps - logger.debug("Synchronizing timestamps") - combdf, timestamp_map = synchronize_series( - series_list=concatlist, max_shift=instantaneous_tolerance - ) - - # lapse rate correction - if lapserate is not None: - logger.debug("Applying lapse rate correction with rate: %s", lapserate) - # get altitude dataframe - altdict = {sta.name: sta.site.altitude for sta in target_stations} - altseries = pd.Series(altdict) - altcorrectionseries = (altseries - altseries.min()) * lapserate - combdf = combdf - altcorrectionseries # Correct for altitude - - # ---- Part 3 : Apply buddy check on each group, - # rejecting the most extreme outlier - - outliersbin = [] - for i in range(N_iter): - logger.debug("Starting iteration %s of %s", i + 1, N_iter) - # convert values to NaN, if they are labeled as outlier in - # previous iteration - if bool(outliersbin): - logger.debug("Converting previous-iteration outliers to NaN") - for outlier_station, outlier_time, _msg in outliersbin: - if outlier_station in combdf.columns: - combdf.loc[outlier_time, outlier_station] = np.nan - - if use_mp: - # Use multiprocessing generator (parallelization) - num_cpus = os.cpu_count() - # since this check is an instantaneous check --> - # perfect for splitting the dataset in chunks in time - chunks = np.array_split(combdf, num_cpus) - - # create inputargs for each buddygroup, and for each chunk in time - inputargs = [ - ( - buddygroup, - chunk, - spatial_min_sample_size, - min_std, - spatial_z_threshold, - ) - for buddygroup in buddygroups - for chunk in chunks - ] - - with concurrent.futures.ProcessPoolExecutor() as executor: - outliers = executor.map(find_buddy_group_outlier, inputargs) - outliers = list(outliers) - - else: - # create inputargs for each buddygroup, and for each chunk in time - inputargs = [ - ( - buddygroup, - combdf, - spatial_min_sample_size, - min_std, - spatial_z_threshold, - ) - for buddygroup in buddygroups - ] - - logger.debug("Finding outliers in each buddy group") - outliers = list(map(find_buddy_group_outlier, inputargs)) - - # unpack double nested list - outliers = [item for sublist in outliers for item in sublist] - - # Apply safety nets (if configured) - if safety_net_configs: - logger.debug( - "Applying %s safety net(s) to %s outliers", - len(safety_net_configs), - len(outliers), - ) - outliers = apply_safetynets( - outliers=outliers, - safety_net_configs=safety_net_configs, - wideobsds=combdf, - metadf=metadf, - distance_df=dist_matrix, - max_alt_diff=max_alt_diff, - min_std=min_std, - ) - # NOTE: Records saved by any safety net will be tested again in - # the following iteration. A different result can occur if the - # spatial/safety net sample changes in the next iteration. - - # Save white-listed records - outliers = save_whitelist_records( - outliers=outliers, - whiteset=whiteset, - obstype=obstype, - ) - # NOTE: The white-listed records are removed from the outliers at the end - # of each iteration, similar to the safety nets. They participate in - # the buddy check calculations but are not flagged as outliers. - - # Save white-listed records - outliers = save_whitelist_records( - outliers=outliers, - whiteset=whiteset, - obstype=obstype, - ) - # NOTE: The white-listed records are removed from the outliers at the end - # of each iteration, similar to the LCZ safety net. They participate in - # the buddy check calculations but are not flagged as outliers. - - # Add reference to the iteration in the msg of the outliers - outliers = [ - (station, timestamp, f"{msg} (iteration {i+1}/{N_iter})") - for station, timestamp, msg in outliers - ] - - outliersbin.extend(outliers) - i += 1 - - return outliersbin, timestamp_map - - -@log_entry -def apply_safety_net( - outliers: list, - category_buddies: dict, - wideobsds: pd.DataFrame, - safety_z_threshold: Union[int, float], - min_sample_size: int, - min_std: Union[int, float], - category_name: str, -) -> list: - """ - Apply a category-based safety net to outliers detected by the spatial buddy check. - - This function works with any categorical grouping (e.g., LCZ, network, region). - - For each outlier, this function checks whether the value can be "saved" by - comparison with its category buddies (stations with the same category value - and within a certain distance). If the outlier's value is within the specified - z-threshold when compared to its category buddies, it is not considered an - outlier for this iteration. - - Parameters - ---------- - outliers : list of tuple - List of detected outliers, each as a tuple (station_name, timestamp, message). - category_buddies : dict - Dictionary mapping each station to a list of its category buddies. - wideobsds : pandas.DataFrame - Wide-format DataFrame with stations as columns and timestamps as index. - safety_z_threshold : int or float - Z-value threshold for saving an outlier using the safety net. - min_sample_size : int - Minimum number of category buddies required to apply the safety net. - min_std : int or float - Minimum standard deviation to use for z-value calculation. - category_name : str - Name of the category being used (for logging and messages). - - Returns - ------- - list of tuple - List of outliers that were not saved by the safety net, each as a tuple - (station_name, timestamp, message). Outliers that are "saved" are not - included in the returned list. - - Notes - ----- - - The safety net is only applied if there are enough category buddies and - non-NaN values. - - Outliers from previous iterations are already set to NaN in `wideobsds` - and are not considered. - - The function appends a message to the outlier if the safety net is not - applied or not passed. - """ - checked_outliers = [] - for outl in outliers: - outlstation, outltimestamp, outl_msg = outl - - outl_value = wideobsds.loc[outltimestamp, outlstation] - outl_category_buddies = category_buddies.get(outlstation, []) - - # Check if sample size is sufficient - if len(outl_category_buddies) < min_sample_size: - msg = f"Too few {category_name} buddies to apply safety net ({len(outl_category_buddies)} < {min_sample_size})." - logger.debug( - "Skip %s safety net for %s: too few buddies (%s < %s).", - category_name, - outlstation, - len(outl_category_buddies), - min_sample_size, - ) - checked_outliers.append((outlstation, outltimestamp, outl_msg + msg)) - continue - - # Get safety net samples - # NOTE: The sample is constructed using wideobsds, thus outliers - # from the current iteration are not taken into account! - # Outliers from previous iterations are taken into account since - # wideobsdf is altered (NaNs placed at outlier records) at the beginning - # of each iteration. - safetynet_samples = wideobsds.loc[outltimestamp][outl_category_buddies] - - # Compute scores - sample_mean = safetynet_samples.mean() - sample_std = safetynet_samples.std() - sample_non_nan_count = safetynet_samples.notna().sum() - - # Instantaneous sample size check - if sample_non_nan_count < min_sample_size: - msg = f"Too few non-NaN {category_name} buddies ({sample_non_nan_count} < {min_sample_size})." - logger.debug( - "Skip %s safety net for %s: too few non-NaN buddies (%s < %s).", - category_name, - outlstation, - sample_non_nan_count, - min_sample_size, - ) - checked_outliers.append((outlstation, outltimestamp, outl_msg + msg)) - continue - - # Apply min std - if sample_std < min_std: - sample_std = min_std - - # Check if saved - z_value = abs((outl_value - sample_mean) / sample_std) - if z_value <= safety_z_threshold: - # Is saved - logger.debug( - "%s at %s is saved by %s safety net with z=%.2f.", - outlstation, - outltimestamp, - category_name, - z_value, - ) - # Do not append the current outl to checked (it's saved) - else: - # Not saved by the safety net - msg = f"{category_name} safety net applied but not saved (z={z_value:.2f} > {safety_z_threshold})." - checked_outliers.append((outlstation, outltimestamp, outl_msg + msg)) - continue - - n_saved = len(outliers) - len(checked_outliers) - logger.debug( - "A total of %s records are saved by the %s safety net.", n_saved, category_name - ) - return checked_outliers - - -@log_entry -def apply_safetynets( - outliers: list, - safety_net_configs: List[Dict], - wideobsds: pd.DataFrame, - metadf: pd.DataFrame, - distance_df: pd.DataFrame, - max_alt_diff: Union[int, float, None], - min_std: Union[int, float], -) -> list: - """ - Apply multiple safety nets in sequence to outliers. - - Each safety net is defined by a category column, buddy radius, z-threshold, - and minimum sample size. Outliers are tested against each safety net in order, - and if saved by any of them, they are removed from the outlier list. - - Parameters - ---------- - outliers : list of tuple - List of detected outliers, each as a tuple (station_name, timestamp, message). - safety_net_configs : list of dict - List of safety net configurations. Each dict must contain: - - 'category': str, the metadata column name to group by - - 'buddy_radius': int or float, maximum distance for category buddies - - 'z_threshold': int or float, z-value threshold for saving outliers - - 'min_sample_size': int, minimum number of buddies required - wideobsds : pandas.DataFrame - Wide-format DataFrame with stations as columns and timestamps as index. - metadf : pandas.DataFrame - DataFrame containing station metadata. - distance_df : pandas.DataFrame - DataFrame containing distances between stations. - max_alt_diff : int, float, or None - Maximum altitude difference allowed for buddies. If None, no altitude - filter is applied. - min_std : int or float - Minimum standard deviation for sample statistics. - - Returns - ------- - list of tuple - List of outliers that were not saved by any safety net. - """ - if not safety_net_configs: - return outliers - - current_outliers = outliers - - for config in safety_net_configs: - category = config["category"] - buddy_radius = config["buddy_radius"] - z_threshold = config["z_threshold"] - min_sample_size = config["min_sample_size"] - - logger.debug( - "Applying %s safety net (radius=%s, z_threshold=%s, min_sample=%s)", - category, - buddy_radius, - z_threshold, - min_sample_size, - ) - - # Find category buddies - category_buddies = _find_category_buddies( - metadf=metadf, - category_column=category, - max_dist=buddy_radius, - distance_df=distance_df, - ) - - # Filter by altitude difference if specified - if max_alt_diff is not None: - category_buddies = _filter_to_altitude_buddies( - buddies=category_buddies, - altitudes=metadf["altitude"], - max_altitude_diff=max_alt_diff, - ) - - # Apply the safety net - current_outliers = apply_safety_net( - outliers=current_outliers, - category_buddies=category_buddies, - wideobsds=wideobsds, - safety_z_threshold=z_threshold, - min_sample_size=min_sample_size, - min_std=min_std, - category_name=category, - ) - - return current_outliers - - -@log_entry -def save_whitelist_records( - outliers: list, - whiteset: WhiteSet, - obstype: str, -) -> list: - """Remove whitelisted records from the outlier list. - - This function filters out any outliers that are present in the WhiteSet. - Whitelisted records are known valid observations that should not be flagged - as outliers, even if they are detected by the buddy check. - - Parameters - ---------- - outliers : list of tuple - List of detected outliers, each as a tuple (station_name, timestamp, message). - whiteset : WhiteSet - A WhiteSet instance containing records that should be excluded from outlier - detection. The WhiteSet is converted to station-specific and obstype-specific - SensorWhiteSet instances for each station in the outliers list. - obstype : str - The observation type being checked. Used to filter the whiteset for the - target obstype. - - Returns - ------- - list of tuple - List of outliers excluding those that are whitelisted. Each tuple contains - (station_name, timestamp, message). - - Notes - ----- - * Whitelisted records undergo the buddy check iterations as if they are regular - records. - * Only at the end of each iteration are they filtered out from the outliers list. - * This allows whitelisted records to still influence the statistics of their - buddy groups. - * The function processes each station separately by creating a SensorWhiteSet - for each station-obstype combination. - """ - - outldf = pd.DataFrame(outliers, columns=["name", "datetime", "message"]) - - for outlsta in outldf["name"].unique(): - # Create a sensorwhiteset for each station - sensorwhiteset = whiteset.create_sensorwhitelist( - stationname=outlsta, obstype=obstype - ) - # get the white-listed datetimes for the station - outliers_dts = sensorwhiteset.catch_white_records( - outliers_idx=pd.DatetimeIndex( - data=outldf[outldf["name"] == outlsta]["datetime"], name="datetime" - ) - ) - - # subset to the saved outliers - outldf = outldf.drop( - outldf[ - (outldf["name"] == outlsta) & (~outldf["datetime"].isin(outliers_dts)) - ].index - ) - - # convert back to a list of tuples (name, datetime, message) - outliers = list(outldf.itertuples(index=False, name=None)) - return outliers - - -@log_entry -def find_buddy_group_outlier(inputarg: Tuple) -> List[Tuple]: - """ - Apply a buddy check on a group to identify outliers. - - Parameters - ---------- - inputarg : tuple - A tuple containing: - - * buddygroup : list - List of station names that form the buddy group. - * combdf : pandas.DataFrame - DataFrame containing the combined data for all stations. - * min_sample_size : int - Minimum number of non-NaN values required in the buddy group for a - valid comparison. - * min_std : float - Minimum standard deviation to use when calculating z-scores. - * outlier_threshold : float - Threshold for identifying outliers in terms of z-scores. - - Returns - ------- - list of tuple - Each tuple contains: - - * str : The station name of the most extreme outlier. - * pandas.Timestamp : The timestamp of the outlier. - * str : A detailed message describing the outlier. - - Notes - ----- - This function performs the following steps: - - 1. Subsets the data to the buddy group. - 2. Calculates the mean, standard deviation, and count of non-NaN values - for each timestamp. - 3. Filters out timestamps with insufficient data. - 4. Replaces standard deviations below the minimum threshold with the - minimum value. - 5. Converts station values to z-scores. - 6. Identifies timestamps with at least one outlier. - 7. Locates the most extreme outlier for each timestamp. - 8. Generates a detailed message for each outlier. - """ - - buddygroup, combdf = inputarg[0], inputarg[1] - min_sample_size, min_std, outlier_threshold = inputarg[2:] - - # subset to the buddies - buddydf = combdf[[*buddygroup]] - - # calculate std and mean row wise - buddydf["mean"] = buddydf[[*buddygroup]].mean(axis=1) - buddydf["std"] = buddydf[[*buddygroup]].std(axis=1) - buddydf["non_nan_count"] = buddydf[[*buddygroup]].notna().sum(axis=1) - - # subset to samples with enough members (check for each timestamp - - # specifically) - buddydf = buddydf.loc[buddydf["non_nan_count"] >= min_sample_size] - - # replace std by minimum, if needed - buddydf.loc[buddydf["std"] < min_std, "std"] = np.float32(min_std) - - # Convert values to sigmas - for station in buddygroup: - buddydf[station] = (buddydf[station] - buddydf["mean"]).abs() / buddydf["std"] - - # Drop rows for which all values are smaller than the threshold - # (speed up the last step) - buddydf["timestamp_with_outlier"] = buddydf[[*buddygroup]].apply( - lambda row: any(row > outlier_threshold), axis=1 - ) - buddydf = buddydf.loc[buddydf["timestamp_with_outlier"]] - - # locate the most extreme outlier per timestamp - buddydf["is_the_most_extreme_outlier"] = buddydf[[*buddygroup]].idxmax(axis=1) - - @log_entry - def msgcreator(row): - """ - Create a detailed message describing an outlier. - - Parameters - ---------- - row : pandas.Series - A row from the buddy DataFrame containing outlier information, - including 'is_the_most_extreme_outlier', 'mean', and 'std' columns. - - Returns - ------- - str - Formatted message describing the outlier with its z-score and - buddy group statistics. - """ - retstr = f"Outlier at {row['is_the_most_extreme_outlier']}" - retstr += f" with chi value \ -{row[row['is_the_most_extreme_outlier']]:.2f}," - retstr += ( - f" is part of {sorted(buddygroup)}, with mean: {row['mean']:.2f}, " - f"std: {row['std']:.2f}. " - ) - return retstr - - # detail info string - buddydf["detail_msg"] = buddydf.apply( - lambda row: msgcreator(row), axis=1, result_type="reduce" - ) - - return list( - zip( - buddydf["is_the_most_extreme_outlier"], buddydf.index, buddydf["detail_msg"] - ) - ) diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py new file mode 100644 index 00000000..15ebe171 --- /dev/null +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py @@ -0,0 +1,390 @@ +from __future__ import annotations + +import os +import logging +import concurrent.futures +from typing import Union, List, Dict, Tuple, TYPE_CHECKING, Optional + + +import numpy as np +import pandas as pd + + + +from metobs_toolkit.backend_collection.datetime_collection import to_timedelta +from metobs_toolkit.backend_collection.decorators import log_entry +from metobs_toolkit.qc_collection.distancematrix_func import generate_distance_matrix + +from metobs_toolkit.qcresult import QCresult, flagged_cond +from .buddywrapstation import BuddyCheckStation, to_qc_labels_map +from ..whitelist import WhiteSet +# Import methods +from . import methods as buddymethods + + + +if TYPE_CHECKING: + from metobs_toolkit.station import Station + +logger = logging.getLogger("") + + +@log_entry +def toolkit_buddy_check( + target_stations: list[Station], + metadf: pd.DataFrame, + obstype: str, + spatial_buddy_radius: Union[int, float], + spatial_min_sample_size: int, + max_alt_diff: Union[int, float, None], + min_std: Union[int, float], + spatial_z_threshold: Union[int, float], + N_iter: int, + instantaneous_tolerance: pd.Timedelta, + # Whitelist arguments + whiteset: WhiteSet, + # Safety nets + safety_net_configs: List[Dict] = None, + # Technical + lapserate: Union[float, None] = None, # -0.0065 for temperature + use_mp: bool = True, +) -> List[QCresult]: + """ + Spatial buddy check. + + The buddy check compares an observation against its neighbors + (i.e. spatial buddies). The check loops over all the groups, which are stations + within a radius of each other. For each group, the z-value of the reference + observation is computed given the sample of spatial buddies. If one (or more) + exceeds the `spatial_z_threshold`, the most extreme (=baddest) observation of + that group is labeled as an outlier. + + Multiple iterations of this checks can be done using the N_iter. + + Optionally, one or more safety nets can be applied. A safety net tests + potential outliers against a sample of stations that share a categorical + attribute (e.g., LCZ, network). If the z-value computed using the safety + net sample is below the specified threshold, the outlier is "saved" and + removed from the outlier set for the current iteration. + + Safety nets are applied in the order they are specified, allowing for + multi-level filtering (e.g., first test against LCZ buddies, then against + network buddies). + + A schematic step-by-step description of the buddy check: + + #. A distance matrix is constructed for all interdistances between + the stations. This is done using the haversine approximation. + #. Groups of spatial buddies (neighbours) are created by using the + `spatial_buddy_radius.` These groups are further filtered by: + + * removing stations from the groups that differ to much in altitude + (based on the `max_alt_diff`) + * removing groups of buddies that are too small (based on the + `min_sample_size`) + + #. Observations per group are synchronized in time (using the + `instantaneous_tolerance` for allignment). + #. If a `lapsrate` is specified, the observations are corrected for + altitude differences. + #. The following steps are repeated for `N-iter` iterations: + + #. The values of outliers flagged by a previous iteration are converted to + NaN's. Therefore they are not used in any following score or sample. + #. For each buddy group: + + * The mean, standard deviation (std), and sample size are computed. + * If the std is lower than the `minimum_std`, it is replaced by the + minimum std. + * Chi values are calculated for all records. + * For each timestamp the record with the highest Chi is tested if + it is larger then spatial_z_threshold. + If so, that record is flagged as an outlier. It will be ignored + in the next iteration. + + #. If `safety_net_configs` is provided, the following steps are applied + on the outliers flagged by the current iteration, for each safety net + in order: + + * Category buddies (stations sharing the same category value within + the specified radius) are identified. + * The safety net sample is tested in size (sample size must be at + least `min_sample_size`). If the condition is not met, the safety + net test is not applied. + * The safety net test is applied: + + * The mean and std are computed of the category-buddy sample. If + the std is smaller than `min_std`, the latter is used. + * The z-value is computed for the target record (= flagged outlier). + * If the z-value is smaller than the safety net's `z_threshold`, + the tested outlier is "saved" and removed from the set of outliers + for the current iteration. + + #. If `whiteset` contains records, any outliers that match the white-listed + timestamps (and optionally station names) are removed from the outlier set + for the current iteration. White-listed records participate in all buddy + check calculations but are not flagged as outliers in the final results. + + Parameters + ---------- + target_stations : list[Station] + A list of Station objects to apply the buddy check on. These should be + stations that contain the target observation type. + metadf : pandas.DataFrame + DataFrame containing station metadata including coordinates (geometry) + and altitude information for all stations. + obstype : str + The observation type that has to be checked. + spatial_buddy_radius : int or float + The radius to define spatial neighbors in meters. + spatial_min_sample_size : int + The minimum sample size to calculate statistics on used by + spatial-buddy samples. + max_alt_diff : int, float, or None + The maximum altitude difference allowed for buddies. If None, + no altitude filter is applied. + min_std : int or float + The minimum standard deviation for sample statistics. This should + represent the accuracy of the observations. + spatial_z_threshold : int or float + The threshold, tested with z-scores, for flagging observations as outliers. + N_iter : int + The number of iterations to perform the buddy check. + instantaneous_tolerance : pandas.Timedelta + The maximum time difference allowed for synchronizing observations. + whiteset : WhiteSet + A WhiteSet instance containing records that should be excluded from + outlier detection. Records in the WhiteSet undergo the buddy check + iterations as regular records but are removed from the outlier set + at the end of each iteration. + safety_net_configs : list of dict, optional + List of safety net configurations to apply in order. Each dict must + contain: + + * 'category': str, the metadata column name to group by (e.g., 'LCZ', + 'network') + * 'buddy_radius': int or float, maximum distance for category buddies + (in meters) + * 'z_threshold': int or float, z-value threshold for saving outliers + * 'min_sample_size': int, minimum number of buddies required for the + safety net test + + The default is None. + lapserate : float or None, optional + Describes how the obstype changes with altitude (in meters). If + None, no altitude correction is applied. For temperature, a + common value is -0.0065. + use_mp : bool, optional + Use multiprocessing to speed up the buddy check. The default is True. + + Returns + ------- + list + A list of tuples containing the outlier station, timestamp, + and detail message. Each tuple is in the form (station_name, + timestamp, message). + dict + A dictionary mapping each synchronized timestamp to its original + timestamp. + A dictionary mapping station names to BuddyCheckStationDetails objects + containing detailed tracking information for each timestamp. + + + Notes + ----- + + * The altitude of the stations can be extracted from GEE by using the + `Dataset.get_altitude()` method. + * The LCZ of the stations can be extracted from GEE by using the + `Dataset.get_LCZ()` method. + + """ + targets = [BuddyCheckStation(station=sta) for sta in target_stations] + + + # Validate safety net configs if provided + buddymethods.validate_safety_net_configs(safety_net_configs) + + # ----- Part 1: construct buddy groups ------ + # compute distance metric + logger.debug("Calculating distance matrix with Haversine formula") + dist_matrix = generate_distance_matrix(metadf) + + # find potential buddies by distance + logger.debug( + "Finding spatial buddies within radius of %s meters", spatial_buddy_radius + ) + + buddymethods.assign_spatial_buddies( + distance_df=dist_matrix, + metadf = metadf, + max_alt_diff=max_alt_diff, + buddy_radius=spatial_buddy_radius, + wrappedstations=targets, + ) + + + # ---- Part 2: Preparing the records ----- + + # construct a wide observation dataframe + + widedf, timestamp_map = buddymethods.create_wide_obs_df( + wrappedstations=targets, + obstype=obstype, + instantaneous_tolerance=instantaneous_tolerance, + ) + + # lapse rate correction + widedf = buddymethods.correct_lapse_rate(widedf=widedf, + wrappedstations=targets, + lapserate=lapserate) + + + + # ---- Part 3 : Apply buddy check per stationcenter, + + # valid_targets = [budsta for budsta in targets if budsta.has_enough_buddies( + # groupname='spatial', min_buddies = spatial_min_sample_size)] + valid_targets = targets + outliersbin = [] + for i in range(N_iter): + logger.debug("Starting iteration %s of %s", i + 1, N_iter) + # convert values to NaN, if they are labeled as outlier in + # previous iteration + if bool(outliersbin): + logger.debug("Converting previous-iteration outliers to NaN") + for outlier_station, outlier_time in outliersbin: + if outlier_station in widedf.columns: + widedf.loc[outlier_time, outlier_station] = np.nan + + if use_mp: + #TODO: implement multiprocessing (make chunks along the time dimension) + pass + # Use multiprocessing generator (parallelization) + # Use multiprocessing generator (parallelization) + # # since this check is an instantaneous check --> + # # perfect for splitting the dataset in chunks in time + # chunks = np.array_split(combdf, num_cpus) + + + # inputargs = [ + # ( + # budsta, + # chunk, + # spatial_min_sample_size, + # min_std, + # spatial_z_threshold, + # ) + # for budsta in valid_targets + # for chunk in chunks + # ] + + + # outliers = executor.map(find_buddy_group_outlier, inputargs) + # outliers = list(outliers) + + + else: + # create inputargs for each buddygroup, and for each chunk in time + inputargs = [ + { + 'centerwrapstation': sta, + 'buddygroupname': 'spatial', + 'widedf': widedf, + 'min_sample_size': spatial_min_sample_size, + 'min_std': min_std, + 'outlier_threshold': spatial_z_threshold, + 'iteration': i, + 'check_type': 'spatial_check', + } + for sta in valid_targets + ] + + + logger.debug("Finding outliers in each buddy group") + outlier_indices = list(map(lambda kwargs: buddymethods.buddy_test_a_station(**kwargs), inputargs)) + + + # Concatenate all outlier MultiIndices + # Each element is a MultiIndex with (name, datetime) + spatial_outliers = buddymethods.concat_multiindices(outlier_indices) + + # Start with spatial outliers for further processing + current_outliers_idx = spatial_outliers + + # Apply safety nets (if configured) + if safety_net_configs: + logger.debug( + "Applying %s safety net(s) to %s outliers", + len(safety_net_configs), + len(current_outliers_idx), + ) + for safety_net_config in safety_net_configs: + + current_outliers_idx = buddymethods.apply_safety_net( + outliers=current_outliers_idx, + buddycheckstations = valid_targets, + buddygroupname=safety_net_config['category'], + metadf = metadf, + distance_df = dist_matrix, + max_distance=safety_net_config['buddy_radius'], + max_alt_diff=max_alt_diff, #make this configurable? + wideobsds=widedf, + safety_z_threshold=safety_net_config['z_threshold'], + min_sample_size=safety_net_config['min_sample_size'], + min_std=min_std, #make this configurable? + iteration=i, + ) + + # NOTE: Records saved by any safety net will be tested again in + # the following iteration. A different result can occur if the + # spatial/safety net sample changes in the next iteration. + + # Apply whitelist filtering + current_outliers_idx = buddymethods.save_whitelist_records( + outliers=current_outliers_idx, + wrappedstations=valid_targets, + whiteset=whiteset, + obstype=obstype, + iteration=i, + ) + + # NOTE: The white-listed records are removed from the outliers at the end + # of each iteration, similar to the safety nets. They participate in + # the buddy check calculations but are not flagged as outliers. + + # Convert MultiIndex to list of tuples for outliersbin + # Format: (station_name, timestamp, message) + for name, dt in current_outliers_idx: + outliersbin.append((name, dt)) + + #Prepare for output + return_results = {} + qcsettings = locals() + del qcsettings['target_stations'] + del qcsettings['metadf'] + for wrapsta in targets: + #1. Map timestamps back to original timestamps + wrapsta.map_timestamps(timestamp_map=timestamp_map[wrapsta.name]) + + #2. Create final QC labels (specific for buddy check) + final_labels = wrapsta.get_final_labels() + + #3 Convert these flags to default qc flags + qcflags = final_labels.map(to_qc_labels_map) + + #4 Create QCresult object + outliers = wrapsta.station.get_sensor(obstype).series.loc[qcflags[qcflags == flagged_cond].index] + + qcres = QCresult( + checkname='buddy_check', + checksettings=qcsettings, + flags=qcflags, + outliers=outliers, + detail='', + ) + qcres.add_details_by_series(detail_series = wrapsta.get_final_details()) + return_results[wrapsta.name] = qcres + + return return_results + diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py new file mode 100644 index 00000000..e0e53483 --- /dev/null +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py @@ -0,0 +1,800 @@ +from __future__ import annotations +import os +import logging +import concurrent.futures +from typing import Union, List, Dict, Tuple, TYPE_CHECKING, Optional + +from metobs_toolkit.qcresult import ( + unchecked_cond, + unmet_cond, + pass_cond, + flagged_cond, + saved_cond, +) + +import numpy as np +import pandas as pd +# Constants for buddy check status labels +BC_NOT_TESTED = "not_tested" # Value was NaN, not tested +BC_NO_BUDDIES = "no_buddies" # Not enough buddies to test +BC_PASSED = "passed" # Tested and passed +BC_FLAGGED = "flagged" # Tested and flagged as outlier +BC_SAFETYNET_SAVED = "safetynet_saved" # Flagged but saved by safetynet +BC_SAFETYNET_OUTLIER = "safetynet_outlier" # Flagged but not saved by safetynet +BC_WHITELIST_SAVED = "whitelist_saved" # Flagged but saved by whitelist +BC_WHITELIST_NOT_SAVED = "whitelist_not_saved" # Flagged but not saved by whitelist + +if TYPE_CHECKING: + from metobs_toolkit.station import Station + +logger = logging.getLogger("") + +class BuddyCheckStation: + """Wrapper for a Station with buddy check-specific details. + + This class wraps a Station object and adds information about how it is + handled during the buddy check process, including buddy assignment, + filtering steps, and participation in buddy groups. + + Attributes + ---------- + station : Station + The wrapped Station object. + _buddy_groups : dict + Dictionary mapping group names to lists of buddy station names. + flag_lapsrate_corrections : bool + Whether lapse rate corrections have been applied. + cor_term : float + The correction term applied for lapse rate. + flags : pandas.DataFrame + DataFrame with MultiIndex (datetime, iteration) containing flag values. + Columns are added via `add_flags` method for different check types. + details : dict + Dictionary storing iteration-wise detail information. Structure: + { + 'spatial_check': { + iteration_int: Series(index=DatetimeIndex, data=detail_strings), + ... + }, + 'safetynet_check': { + groupname_str: { + iteration_int: Series(index=DatetimeIndex, data=detail_strings), + ... + }, + ... + }, + 'whitelist_check': { + iteration_int: Series(index=DatetimeIndex, data=detail_strings), + ... + }, + } + """ + station: Station + + def __init__(self, station: Station): + self.station = station + # Initialize instance-specific attributes (NOT class attributes!) + self._buddy_groups: Dict[str, List[str]] = { + 'spatial': [], + } + + # Value corrections + self.flag_lapsrate_corrections: bool = False + self.cor_term: float = 0. + + # Flags DataFrame with MultiIndex (datetime, iteration) + self._flags: pd.DataFrame = pd.DataFrame() + + # Details dictionary structure + self.details: Dict[str, Union[Dict[int, pd.Series], Dict[str, Dict[int, pd.Series]]]] = { + 'spatial_check': {}, + 'safetynet_check': {}, # Dict of groupname -> Dict of iteration -> Series + 'whitelist_check': {}, + } + + @property + def name(self) -> str: + """Get the station name.""" + return self.station.name + + @property + def flags(self) -> pd.DataFrame: + """Get the flags DataFrame.""" + if self._flags.empty: + return pd.DataFrame(index=pd.MultiIndex(levels=[[], []], codes=[[], []], names=['datetime', 'iteration'])) + + return self._flags + + @flags.setter + def flags(self, flags: pd.DataFrame) -> None: + + if not isinstance(flags, pd.DataFrame): + raise ValueError("flags must be a pandas DataFrame") + + if not flags.empty: + if not isinstance(flags.index, pd.MultiIndex): + raise ValueError("flags DataFrame must have a MultiIndex") + if flags.index.names != ['datetime', 'iteration']: + raise ValueError("flags DataFrame MultiIndex must have levels ['datetime', 'iteration']") + + # Preserve column order: existing columns first, new columns at the end + if not self._flags.empty and not flags.empty: + existing_cols = [col for col in self._flags.columns if col in flags.columns] + new_cols = [col for col in flags.columns if col not in self._flags.columns] + ordered_cols = existing_cols + new_cols + flags = flags[ordered_cols] + + self._flags = flags + + + + def add_flags(self, iteration: int, flag_series: pd.Series, column_name: str) -> None: + """Add flags to the flags DataFrame for a specific iteration. + + Parameters + ---------- + iteration : int + The iteration number. + flag_series : pd.Series + Series with DatetimeIndex containing flag values. + column_name : str + The name of the column to add/update (e.g., 'spatial_check', + 'safetynet_check:groupname', 'whitelist_check'). + """ + if flag_series.empty: + return + + # Remove duplicates (keep first occurrence) + flag_series = flag_series[~flag_series.index.duplicated(keep='first')] + + # Create a DataFrame with MultiIndex for the new flags + new_flags = pd.DataFrame({ + column_name: flag_series.values + }, index=pd.MultiIndex.from_arrays( + [flag_series.index, [iteration] * len(flag_series)], + names=['datetime', 'iteration'] + )) + + if self.flags.empty: + self.flags = new_flags + else: + # Merge new flags with existing flags + # Use combine_first to keep existing values and add new ones + self.flags = self.flags.combine_first(new_flags) + + # If the column already exists, update with new values (not NaN) + if column_name in self.flags.columns: + # For indices that exist in both, update from new_flags + common_idx = self.flags.index.intersection(new_flags.index) + if not common_idx.empty: + self.flags.loc[common_idx, column_name] = new_flags.loc[common_idx, column_name] + + self.flags = self.flags.sort_index() + + def filter_buddies(self, filteredbuddies: List[str], groupname: str) -> None: + self.set_buddies(filteredbuddies, groupname=f'{groupname}_filtered') + + def set_buddies(self, buddies: List[str], groupname: str) -> None: + self._buddy_groups.update({groupname: buddies}) + + def get_buddies(self, groupname: str) -> List[str]: + if f'{groupname}_filtered' in self._buddy_groups: + return self._buddy_groups[f'{groupname}_filtered'] + if groupname in self._buddy_groups: + return self._buddy_groups[groupname] + raise ValueError(f"Unknown buddy group: {groupname}") + + def has_enough_buddies(self, groupname: str, min_buddies: int) -> bool: + """Check if the station has enough final buddies.""" + enough = len(self.get_buddies(groupname=groupname)) >= min_buddies + return enough + + + def _update_details(self, iteration: int, detail_series: pd.Series, groupname: str) -> None: + if detail_series.empty: + return + # Remove duplicates (keep first occurrence) + detail_series = detail_series[~detail_series.index.duplicated(keep='first')] + + # Store details in the details dictionary + if iteration not in self.details[groupname]: + self.details[groupname][iteration] = detail_series + else: + #FIXME: i do not think this branch is ever used + # Append to existing series for this iteration + existing = self.details[groupname][iteration] + combined = pd.concat([existing, detail_series]) + # Remove duplicates keeping first + self.details[groupname][iteration] = combined[~combined.index.duplicated(keep='first')].sort_index() + + def add_spatial_details(self, iteration: int, detail_series: pd.Series) -> None: + """Add spatial check detail information for an iteration. + + Parameters + ---------- + iteration : int + The iteration number. + detail_series : pd.Series + Series with DatetimeIndex containing detail messages. + """ + self._update_details( + iteration=iteration, + detail_series=detail_series, + groupname='spatial_check' + ) + + def update_safetynet_details(self, + detailseries: pd.Series, + iteration: int, + groupname: str, + is_saved: bool = True,) -> None: + """Update safetynet check saved information for an iteration. + + Parameters + ---------- + detailseries : pd.Series + Series with DatetimeIndex containing detail messages for saved records. + iteration : int + The iteration number. + groupname : str + The name of the safetynet group that saved the records. + is_saved : bool, optional + Whether the records were saved by the safetynet (True) or failed (False). + Default is True. + """ + # Remove duplicates (keep first occurrence) + detailseries = detailseries[~detailseries.index.duplicated(keep='first')] + + if detailseries.empty: + return + + if is_saved: + flag = BC_SAFETYNET_SAVED + else: + flag = BC_SAFETYNET_OUTLIER + + # Add flags to the flags DataFrame + column_name = f'safetynet_check:{groupname}' + flag_series = pd.Series(flag, index=detailseries.index) + self.add_flags(iteration=iteration, flag_series=flag_series, column_name=column_name) + + # Store details in the details dictionary + # Ensure the groupname entry exists + if groupname not in self.details['safetynet_check']: + self.details['safetynet_check'][groupname] = {} + + if iteration not in self.details['safetynet_check'][groupname]: + self.details['safetynet_check'][groupname][iteration] = detailseries + else: + # Append to existing series for this iteration + existing = self.details['safetynet_check'][groupname][iteration] + combined = pd.concat([existing, detailseries]) + # Remove duplicates keeping first + self.details['safetynet_check'][groupname][iteration] = combined[~combined.index.duplicated(keep='first')].sort_index() + + def update_whitelist_details(self, whitelistseries: pd.Series, + iteration: int, + is_saved: bool = True) -> None: + """Update whitelist check saved information for an iteration. + + Parameters + ---------- + whitelistseries : pd.Series + Series with DatetimeIndex containing detail messages for whitelisted records. + iteration : int + The iteration number. + is_saved : bool, optional + Whether the records were saved by the whitelist (True) or not (False). + Default is True. + """ + # Remove duplicates (keep first occurrence) + whitelistseries = whitelistseries[~whitelistseries.index.duplicated(keep='first')] + + if whitelistseries.empty: + return + + if is_saved: + flag = BC_WHITELIST_SAVED + else: + flag = BC_WHITELIST_NOT_SAVED + # Add flags to the flags DataFrame + flag_series = pd.Series(flag, index=whitelistseries.index) + self.add_flags(iteration=iteration, flag_series=flag_series, column_name='whitelist_check') + + # Store details in the details dictionary + if iteration not in self.details['whitelist_check']: + self.details['whitelist_check'][iteration] = whitelistseries + else: + # Append to existing series for this iteration + existing = self.details['whitelist_check'][iteration] + combined = pd.concat([existing, whitelistseries]) + # Remove duplicates keeping first + self.details['whitelist_check'][iteration] = combined[~combined.index.duplicated(keep='first')].sort_index() + + def get_combined_status_df(self) -> pd.DataFrame: + """Combine all time-dependent status information into a single DataFrame. + + Combines flags and details across all iterations into a unified DataFrame. + + Returns + ------- + pandas.DataFrame + DataFrame with MultiIndex (datetime, iteration) and columns: + + * 'check_type' : str - Type of check ('spatial_check', 'safetynet_check:', 'whitelist_check') + * 'flag' : str - Status flag (BC_FLAGGED, BC_SAFETYNET_SAVED, BC_WHITELIST_SAVED) + * 'details' : str - Detailed message about the record + + Returns empty DataFrame if no records exist. + """ + records = [] + + # Process flags DataFrame + if not self.flags.empty: + for (timestamp, iteration), row in self.flags.iterrows(): + for col in self.flags.columns: + flag_value = row[col] + if pd.notna(flag_value): + # Determine check_type and get details + check_type = col + detail = '' + + if col == 'spatial_check': + if iteration in self.details['spatial_check']: + detail_series = self.details['spatial_check'][iteration] + if timestamp in detail_series.index: + detail = detail_series.loc[timestamp] + elif col.startswith('safetynet_check:'): + groupname = col.split(':', 1)[1] + if groupname in self.details['safetynet_check']: + if iteration in self.details['safetynet_check'][groupname]: + detail_series = self.details['safetynet_check'][groupname][iteration] + if timestamp in detail_series.index: + detail = detail_series.loc[timestamp] + elif col == 'whitelist_check': + if iteration in self.details['whitelist_check']: + detail_series = self.details['whitelist_check'][iteration] + if timestamp in detail_series.index: + detail = detail_series.loc[timestamp] + + records.append({ + 'datetime': timestamp, + 'iteration': iteration, + 'check_type': check_type, + 'flag': flag_value, + 'details': detail + }) + + if not records: + return pd.DataFrame(columns=['check_type', 'flag', 'details']) + + result_df = pd.DataFrame(records) + result_df = result_df.set_index(['datetime', 'iteration']) + result_df = result_df.sort_index() + + return result_df + + def _get_iterations(self) -> List[int]: + """Get all iterations that have been processed.""" + iterations = set() + + # From spatial_check + iterations.update(self.details['spatial_check'].keys()) + + # From safetynet_check (nested) + for groupname, iter_dict in self.details['safetynet_check'].items(): + iterations.update(iter_dict.keys()) + + # From whitelist_check + iterations.update(self.details['whitelist_check'].keys()) + + return sorted(iterations) + + def get_final_labels(self) -> pd.Series: + + final_labels = self.flags.groupby('datetime').apply(final_label_logic) + final_labels.name = 'final_label' + return final_labels + + def get_final_details(self) -> pd.Series: + """Get detailed description strings for each timestamp based on final label logic. + + This method returns a Series with detailed description strings that illustrate + how the final label was determined. The details are extracted from the details + attribute and combined based on the check pipeline. + + Returns + ------- + pd.Series + Series with DatetimeIndex containing detailed description strings. + The series name is 'final_details'. + """ + final_details = self.flags.groupby('datetime').apply( + lambda subset: final_detail_logic(subset, self.details) + ) + final_details.name = 'final_details' + return final_details + + + def get_iteration_summary(self, iteration: int) -> Dict[str, int]: + """Get a summary of record counts for a specific iteration. + + Parameters + ---------- + iteration : int + The iteration number to summarize. + + Returns + ------- + dict + Dictionary with check types as keys and record counts as values. + For safetynet_check, includes both total and per-group counts. + """ + # Count spatial_check + spatial_count = 0 + if iteration in self.details['spatial_check']: + spatial_count = len(self.details['spatial_check'][iteration]) + + # Count safetynet_check per group + safetynet_per_group = {} + for groupname, iter_dict in self.details['safetynet_check'].items(): + if iteration in iter_dict: + safetynet_per_group[groupname] = len(iter_dict[iteration]) + safetynet_total = sum(safetynet_per_group.values()) + + # Count whitelist_check + whitelist_count = 0 + if iteration in self.details['whitelist_check']: + whitelist_count = len(self.details['whitelist_check'][iteration]) + + return { + 'spatial_check': spatial_count, + 'safetynet_check': safetynet_per_group, + 'safetynet_check_total': safetynet_total, + 'whitelist_check': whitelist_count + } + + def get_info(self) -> str: + """Get a summary of the BuddyCheckStation status and attributes. + + Returns + ------- + str + Formatted string with overview of the station's buddy check status. + """ + lines = [] + lines.append("=" * 60) + lines.append(f"BuddyCheckStation: {self.name}") + lines.append("=" * 60) + + # Buddy groups + lines.append("\n--- Buddy Groups ---") + if self._buddy_groups: + for groupname, buddies in self._buddy_groups.items(): + n_buddies = len(buddies) + buddies_str = ", ".join(buddies[:5]) + if n_buddies > 5: + buddies_str += f", ... (+{n_buddies - 5} more)" + lines.append(f" {groupname}: {n_buddies} buddies") + if buddies: + lines.append(f" [{buddies_str}]") + else: + lines.append(" No buddy groups assigned") + + # Corrections + lines.append("\n--- Value Corrections ---") + lines.append(f" Lapse rate correction: {self.flag_lapsrate_corrections}") + if self.flag_lapsrate_corrections: + lines.append(f" Correction term: {self.cor_term:.4f}") + + # Flags summary + lines.append("\n--- Flags ---") + if not self.flags.empty: + lines.append(f" Total flag entries: {len(self.flags)}") + lines.append(f" Flag columns: {list(self.flags.columns)}") + else: + lines.append(" No flags recorded") + + # Iteration status + iterations = self._get_iterations() + lines.append("\n--- Iteration Status ---") + if iterations: + lines.append(f" Iterations processed: {len(iterations)}") + + # Totals across all iterations + total_spatial = 0 + total_safetynet = 0 + total_whitelist = 0 + safetynet_groups_total: Dict[str, int] = {} + + for iteration in iterations: + summary = self.get_iteration_summary(iteration) + total_spatial += summary['spatial_check'] + total_safetynet += summary['safetynet_check_total'] + total_whitelist += summary['whitelist_check'] + + # Accumulate per-group safetynet totals + for groupname, count in summary['safetynet_check'].items(): + safetynet_groups_total[groupname] = safetynet_groups_total.get(groupname, 0) + count + + lines.append(f"\n Iteration {iteration}:") + lines.append(f" Spatial outliers: {summary['spatial_check']}") + if summary['safetynet_check']: + lines.append(f" Safetynet saved (total): {summary['safetynet_check_total']}") + for groupname, count in summary['safetynet_check'].items(): + lines.append(f" - {groupname}: {count}") + else: + lines.append(f" Safetynet saved: 0") + lines.append(f" Whitelist saved: {summary['whitelist_check']}") + + lines.append(f"\n --- Totals ---") + lines.append(f" Total spatial outliers: {total_spatial}") + lines.append(f" Total safetynet saved: {total_safetynet}") + if safetynet_groups_total: + for groupname, count in safetynet_groups_total.items(): + lines.append(f" - {groupname}: {count}") + lines.append(f" Total whitelist saved: {total_whitelist}") + else: + lines.append(" No iterations processed") + + lines.append("=" * 60) + + info_str = "\n".join(lines) + print(info_str) + return info_str + + def map_timestamps(self, + timestamp_map: Dict[str, pd.Series]) -> None: + """Map synchronized timestamps to original timestamps for this station. + + This function maps the synchronized timestamps (used during buddy check + processing) back to the original timestamps for this station's flags and + details attributes. + + Parameters + ---------- + timestamp_map : dict + Dictionary mapping station names to Series where index is synchronized + timestamp and value is original timestamp. + + Returns + ------- + None + Modifies the station in-place. + """ + + # ts_map = timestamp_map[self.name] + + # Revert flags DataFrame timestamps (MultiIndex: datetime, iteration) + if not self.flags.empty: + self.flags = _map_dt_index( + pdobj=self.flags, + ts_map=timestamp_map, + datetime_level='datetime' + ) + + # Revert details timestamps + # spatial_check: {iteration: Series} + for iteration, detail_series in self.details['spatial_check'].items(): + self.details['spatial_check'][iteration] = _map_dt_index( + pdobj=detail_series, + ts_map=timestamp_map + ) + + # safetynet_check: {groupname: {iteration: Series}} + for groupname, iter_dict in self.details['safetynet_check'].items(): + for iteration, detail_series in iter_dict.items(): + self.details['safetynet_check'][groupname][iteration] = _map_dt_index( + pdobj=detail_series, + ts_map=timestamp_map + ) + + # whitelist_check: {iteration: Series} + for iteration, detail_series in self.details['whitelist_check'].items(): + self.details['whitelist_check'][iteration] = _map_dt_index( + pdobj=detail_series, + ts_map=timestamp_map + ) + + +def final_label_logic(subset: pd.DataFrame) -> str: + #the flag not tested is present on ALL iterations ! + if subset['spatial_check'].apply(lambda x: x=='not_tested').all(): + return BC_NOT_TESTED + + # --- passed condition ---- + #1a perfect pass (pass on last iteration of spatial check) + if subset['spatial_check'].iloc[-1] == BC_PASSED: + return BC_PASSED + + if subset['spatial_check'].iloc[-1] == BC_NO_BUDDIES: + #Choice made: it can happen that a record passed a previous iteration, + #but has not enough buddies in the last iteration. Applying the 'save' logic, + #it is best to label this as no_buddies + return BC_NO_BUDDIES + + #catched by safetynet + #if there is at least (there can only be one), pass in the last iteration of saftynets + saftynet_cols = [col for col in subset.columns if col.startswith('safetynet_check:')] + if any(subset.iloc[-1][saftynet_cols] == BC_PASSED): #TODO not shure of this string + return BC_SAFETYNET_SAVED + + + #catched by whitelist + if subset['whitelist_check'].iloc[-1] == BC_WHITELIST_SAVED: + return BC_WHITELIST_SAVED + + # --- failed condition ---- + + #fail in last iteration of spatial check + if ((subset['spatial_check'].iloc[-1] == BC_FLAGGED) and + all(subset.iloc[-1][saftynet_cols] != BC_PASSED) and #not passed is [nan, flagged, no-buddies] + (subset['whitelist_check'].iloc[-1] == BC_WHITELIST_NOT_SAVED)): + return BC_FLAGGED + + #fail in any previous iteration of spatial check + if ((any(subset['spatial_check'] == BC_FLAGGED)) and + (subset['spatial_check'].iloc[-1] == BC_NOT_TESTED)): + return BC_FLAGGED + + + raise ValueError(f"Unforeseen situartion encountered in final label logic: \n {subset}") + + + +def final_detail_logic(subset: pd.DataFrame, details: Dict) -> str: + """Extract detailed description string based on the final label logic. + + This function mirrors the logic of `final_label_logic` but returns + a detailed description string extracted from the details dictionary. + + Parameters + ---------- + subset : pd.DataFrame + DataFrame subset for a single timestamp with all iterations. + details : dict + The details dictionary from BuddyCheckStation containing: + - 'spatial_check': {iteration: Series} + - 'safetynet_check': {groupname: {iteration: Series}} + - 'whitelist_check': {iteration: Series} + + Returns + ------- + str + Detailed description string for the final label. + """ + # Get the timestamp from the subset index + timestamp = subset.index.get_level_values('datetime')[0] + last_iteration = subset.index.get_level_values('iteration')[-1] + + # Helper to get detail from a specific check type and iteration + def get_spatial_detail(iteration: int) -> str: + if iteration in details['spatial_check']: + detail_series = details['spatial_check'][iteration] + if timestamp in detail_series.index: + return str(detail_series.loc[timestamp]) + return "" + + def get_safetynet_detail(groupname: str, iteration: int) -> str: + if groupname in details['safetynet_check']: + if iteration in details['safetynet_check'][groupname]: + detail_series = details['safetynet_check'][groupname][iteration] + if timestamp in detail_series.index: + return str(detail_series.loc[timestamp]) + return "" + + def get_whitelist_detail(iteration: int) -> str: + if iteration in details['whitelist_check']: + detail_series = details['whitelist_check'][iteration] + if timestamp in detail_series.index: + return str(detail_series.loc[timestamp]) + return "" + + # Apply same logic as final_label_logic to determine which detail to return + + # Not tested condition + if subset['spatial_check'].apply(lambda x: x == 'not_tested').all(): + return "Value was NaN, not tested." + + # Passed condition - pass on last iteration of spatial check + if subset['spatial_check'].iloc[-1] == BC_PASSED: + detail = get_spatial_detail(last_iteration) + return detail if detail else "Passed spatial check." + + # No buddies condition + if subset['spatial_check'].iloc[-1] == BC_NO_BUDDIES: + detail = get_spatial_detail(last_iteration) + return detail if detail else "Not enough buddies to test." + + # Caught by safetynet + saftynet_cols = [col for col in subset.columns if col.startswith('safetynet_check:')] + for col in saftynet_cols: + if subset.iloc[-1][col] == BC_PASSED: + groupname = col.split(':', 1)[1] + detail = get_safetynet_detail(groupname, last_iteration) + return detail if detail else f"Saved by safetynet ({groupname})." + + # Caught by whitelist + if subset['whitelist_check'].iloc[-1] == BC_WHITELIST_SAVED: + detail = get_whitelist_detail(last_iteration) + return detail if detail else "Saved by whitelist." + + # Failed conditions + + # Fail in last iteration of spatial check + if ((subset['spatial_check'].iloc[-1] == BC_FLAGGED) and + all(subset.iloc[-1][saftynet_cols] != BC_PASSED) and + (subset['whitelist_check'].iloc[-1] == BC_WHITELIST_NOT_SAVED)): + # Combine details from spatial check and whitelist + spatial_detail = get_spatial_detail(last_iteration) + whitelist_detail = get_whitelist_detail(last_iteration) + parts = [p for p in [spatial_detail, whitelist_detail] if p] + return " | ".join(parts) if parts else "Flagged as outlier." + + # Fail in any previous iteration of spatial check + if ((any(subset['spatial_check'] == BC_FLAGGED)) and + (subset['spatial_check'].iloc[-1] == BC_NOT_TESTED)): + # Find the iteration where it was flagged + for idx, row in subset.iterrows(): + if row['spatial_check'] == BC_FLAGGED: + flagged_iteration = idx[1] # iteration from MultiIndex + detail = get_spatial_detail(flagged_iteration) + return detail if detail else f"Flagged in iteration {flagged_iteration}." + + return "Unknown condition." + + +def _map_dt_index(pdobj: pd.Series | pd.DataFrame, + ts_map: pd.Series, + datetime_level: str = 'datetime' +) -> pd.DataFrame: + """Revert timestamps in a DataFrame with MultiIndex containing datetime level. + + Parameters + ---------- + df : pd.DataFrame + DataFrame with MultiIndex containing a datetime level. + ts_map : pd.Series + Series mapping synchronized timestamps (index) to original timestamps (values). + datetime_level : str + Name of the datetime level in the MultiIndex. + + Returns + ------- + pd.DataFrame + DataFrame with reverted timestamps in the MultiIndex. + """ + + if isinstance(pdobj, pd.Series): + df = pdobj.to_frame() + returnseries = True + else: + df = pdobj + returnseries = False + + # Get the current index + old_index = df.index + level_names = old_index.names + + df = df.reset_index() + df['_mapped_datetime'] = df[datetime_level].map(lambda x: ts_map.get(x, x) if pd.notna(ts_map.get(x, pd.NaT)) else x) + df = df.drop(columns=[datetime_level]) + df = df.rename(columns={'_mapped_datetime': datetime_level}) + df = df.set_index(level_names) + df = df.sort_index() + if returnseries: + return df.iloc[:,0] + return df + + + +to_qc_labels_map = { + BC_NOT_TESTED: unchecked_cond, # Value was NaN, not tested + BC_NO_BUDDIES: unmet_cond, # Not enough buddies to test + BC_PASSED : pass_cond, # Tested and passed + BC_FLAGGED : flagged_cond, # Tested and flagged as outlier + BC_SAFETYNET_SAVED : pass_cond, # IMPORTANT !!! + # BC_SAFETYNET_OUTLIER : flagged_cond # Flagged but not saved by safetynet + BC_WHITELIST_SAVED : saved_cond, # Flagged but saved by whitelist + # BC_WHITELIST_NOT_SAVED : flagged_cond +} \ No newline at end of file diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/__init__.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/__init__.py new file mode 100644 index 00000000..74c7c9f5 --- /dev/null +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/__init__.py @@ -0,0 +1,7 @@ +from .findbuddies import assign_spatial_buddies, filter_buddygroup_by_altitude +from .pdmethods import create_wide_obs_df, concat_multiindices +from .lapsratecorrection import correct_lapse_rate +from .samplechecks import buddy_test_a_station + +from .safetynets import validate_safety_net_configs, apply_safety_net +from .whitesaving import save_whitelist_records \ No newline at end of file diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/findbuddies.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/findbuddies.py new file mode 100644 index 00000000..6d5aa163 --- /dev/null +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/findbuddies.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import logging +from typing import Union, List, Dict, TYPE_CHECKING + +import pandas as pd + + +logger = logging.getLogger("") + + +if TYPE_CHECKING: + from ...buddystation import BuddyCheckStation + +# ------------------------------------------ +# Callables by buddy check +# ------------------------------------------ + +def assign_spatial_buddies( + distance_df: pd.DataFrame, + metadf: pd.DataFrame, + buddy_radius: Union[int, float], + wrappedstations: List[BuddyCheckStation], + max_alt_diff: Union[int, float, None]=None, +) -> None: + + + spatial_buddies = _find_buddies_by_distance(distance_df=distance_df, + buddy_radius=buddy_radius) + + #update the wrapstations + for wrapsta in wrappedstations: + wrapsta.set_buddies(spatial_buddies[wrapsta.name], groupname='spatial') + + if max_alt_diff is not None: + for wrapsta in wrappedstations: + filter_buddygroup_by_altitude( + wrappedstation=wrapsta, + groupname='spatial', + altitudes=metadf['altitude'], + max_altitude_diff=max_alt_diff + ) + + + +def filter_buddygroup_by_altitude( + wrappedstation: BuddyCheckStation, + groupname: str, + altitudes: pd.Series, + max_altitude_diff: Union[int, float] +) : + + if altitudes.isnull().any(): + raise ValueError("Altitude series contains NaN values. All stations must have valid altitude data for altitude filtering.") + + #update the filter flag + station_altitude = altitudes.loc[wrappedstation.name] + alt_buddies = [] + for buddy_name in wrappedstation.get_buddies(groupname=groupname): + buddy_altitude = altitudes.loc[buddy_name] + if abs(station_altitude - buddy_altitude) <= max_altitude_diff: + alt_buddies.append(buddy_name) + wrappedstation.filter_buddies(groupname=groupname, filteredbuddies=alt_buddies) + +# ------------------------------------------ +# Help functions to find buddies +# ------------------------------------------ +def _find_buddies_by_distance( + distance_df: pd.DataFrame, buddy_radius: Union[int, float] +) -> Dict: + + buddies = {} + for refstation, distances in distance_df.iterrows(): + bud_stations = distances[distances <= buddy_radius].index.to_list() + bud_stations.remove(refstation) + buddies[refstation] = bud_stations + + return buddies + diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/lapsratecorrection.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/lapsratecorrection.py new file mode 100644 index 00000000..04724973 --- /dev/null +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/lapsratecorrection.py @@ -0,0 +1,45 @@ + +from __future__ import annotations + +import logging +from typing import List, TYPE_CHECKING + +import pandas as pd + + +logger = logging.getLogger("") + + +if TYPE_CHECKING: + from ...buddystation import BuddyCheckStation + + + +def correct_lapse_rate(widedf: pd.DataFrame, + wrappedstations: List[BuddyCheckStation], + lapserate: float|None = None) -> pd.DataFrame: + + if lapserate is None: + logger.debug("No lapse rate correction applied") + for wrapsta in wrappedstations: wrapsta.flag_lapsrate_corrections = False + else: + logger.debug("Applying lapse rate correction with rate: %s", lapserate) + #Test if all stations have altitude + has_alts = [budsta.station.site.flag_has_altitude() for budsta in wrappedstations] + + if not all(has_alts): + raise ValueError( + "At least one station has a NaN value for 'altitude', not lapse rate correction possible" + ) + for budsta in wrappedstations: + budsta.flag_lapsrate_corrections = True + + # Since buddy check works with relative differences, correct all + # stations to the 0m altitude + correction_term = budsta.station.site.altitude * (-1) * lapserate + budsta.cor_term = correction_term #update it in the buddy station + + #apply the correction on the wide dataframe + widedf[budsta.name] = widedf[budsta.name] + correction_term + + return widedf \ No newline at end of file diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/pdmethods.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/pdmethods.py new file mode 100644 index 00000000..533e0d11 --- /dev/null +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/pdmethods.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import logging +from typing import Union, List, Dict, TYPE_CHECKING, Tuple + +import pandas as pd +from metobs_toolkit.backend_collection.datetime_collection import to_timedelta + +logger = logging.getLogger("") + + +if TYPE_CHECKING: + from ...buddystation import BuddyCheckStation + + +def create_wide_obs_df(wrappedstations: List[BuddyCheckStation], + obstype: str, + instantaneous_tolerance: pd.Timedelta + ) -> Tuple[pd.DataFrame, Dict]: + logger.debug("Constructing wide observation DataFrame for obstype: %s", obstype) + concatlist = [] + for wrapsta in wrappedstations: + if obstype in wrapsta.station.sensordata.keys(): + records = wrapsta.station.get_sensor(obstype).series + records.name = wrapsta.name + concatlist.append(records) + + + # synchronize the timestamps + logger.debug("Synchronizing timestamps") + combdf, timestamp_map = _synchronize_series( + series_list=concatlist, max_shift=instantaneous_tolerance + ) + + return (combdf, timestamp_map) + +def _synchronize_series( + series_list: List[pd.Series], max_shift: pd.Timedelta +) -> Tuple[pd.DataFrame, Dict]: + """ + Synchronize a list of pandas Series with datetime indexes. + + The target timestamps are defined by: + + + * freq: the highest frequency present in the input series + * origin: the earliest timestamp found, rounded down by the freq + * closing: the latest timestamp found, rounded up by the freq. + + Parameters + ---------- + series_list : list of pandas.Series + List of pandas Series with datetime indexes. + max_shift : pandas.Timedelta + Maximum shift in time that can be applied to each timestamp + in synchronization. + + Returns + ------- + pandas.DataFrame + DataFrame with synchronized Series. + dict + Dictionary mapping each synchronized timestamp to its + original timestamp. + """ + + # find highest frequency + frequencies = [to_timedelta(s.index.inferred_freq) for s in series_list] + trg_freq = min(frequencies) + + # find origin and closing timestamp (earliest/latest) + origin = min([s.index.min() for s in series_list]).floor(trg_freq) + closing = max([s.index.max() for s in series_list]).ceil(trg_freq) + + # Create target datetime axes + target_dt = pd.date_range(start=origin, end=closing, freq=trg_freq) + + # Synchronize (merge with tolerance) series to the common index + synchronized_series = [] + timestamp_mapping = {} + for s in series_list: + targetdf = ( + s.to_frame() + .assign(orig_datetime=s.index) + .reindex( + index=pd.DatetimeIndex(target_dt), + method="nearest", + tolerance=max_shift, + limit=1, + ) + ) + + # extract the mapping (new -> original) + orig_timestampseries = targetdf["orig_datetime"] + orig_timestampseries.name = "original_timestamp" + timestamp_mapping[s.name] = orig_timestampseries + + synchronized_series.append(s) + + return pd.concat(synchronized_series, axis=1), timestamp_mapping + + + +def concat_multiindices( + indices: List[pd.MultiIndex] +) -> pd.MultiIndex: + """Concatenate a list of MultiIndex objects into a single MultiIndex. + + Parameters + ---------- + indices : list of pd.MultiIndex + List of MultiIndex objects to concatenate. + + Returns + ------- + pd.MultiIndex + Concatenated MultiIndex. + """ + if not indices: + return pd.MultiIndex.from_tuples([], names=['name', 'datetime']) + + concatenated = pd.MultiIndex.from_tuples( + [tup for idx in indices for tup in idx], + names=indices[0].names + ) + + + # non_empty_indices = [idx for idx in outlier_indices if len(idx) > 0] + # if non_empty_indices: + # spatial_outliers = non_empty_indices[0] + # for idx in non_empty_indices[1:]: + # spatial_outliers = spatial_outliers.union(idx) + # spatial_outliers = spatial_outliers.drop_duplicates() + # else: + # spatial_outliers = pd.MultiIndex.from_tuples([], names=['name', 'datetime']) + + return concatenated \ No newline at end of file diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py new file mode 100644 index 00000000..65df9a5f --- /dev/null +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import logging +from typing import Union, List, Dict, TYPE_CHECKING + +import pandas as pd + + +logger = logging.getLogger("") + +from .findbuddies import filter_buddygroup_by_altitude +from .samplechecks import buddy_test_a_station +from ..buddywrapstation import BC_PASSED + +if TYPE_CHECKING: + from ..buddywrapstation import BuddyCheckStation + + +def validate_safety_net_configs(safety_net_configs: List[Dict]) -> None: + """ + Validate that all required keys are present in safety_net_configs. + + Parameters + ---------- + safety_net_configs : list of dict + List of safety net configuration dictionaries. + + Raises + ------ + ValueError + If safety_net_configs is not a list or contains non-dict elements. + KeyError + If any required key is missing from a safety net configuration. + """ + if safety_net_configs is None: + return None + + required_keys = {"category", "buddy_radius", "z_threshold", "min_sample_size"} + + if not isinstance(safety_net_configs, list): + raise ValueError( + f"safety_net_configs must be a list, got {type(safety_net_configs).__name__}" + ) + + for i, config in enumerate(safety_net_configs): + if not isinstance(config, dict): + raise ValueError( + f"Each safety net config must be a dict, but config at index {i} " + f"is {type(config).__name__}" + ) + + missing_keys = required_keys - set(config.keys()) + if missing_keys: + raise KeyError( + f"Safety net config at index {i} is missing required key(s): " + f"{', '.join(sorted(missing_keys))}. " + f"Required keys are: {', '.join(sorted(required_keys))}" + ) + + return None + + + + +def apply_safety_net( + outliers: pd.Index, + buddycheckstations: List[BuddyCheckStation], + buddygroupname: str, + metadf: pd.DataFrame, + distance_df: pd.DataFrame, + max_distance: Union[int, float], + max_alt_diff: Union[int, float, None], + wideobsds: pd.DataFrame, + safety_z_threshold: Union[int, float], + min_sample_size: int, + min_std: Union[int, float], + iteration: int, +) -> pd.MultiIndex: + + # Track records that were saved (passed the safety net test) + saved_records = pd.MultiIndex.from_tuples([], names=['name', 'datetime']) + + #create a name map of the wrappedstations + name_map = {wrapsta.name: wrapsta for wrapsta in buddycheckstations} + + + #find the categorical buddies (only for the outlier stations) + for outlstation in outliers.get_level_values('name').unique(): + wrapsta = name_map[outlstation] + + ref_category = metadf.loc[wrapsta.name, buddygroupname] + # Handle NaN values - they should not match with anything + if pd.isna(ref_category): + logger.warning( + "Station %s has NaN value for category '%s' - no category buddies assigned", + wrapsta.name, + buddygroupname, + ) + # Assign empty buddy list + wrapsta.set_buddies([], groupname=buddygroupname) + else: + #find potential candidates + buddy_candidates = metadf.loc[ + metadf[buddygroupname] == ref_category + ].index.to_list() + + #remove self from buddy candidates + buddy_candidates.remove(wrapsta.name) + + target_distances = distance_df.loc[wrapsta.name, buddy_candidates] + #filter by distance + ref_buddies = target_distances[target_distances <= max_distance].index.to_list() + + # Assign the found buddies + wrapsta.set_buddies(ref_buddies, groupname=buddygroupname) + + #filter by altitude difference if needed + if max_alt_diff is not None: + filter_buddygroup_by_altitude( + wrappedstation=wrapsta, + groupname=buddygroupname, + altitudes=metadf['altitude'], + max_altitude_diff=max_alt_diff + ) + + #find outliers in the new categorical group + # The buddy_test_a_station function updates flags/details directly + # and returns only the outlier MultiIndex (BC_FLAGGED records) + # We need to track BC_PASSED records to remove them from outliers + for outlstation in outliers.get_level_values('name').unique(): + wrapsta = name_map[outlstation] + + # Get the timestamps for this station from the original outliers + station_outlier_timestamps = outliers[ + outliers.get_level_values('name') == outlstation + ].get_level_values('datetime') + + # Subset wideobsds to only the outlier timestamps for this station + widedf_subset = wideobsds.loc[station_outlier_timestamps] + + # Run the buddy test - this updates flags/details directly on wrapsta + # and returns outliers (BC_FLAGGED records) + station_flagged = buddy_test_a_station( + centerwrapstation=wrapsta, + buddygroupname=buddygroupname, + widedf=widedf_subset, + min_sample_size=min_sample_size, + min_std=min_std, + outlier_threshold=safety_z_threshold, + iteration=iteration, + check_type=f'safetynet_check:{buddygroupname}', + ) + + # Get passed timestamps from the flags DataFrame + # These are records where the safetynet check passed (BC_PASSED) + check_col = f'safetynet_check:{buddygroupname}' + if not wrapsta.flags.empty and check_col in wrapsta.flags.columns: + # Get flags for this iteration + iter_mask = wrapsta.flags.index.get_level_values('iteration') == iteration + iter_flags = wrapsta.flags.loc[iter_mask, check_col] + + # Find passed timestamps + passed_mask = iter_flags == BC_PASSED + if passed_mask.any(): + passed_timestamps = iter_flags[passed_mask].index.get_level_values('datetime') + + # Create MultiIndex for saved records + station_saved = pd.MultiIndex.from_arrays( + [[outlstation] * len(passed_timestamps), passed_timestamps], + names=['name', 'datetime'] + ) + saved_records = saved_records.union(station_saved) + + # Return original outliers minus the saved records + remaining_outliers = outliers.difference(saved_records) + + return remaining_outliers.sort_values().unique() + + \ No newline at end of file diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py new file mode 100644 index 00000000..9f2426e7 --- /dev/null +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +import logging +from typing import Union, List, Dict, TYPE_CHECKING, Tuple +import numpy as np +import pandas as pd +from metobs_toolkit.backend_collection.datetime_collection import to_timedelta + +logger = logging.getLogger("") + + +if TYPE_CHECKING: + from ..buddywrapstation import BuddyCheckStation + +# Import constants from buddywrapstation +from ..buddywrapstation import BC_NO_BUDDIES, BC_PASSED, BC_FLAGGED, BC_NOT_TESTED + + +def buddy_test_a_station( + centerwrapstation: BuddyCheckStation, + buddygroupname: str, + widedf: pd.DataFrame, + min_sample_size: int, + min_std: float, + outlier_threshold: float, + iteration: int, + check_type: str = 'spatial_check', +) -> pd.MultiIndex: + """Find outliers in a buddy group and update station flags/details. + + This function tests whether the center station is an outlier compared to + its buddy stations using z-score analysis. The z-score is computed using + the mean and standard deviation of the buddy stations only (the center + station's values are excluded from the sample distribution). + + Parameters + ---------- + centerwrapstation : BuddyCheckStation + The wrapped station at the center of the buddy group to be tested. + buddygroupname : str + The name of the buddy group to use. + widedf : pd.DataFrame + Wide-format DataFrame with stations as columns and timestamps as index. + min_sample_size : int + Minimum number of valid buddy samples required for z-score calculation. + min_std : float + Minimum standard deviation to use (avoids division by near-zero). + outlier_threshold : float + Z-score threshold above which a record is flagged as outlier. + iteration : int + The current iteration number. + check_type : str, optional + The type of check being performed ('spatial_check', 'safetynet_check:groupname'). + Default is 'spatial_check'. + + Returns + ------- + pd.MultiIndex + MultiIndex with levels ('name', 'datetime') containing only the outlier + records for the center station. + """ + + # Get buddies (excluding center station) + buddies = centerwrapstation.get_buddies(groupname=buddygroupname) + center_name = centerwrapstation.name + + # Subset to buddies only (for sample distribution) and center station + buddydf = widedf[buddies].copy() + center_series = widedf[center_name].copy() + + # Count valid buddy samples per timestamp (center station NOT included) + buddy_sample_sizes = buddydf.notna().sum(axis=1) + + # Mark timestamps where center station has no data as NOT_TESTED + no_data = pd.Series(BC_NOT_TESTED, index=center_series[center_series.isna()].index) + centerwrapstation.add_flags( + iteration=iteration, + flag_series=no_data, + column_name=check_type + ) + + + # Find timestamps where center station has data + center_has_data = center_series.notna() + #TODO: pass the flag BC_NOT_TESTED for timestamps where center has no data + + # Separate timestamps by sample size condition (only where center has data) + sufficient_samples_mask = (buddy_sample_sizes >= min_sample_size) & center_has_data + insufficient_samples_mask = (buddy_sample_sizes < min_sample_size) & center_has_data + + timestamps_with_sufficient = widedf.index[sufficient_samples_mask] + timestamps_insufficient = widedf.index[insufficient_samples_mask] + + # ---- Handle timestamps with insufficient buddy samples (BC_NO_BUDDIES) ---- + if not timestamps_insufficient.empty: + # Create flags for NO_BUDDIES + no_buddies_flags = pd.Series(BC_NO_BUDDIES, index=timestamps_insufficient) + centerwrapstation.add_flags( + iteration=iteration, + flag_series=no_buddies_flags, + column_name=check_type + ) + + # Create detail messages + no_buddies_details = pd.Series( + [f"Insufficient buddy sample size (n={int(buddy_sample_sizes.loc[ts])}, " + f"required={min_sample_size}) in {buddygroupname} buddy group " + f"centered on {center_name}" + for ts in timestamps_insufficient], + index=timestamps_insufficient + ) + centerwrapstation.add_spatial_details( + iteration=iteration, + detail_series=no_buddies_details + ) + + # ---- Handle timestamps with sufficient samples ---- + if timestamps_with_sufficient.empty: + # No timestamps to process, return empty MultiIndex + return pd.MultiIndex.from_tuples([], names=['name', 'datetime']) + + # Filter to rows with enough valid buddy samples + buddydf_filtered = buddydf.loc[timestamps_with_sufficient] + center_filtered = center_series.loc[timestamps_with_sufficient] + buddy_sample_sizes_filtered = buddy_sample_sizes.loc[timestamps_with_sufficient] + + # Compute z-scores for center station using buddy distribution + results_df = _compute_center_z_scores( + buddydf=buddydf_filtered, + center_values=center_filtered, + min_std=min_std, + outlier_threshold=outlier_threshold + ) + + # Separate flagged (outliers) and passed + outlier_timestamps = results_df.index[results_df['flagged']] + passed_timestamps = results_df.index[~results_df['flagged']] + + # ---- Update PASSED flags and details ---- + if not passed_timestamps.empty: + passed_flags = pd.Series(BC_PASSED, index=passed_timestamps) + centerwrapstation.add_flags( + iteration=iteration, + flag_series=passed_flags, + column_name=check_type + ) + + # Create detail messages for passed + passed_details = pd.Series( + [f"Passed {buddygroupname} check (z={results_df.loc[ts, 'z_score']:.2f}, " + f"threshold={outlier_threshold}, n={int(buddy_sample_sizes_filtered.loc[ts])}, " + f"mean={results_df.loc[ts, 'buddy_mean']:.2f}, " + f"std={results_df.loc[ts, 'buddy_std']:.2f})" + for ts in passed_timestamps], + index=passed_timestamps + ) + centerwrapstation.add_spatial_details( + iteration=iteration, + detail_series=passed_details + ) + + # ---- Update FLAGGED (outlier) flags and details ---- + if not outlier_timestamps.empty: + flagged_flags = pd.Series(BC_FLAGGED, index=outlier_timestamps) + centerwrapstation.add_flags( + iteration=iteration, + flag_series=flagged_flags, + column_name=check_type + ) + + # Create detail messages for outliers + outlier_details = pd.Series( + [f"Outlier in {buddygroupname} buddy group centered on {center_name} " + f"(z={results_df.loc[ts, 'z_score']:.2f}, threshold={outlier_threshold}, " + f"n={int(buddy_sample_sizes_filtered.loc[ts])}, mean={results_df.loc[ts, 'buddy_mean']:.2f}, " + f"std={results_df.loc[ts, 'buddy_std']:.2f})" + for ts in outlier_timestamps], + index=outlier_timestamps + ) + centerwrapstation.add_spatial_details( + iteration=iteration, + detail_series=outlier_details + ) + + # ---- Return outliers as MultiIndex ---- + if not outlier_timestamps.empty: + outlier_multiindex = pd.MultiIndex.from_arrays( + [[center_name] * len(outlier_timestamps), outlier_timestamps], + names=['name', 'datetime'] + ) + return outlier_multiindex + else: + return pd.MultiIndex.from_tuples([], names=['name', 'datetime']) + + +# def _update_details( +# wrapsta: BuddyCheckStation, +# detail_series: pd.Series, +# iteration: int, +# check_type: str +# ) -> None: +# """Update details dictionary for a wrapped station. + +# Parameters +# ---------- +# wrapsta : BuddyCheckStation +# The wrapped station to update. +# detail_series : pd.Series +# Series with DatetimeIndex containing detail messages. +# iteration : int +# The iteration number. +# check_type : str +# The check type (e.g., 'spatial_check', 'safetynet_check:groupname'). +# """ +# if detail_series.empty: +# return + +# # Handle safetynet_check with groupname +# if check_type.startswith('safetynet_check:'): +# groupname = check_type.split(':', 1)[1] +# if groupname not in wrapsta.details['safetynet_check']: +# wrapsta.details['safetynet_check'][groupname] = {} + +# if iteration not in wrapsta.details['safetynet_check'][groupname]: +# wrapsta.details['safetynet_check'][groupname][iteration] = detail_series +# else: +# existing = wrapsta.details['safetynet_check'][groupname][iteration] +# combined = pd.concat([existing, detail_series]) +# wrapsta.details['safetynet_check'][groupname][iteration] = combined[ +# ~combined.index.duplicated(keep='first') +# ].sort_index() +# else: +# # spatial_check or whitelist_check +# if iteration not in wrapsta.details[check_type]: +# wrapsta.details[check_type][iteration] = detail_series +# else: +# existing = wrapsta.details[check_type][iteration] +# combined = pd.concat([existing, detail_series]) +# wrapsta.details[check_type][iteration] = combined[ +# ~combined.index.duplicated(keep='first') +# ].sort_index() + + +# ------------------------------------------ +# Statistical sample scoring +# ------------------------------------------ + +def _compute_center_z_scores( + buddydf: pd.DataFrame, + center_values: pd.Series, + min_std: float, + outlier_threshold: float +) -> pd.DataFrame: + """Compute z-scores for center station using buddy distribution. + + The z-score is computed as the absolute deviation of the center station's + value from the mean of the buddy stations, divided by the standard + deviation of the buddy stations. The center station's values are NOT + included in the mean/std calculation. + + Parameters + ---------- + buddydf : pd.DataFrame + DataFrame with buddy stations as columns (center station excluded). + center_values : pd.Series + Series with the center station's values to test. + min_std : float + Minimum standard deviation to use (avoids division by near-zero). + outlier_threshold : float + Z-score threshold above which a record is flagged as outlier. + + Returns + ------- + pd.DataFrame + DataFrame with columns: 'z_score', 'flagged', 'buddy_mean', 'buddy_std'. + """ + # Compute mean and std from buddies only (center station excluded) + buddy_mean_series = buddydf.mean(axis=1) + buddy_std_series = buddydf.std(axis=1) + + # Replace std below minimum with the minimum (avoid division by near-zero) + buddy_std_series.loc[buddy_std_series < min_std] = np.float32(min_std) + + # Calculate z-score for center station + z_scores = (center_values - buddy_mean_series).abs() / buddy_std_series + + # Build result DataFrame + result_df = pd.DataFrame( + index=buddydf.index, + data={ + 'z_score': z_scores, + 'flagged': z_scores > outlier_threshold, + 'buddy_mean': buddy_mean_series, + 'buddy_std': buddy_std_series, + } + ) + return result_df \ No newline at end of file diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/whitesaving.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/whitesaving.py new file mode 100644 index 00000000..87c057bb --- /dev/null +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/whitesaving.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import logging +from typing import Union, List, Dict, TYPE_CHECKING + +import pandas as pd + + +logger = logging.getLogger("") + +from .findbuddies import filter_buddygroup_by_altitude +from .samplechecks import buddy_test_a_station +if TYPE_CHECKING: + from ..buddywrapstation import BuddyCheckStation + from ...whitelist import WhiteSet + + +def save_whitelist_records( + outliers: pd.MultiIndex, + wrappedstations: List[BuddyCheckStation], + whiteset: WhiteSet, + obstype: str, + iteration: int, +) -> pd.MultiIndex: + """Apply whitelist filtering to outliers and update station details. + + This function filters outlier records against a whitelist. Records that + match the whitelist are marked as 'saved' and removed from the outlier set. + Records that don't match the whitelist remain as outliers. + + Parameters + ---------- + outliers : pd.MultiIndex + MultiIndex with levels ('name', 'datetime') containing the current + outlier records to filter. + wrappedstations : list of BuddyCheckStation + List of wrapped station objects to update with whitelist details. + whiteset : WhiteSet + A WhiteSet instance containing records that should be excluded from + outlier detection. + obstype : str + The observation type being checked. + iteration : int + The current iteration number. + + Returns + ------- + pd.MultiIndex + MultiIndex with levels ('name', 'datetime') containing only the + outliers that were NOT saved by the whitelist. + """ + if outliers.empty: + logger.debug("No outliers to filter with whitelist") + return outliers + + if whiteset._flag_is_empty(): + logger.debug("Whitelist is empty, no records saved") + return outliers + + # Create a name map of the wrapped stations + name_map = {sta.name: sta for sta in wrappedstations} + + # Track which records are not saved + # saved_records = pd.MultiIndex.from_tuples([], names=['name', 'datetime']) + remaining_outliers = pd.MultiIndex.from_tuples([], names=['name', 'datetime']) + + # Process each station's outliers + for wrapsta in wrappedstations: + if wrapsta.name not in outliers.get_level_values("name").unique(): + continue # No outliers for this station + + else: + + # Get the outlier datetimes for this station + sta_outlier_dts = pd.DatetimeIndex( + outliers[outliers.get_level_values("name") == wrapsta.name].get_level_values("datetime"), + name="datetime" + ) + + # Create a SensorWhiteSet for this station + sensorwhiteset = whiteset.create_sensorwhitelist( + stationname=wrapsta.name, obstype=obstype + ) + + # Filter to get remaining outliers (not whitelisted) + remaining_dts = sensorwhiteset.catch_white_records(outliers_idx=sta_outlier_dts) + + # Saved records are those that were filtered out + saved_dts = sta_outlier_dts.difference(remaining_dts) + + # Build MultiIndex for saved and remaining + # if len(saved_dts) > 0: + # saved_idx = pd.MultiIndex.from_arrays( + # [[wrapsta.name] * len(saved_dts), saved_dts], + # names=['name', 'datetime'] + # ) + # saved_records = saved_records.union(saved_idx) + + # Update the wrapped station with saved details + + # Create detail messages for saved records + detail_series = pd.Series( + [f"Saved by whitelist at iteration {iteration}" for _ in saved_dts], + index=saved_dts + ) + wrapsta.update_whitelist_details( + whitelistseries=detail_series, + iteration=iteration, + is_saved=True + ) + + # Create detail messages for records not saved by whitelist + detail_series = pd.Series( + [f"Not saved by whitelist at iteration {iteration}" for _ in remaining_dts], + index=remaining_dts + ) + wrapsta.update_whitelist_details( + whitelistseries=detail_series, + iteration=iteration, + is_saved=False + ) + + if len(remaining_dts) > 0: + remaining_idx = pd.MultiIndex.from_arrays( + [[wrapsta.name] * len(remaining_dts), remaining_dts], + names=['name', 'datetime'] + ) + remaining_outliers = remaining_outliers.union(remaining_idx) + + + + + return remaining_outliers.sort_values() + From aa94f0c7c26bdadd37fc5b29f1bf9c9244ec310f Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Tue, 20 Jan 2026 15:30:34 +0100 Subject: [PATCH 05/57] minor version bump --- pyproject.toml | 2 +- src/metobs_toolkit/settings_collection/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 65e97cfe..8bb86e87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["poetry-core"] [project] name = "MetObs-toolkit" -version = "1.0.0a1" +version = "1.0.0a2" license = "LICENSE" authors = [{name = "Thomas Vergauwen", email = "thomas.vergauwen@ugent.be"}] description = "A Meteorological observations toolkit for scientists" diff --git a/src/metobs_toolkit/settings_collection/version.py b/src/metobs_toolkit/settings_collection/version.py index 6e45a91d..65590392 100644 --- a/src/metobs_toolkit/settings_collection/version.py +++ b/src/metobs_toolkit/settings_collection/version.py @@ -1 +1 @@ -__version__ = "1.0.0a1" +__version__ = "1.0.0a2" From 7a830b46785d7c8aa4260a8e75aadd6089414358 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Tue, 20 Jan 2026 15:31:13 +0100 Subject: [PATCH 06/57] fix qc_stats --- src/metobs_toolkit/dataset.py | 83 ++++-- .../plot_collection/qc_info_pies.py | 251 +++++++++++++----- src/metobs_toolkit/station.py | 66 +++-- 3 files changed, 281 insertions(+), 119 deletions(-) diff --git a/src/metobs_toolkit/dataset.py b/src/metobs_toolkit/dataset.py index 6f622355..19b97bdf 100644 --- a/src/metobs_toolkit/dataset.py +++ b/src/metobs_toolkit/dataset.py @@ -12,9 +12,11 @@ import numpy as np import concurrent.futures + if TYPE_CHECKING: from matplotlib.pyplot import Axes from xarray import Dataset as xrDataset + from matplotlib.pyplot import Figure from metobs_toolkit.backend_collection.df_helpers import ( save_concat, @@ -2359,29 +2361,80 @@ def buddy_check_with_safetynets( - @copy_doc(Station.get_qc_stats) @log_entry def get_qc_stats( self, obstype: str = "temp", make_plot: bool = True - ) -> Union[pd.DataFrame, None]: - freqdf_list = [ - sta.get_qc_stats(obstype=obstype, make_plot=False) for sta in self.stations + ) -> Union[dict[str, pd.Series], Figure]: + """ + Summarize QC label frequencies across all stations for a given observation type. + + This aggregates three series over every station that has the requested ``obstype``: + + * final label counts from each ``SensorData.df['label'].value_counts()``; + * outlier-only label counts from ``SensorData.outliersdf['label'].value_counts()``; + * per-check outcome counts from ``SensorData.get_qc_freq_statistics()`` + (MultiIndex ``['checkname', 'flag']``). + + When ``make_plot`` is True, the aggregated counts are visualized with + ``plotting.qc_overview_pies``. When False, the aggregated series are returned for + programmatic use. + + Parameters + ---------- + obstype : str, optional + Observation type to evaluate, by default "temp". + make_plot : bool, optional + If True, return a figure with pie charts; if False, return the aggregated counts. + Default is True. + + Returns + ------- + matplotlib.figure.Figure or dict[str, pandas.Series] + Figure with QC overview pies when ``make_plot`` is True; otherwise a dictionary with + keys ``all_labels``, ``outlier_labels``, and ``per_check_labels``. Returns None when + no stations provide the requested ``obstype``. + """ + + # collect stations that actually have the target obstype + target_stations = [ + sta for sta in self.stations if obstype in sta.obsdata ] - dfagg = ( - pd.concat(freqdf_list) - .reset_index() - .groupby(["qc_check"]) - .sum() - .drop(columns=["name"]) - ) + if not target_stations: + logger.warning("No stations with obstype '%s' found for QC stats.", obstype) + return None + + + all_label_counts = self.df.xs(obstype, level="obstype")["label"].value_counts() + outlier_label_counts = self.outliersdf.xs(obstype, level="obstype")["label"].value_counts() + + + per_check_counts = [] + for sta in target_stations: + sensor_counts = sta.get_sensor(obstype).get_qc_freq_statistics() + #add the name of the station as a index level + sensor_counts.index = pd.MultiIndex.from_frame( + sensor_counts.index.to_frame().assign(name=sta.name)) + + per_check_counts.append(sensor_counts) + + per_check_counts = pd.concat(per_check_counts).groupby(level=["checkname", "flag"]).sum() if make_plot: - fig = plotting.qc_overview_pies(df=dfagg) - fig.suptitle(f"QC frequency statistics of {obstype} on Dataset level.") + fig = plotting.qc_overview_pies( + end_labels_from_df=all_label_counts, + end_labels_from_outliers=outlier_label_counts, + per_check_labels=per_check_counts, + fig_title = f"QC frequency statistics of {obstype} on Dataset level." + ) + return fig - else: - return dfagg + + return { + "all_labels": all_label_counts, + "outlier_labels": outlier_label_counts, + "per_check_labels": per_check_counts, + } # ------------------------------------------ # Other methods diff --git a/src/metobs_toolkit/plot_collection/qc_info_pies.py b/src/metobs_toolkit/plot_collection/qc_info_pies.py index 447a29ec..fc602062 100644 --- a/src/metobs_toolkit/plot_collection/qc_info_pies.py +++ b/src/metobs_toolkit/plot_collection/qc_info_pies.py @@ -13,28 +13,40 @@ logger = logging.getLogger("") +def autopct_format(pct): + return f'{pct:.1f}%' if pct > 0 else '' + @log_entry def qc_overview_pies( - df: pd.DataFrame, + end_labels_from_df: pd.Series, + end_labels_from_outliers: pd.Series, + per_check_labels: pd.Series, + fig_title: str = "" + ) -> plt.Figure: """ - Generate a quality control (QC) overview using pie charts. + Generate a QC overview figure with pie charts for label frequencies and per-check outcomes. Parameters ---------- - df : pandas.DataFrame - DataFrame containing QC data. Must include columns 'N_labeled', 'N_all', and 'N_checked'. + end_labels_from_df : pandas.Series + Counts of final labels for all records (index as labels), typically from + ``SensorData.df['label'].value_counts()``. + end_labels_from_outliers : pandas.Series + Counts limited to outlier records (index as labels), e.g. from + ``SensorData.outliersdf['label'].value_counts()``. If empty, a single slice + "No QC outliers" is drawn. + per_check_labels : pandas.Series + MultiIndex Series with index levels ``['checkname', 'flag']`` containing counts per + QC check outcome (flags such as ``flagged_cond``, ``pass_cond``, ``unmet_cond``, etc.), + as returned by ``SensorData.get_qc_freq_statistics()``. Returns ------- matplotlib.figure.Figure - The generated figure containing the QC overview pie charts. - - Raises - ------ - TypeError - If any of the arguments are not of the expected type. + Figure containing two large pies (all labels and outlier labels) and one small pie per + QC check showing the distribution of its outcomes. """ # Define layout @@ -48,97 +60,198 @@ def qc_overview_pies( ax_thr = fig.add_subplot(spec[0, 2:]) # top half right # Frequency with all - plotdf = df - colors = [Settings._get_color_from_label(label) for label in plotdf.index] - plotdf.plot( + colors = [Settings._get_color_from_label(label) for label in end_labels_from_df.index] + end_labels_from_df.plot( ax=ax_thl, kind="pie", - y="N_labeled", - autopct="%1.1f%%", + y="", + autopct=autopct_format, legend=False, colors=colors, radius=Settings.get("plotting_settings.pie_charts.radius_big"), - fontsize=Settings.get("plotting_settings.pie_charts.txt_size_big_pies"), + fontsize=Settings.get("plotting_settings.pie_charts.txt_label_size_big_pies"), ) - ax_thl.set_title("Label frequencies") + ax_thl.set_title("Label frequencies", **Settings.get("plotting_settings.pie_charts.big_pie_title_kwargs")) ax_thl.set_ylabel("") - # Outliers comparison - plotdf = df[ - ~df.index.isin( - [ - Settings.get("label_def.goodrecord.label"), - Settings.get("label_def.regular_gap.label"), - ] - ) - ] - - colors = [Settings._get_color_from_label(label) for label in plotdf.index] + # Only outliers + + colors = [Settings._get_color_from_label(label) for label in end_labels_from_outliers.index] - if plotdf.empty: + if end_labels_from_outliers.empty: # No outliers --> full pie with "No QC outliers" in the color of 'ok' - plotdf = pd.DataFrame( - data={"N_labeled": [100]}, index=pd.Index(data=["No QC outliers"]) - ) + end_labels_from_outliers = pd.Series([100], index=["No QC outliers"]) colors = [Settings.get("label_def.goodrecord.color")] - plotdf.plot( + end_labels_from_outliers.plot( ax=ax_thr, kind="pie", - y="N_labeled", - autopct="%1.1f%%", + y="", + autopct=autopct_format, legend=False, colors=colors, radius=Settings.get("plotting_settings.pie_charts.radius_big"), - fontsize=Settings.get("plotting_settings.pie_charts.txt_size_big_pies"), + fontsize=Settings.get("plotting_settings.pie_charts.txt_label_size_big_pies"), ) - ax_thr.set_title("Outlier specific frequencies") + ax_thr.set_title("Outlier specific frequencies", **Settings.get("plotting_settings.pie_charts.big_pie_title_kwargs")) ax_thr.set_ylabel("") # Performance per check - plotdf = df[ - ~df.index.isin( - [ - Settings.get("label_def.goodrecord.label"), - Settings.get("label_def.regular_gap.label"), - ] - ) - ] - - # Label to QC check name map - label_too_qcname_map = Settings._label_to_qccheckmap() + per_qc_colmap = {val['label']: val['plotkwargs']['color'] for val in Settings.get('qc_status_labels_per_check').values()} + i = 0 - for idx, row in plotdf.iterrows(): - # Target a specific axes + for checkname in per_check_labels.index.get_level_values('checkname').unique(): subax = fig.add_subplot(spec[math.floor(i / ncol) + 1, i % ncol]) - - # Construct a plot Series - plotseries = pd.Series( - { - Settings.get("label_def.uncheckedrecord.label"): row["N_all"] - - row["N_checked"], - Settings.get("label_def.goodrecord.label"): row["N_checked"] - - row["N_labeled"], - Settings.get("label_def.outlier.label"): row["N_labeled"], - } - ) - # Define colors - colors = [Settings._get_color_from_label(label) for label in plotseries.index] - plotseries.plot( + + checkname_subset = per_check_labels.loc[checkname] + colors = [per_qc_colmap.get(label, 'gray') for label in checkname_subset.index] + + checkname_subset.plot( ax=subax, kind="pie", - autopct="%1.1f%%", + autopct=autopct_format, legend=False, colors=colors, radius=Settings.get("plotting_settings.pie_charts.radius_small"), - fontsize=Settings.get("plotting_settings.pie_charts.txt_size_small_pies"), + fontsize=Settings.get("plotting_settings.pie_charts.txt_label_size_small_pies"), ) - subax.set_title(f"Effectiveness of {label_too_qcname_map[idx]}") + subax.set_title(f"{checkname}", **Settings.get("plotting_settings.pie_charts.small_pie_title_kwargs")) subax.set_ylabel("") i += 1 - - logger.debug("Exiting qc_overview_pies function.") + + + fig.suptitle(fig_title, **Settings.get("plotting_settings.pie_charts.fig_title_kwargs")) return fig + + +# @log_entry +# def qc_overview_pies( +# df: pd.DataFrame, +# ) -> plt.Figure: +# """ +# Generate a quality control (QC) overview using pie charts. + +# Parameters +# ---------- +# df : pandas.DataFrame +# DataFrame containing QC data. Must include columns 'N_labeled', 'N_all', and 'N_checked'. + +# Returns +# ------- +# matplotlib.figure.Figure +# The generated figure containing the QC overview pie charts. + +# Raises +# ------ +# TypeError +# If any of the arguments are not of the expected type. +# """ + +# # Define layout +# ax = create_axes(**Settings.get("plotting_settings.pie_charts.figkwargs")) +# ax.set_axis_off() +# fig = ax.get_figure() + +# ncol = Settings.get("plotting_settings.pie_charts.ncols") +# spec = fig.add_gridspec(4, ncol) +# ax_thl = fig.add_subplot(spec[0, :2]) # top half left +# ax_thr = fig.add_subplot(spec[0, 2:]) # top half right + +# # Frequency with all +# plotdf = df +# colors = [Settings._get_color_from_label(label) for label in plotdf.index] +# plotdf.plot( +# ax=ax_thl, +# kind="pie", +# y="N_labeled", +# autopct="%1.1f%%", +# legend=False, +# colors=colors, +# radius=Settings.get("plotting_settings.pie_charts.radius_big"), +# fontsize=Settings.get("plotting_settings.pie_charts.txt_size_big_pies"), +# ) +# ax_thl.set_title("Label frequencies") +# ax_thl.set_ylabel("") + +# # Outliers comparison +# plotdf = df[ +# ~df.index.isin( +# [ +# Settings.get("label_def.goodrecord.label"), +# Settings.get("label_def.regular_gap.label"), +# ] +# ) +# ] + +# colors = [Settings._get_color_from_label(label) for label in plotdf.index] + +# if plotdf.empty: +# # No outliers --> full pie with "No QC outliers" in the color of 'ok' +# plotdf = pd.DataFrame( +# data={"N_labeled": [100]}, index=pd.Index(data=["No QC outliers"]) +# ) +# colors = [Settings.get("label_def.goodrecord.color")] + +# plotdf.plot( +# ax=ax_thr, +# kind="pie", +# y="N_labeled", +# autopct="%1.1f%%", +# legend=False, +# colors=colors, +# radius=Settings.get("plotting_settings.pie_charts.radius_big"), +# fontsize=Settings.get("plotting_settings.pie_charts.txt_size_big_pies"), +# ) +# ax_thr.set_title("Outlier specific frequencies") +# ax_thr.set_ylabel("") + +# # Performance per check +# plotdf = df[ +# ~df.index.isin( +# [ +# Settings.get("label_def.goodrecord.label"), +# Settings.get("label_def.regular_gap.label"), +# ] +# ) +# ] + +# # Label to QC check name map +# label_too_qcname_map = Settings._label_to_qccheckmap() + +# i = 0 +# for idx, row in plotdf.iterrows(): +# # Target a specific axes +# subax = fig.add_subplot(spec[math.floor(i / ncol) + 1, i % ncol]) + +# # Construct a plot Series +# plotseries = pd.Series( +# { +# Settings.get("label_def.uncheckedrecord.label"): row["N_all"] +# - row["N_checked"], +# Settings.get("label_def.goodrecord.label"): row["N_checked"] +# - row["N_labeled"], +# Settings.get("label_def.outlier.label"): row["N_labeled"], +# } +# ) +# # Define colors +# colors = [Settings._get_color_from_label(label) for label in plotseries.index] +# plotseries.plot( +# ax=subax, +# kind="pie", +# autopct="%1.1f%%", +# legend=False, +# colors=colors, +# radius=Settings.get("plotting_settings.pie_charts.radius_small"), +# fontsize=Settings.get("plotting_settings.pie_charts.txt_size_small_pies"), +# ) + +# subax.set_title(f"Effectiveness of {label_too_qcname_map[idx]}") +# subax.set_ylabel("") + +# i += 1 + +# logger.debug("Exiting qc_overview_pies function.") +# return fig diff --git a/src/metobs_toolkit/station.py b/src/metobs_toolkit/station.py index f3feb61d..f329ffab 100644 --- a/src/metobs_toolkit/station.py +++ b/src/metobs_toolkit/station.py @@ -46,7 +46,7 @@ from metobs_toolkit.io_collection.filewriters import fmt_output_filepath if TYPE_CHECKING: - from matplotlib.pyplot import Axes + from matplotlib.pyplot import Axes, Figure from os import PathLike from xarray import Dataset as xrDataset from metobs_toolkit.sensordata import SensorData @@ -1615,56 +1615,52 @@ def window_variation_check( @log_entry def get_qc_stats( self, obstype: str = "temp", make_plot: bool = True - ) -> pd.DataFrame: + ) -> Union[dict[str, pd.Series], Figure]: """ - Generate quality control (QC) frequency statistics. + Summarize QC label frequencies for one station and optionally plot pies. - This method calculates the frequency statistics for various QC checks - applied, and other labels. The order of checks is taken into - account. - - Frequency of labels is computed based on the set of all labels (for all - records including gaps). The effectiveness of a check is shown by - the frequency of outliers with respect to the number of records that were given - to the check (thus taking into account the order of checks). - - The frequencies are returned in a dataframe, and can be plotted - as pie charts. + The method gathers: + * final label counts across all records (including gaps) from ``SensorData.df``; + * outlier-only label counts from ``SensorData.outliersdf``; + * per-check outcome counts (flags per QC check) via ``SensorData.get_qc_freq_statistics``. + When ``make_plot`` is True, these counts are visualized with + ``plotting.qc_overview_pies``. Parameters ---------- obstype : str, optional - The target observation type for which to compute frequency statistics, by default "temp". + Observation type to evaluate, by default "temp". make_plot : bool, optional - If True, a figure with pie charts representing the frequencies is generated. The default is True. + If True, return a figure with pie charts; if False, no figure is created. Default is True. Returns ------- - pandas.DataFrame - A DataFrame containing the QC frequency statistics. The DataFrame - has a multi-index with the station name and QC check label, and - includes the following columns: - - * `N_all`: Total number of records in the dataset (including gaps). - * `N_labeled`: Number of records with the specific label. - * `N_checked`: Number of records checked for the specific QC check. - + matplotlib.figure.Figure or dict[str, pandas.Series] + Figure with QC overview pies when ``make_plot`` is True; otherwise a dictionary with + keys ``all_labels``, ``outlier_labels``, and ``per_check_labels``. """ # argument checks self._obstype_is_known_check(obstype) # get freq statistics - qc_df = self.get_sensor(obstype).get_qc_freq_statistics() - + qc_specific_counts = self.get_sensor(obstype).get_qc_freq_statistics() + qc_labels_from_df = self.get_sensor(obstype).df['label'].value_counts() + qc_labels_from_outliers = self.get_sensor(obstype).outliersdf['label'].value_counts() + if make_plot: - plotdf = qc_df.reset_index().drop(columns=["name"]).set_index("qc_check") - - fig = plotting.qc_overview_pies(df=plotdf) - fig.suptitle( - f"QC frequency statistics of {obstype} on Station level: {self.name}." - ) + fig = plotting.qc_overview_pies( + end_labels_from_df=qc_labels_from_df, + end_labels_from_outliers=qc_labels_from_outliers, + per_check_labels=qc_specific_counts, + fig_title = f"QC frequency statistics of {obstype} on Sensor level: {self.name}.") + return fig - else: - return qc_df + + return { + "all_labels": qc_labels_from_df, + "outlier_labels": qc_labels_from_outliers, + "per_check_labels": qc_specific_counts, + } + @log_entry def make_plot_of_modeldata( From a6ed7ee8591001d5c564f48e9cb04f8b85f8c6d3 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Tue, 20 Jan 2026 15:32:18 +0100 Subject: [PATCH 07/57] labels defined in settings --- src/metobs_toolkit/qcresult.py | 11 ++- src/metobs_toolkit/sensordata.py | 96 ++++++++++++------- .../settings_collection/label_defenitions.py | 23 +++++ .../settings_collection/plotting_defaults.py | 8 +- .../settings_collection/settings.py | 4 + 5 files changed, 98 insertions(+), 44 deletions(-) diff --git a/src/metobs_toolkit/qcresult.py b/src/metobs_toolkit/qcresult.py index f35d6720..eaba5e74 100644 --- a/src/metobs_toolkit/qcresult.py +++ b/src/metobs_toolkit/qcresult.py @@ -10,11 +10,12 @@ logger = logging.getLogger("") -pass_cond = 'passed' #checked and successfull pass -flagged_cond = 'flagged' # checked and flagged as outlier -unmet_cond = 'condition_unmet' #not checked due to unmet specific conditions -saved_cond = 'saved' #checked and flagged but saved due to whitelist -unchecked_cond = 'unchecked' #not checked (was nan/gap before check) + +pass_cond = Settings.get("qc_status_labels_per_check.pass.label") #checked and successfull pass +flagged_cond = Settings.get("qc_status_labels_per_check.flagged.label") # checked and flagged as outlier +unmet_cond = Settings.get("qc_status_labels_per_check.condition_unmet.label") #not checked due to unmet specific conditions +saved_cond = Settings.get("qc_status_labels_per_check.saved_whitelist.label") #checked and flagged but saved due to whitelist +unchecked_cond = Settings.get("qc_status_labels_per_check.unchecked.label") #not checked (was nan/gap before check) class QCresult: """Store results of a quality control check. diff --git a/src/metobs_toolkit/sensordata.py b/src/metobs_toolkit/sensordata.py index 75faf957..e39cff37 100644 --- a/src/metobs_toolkit/sensordata.py +++ b/src/metobs_toolkit/sensordata.py @@ -39,6 +39,7 @@ flagged_cond, unmet_cond, saved_cond, + unchecked_cond ) from metobs_toolkit.plot_collection import sensordata_simple_pd_plot import metobs_toolkit.backend_collection.printing_collection as printing @@ -914,43 +915,64 @@ def get_qc_freq_statistics(self) -> pd.DataFrame: excluded from the check due to previous QC checks. """ - - infodict = {} # checkname : details - ntotal = self.series.shape[0] # gaps included !! - already_rejected = self.gapsdf.shape[0] # initial gap records - # add the 'ok' labels - infodict[Settings.get("label_def.goodrecord.label")] = { - "N_all": ntotal, - "N_labeled": self.series[self.series.notnull()].shape[0], - } - # add the 'gap' labels - # TODO: I think the filled and failed labels must be included as well - infodict[Settings.get("label_def.regular_gap.label")] = { - "N_all": ntotal, - "N_labeled": already_rejected, - } - - # add the qc check labels - for check in self.outliers: - n_outliers = check["df"].shape[0] - n_checked = ntotal - already_rejected - outlierlabel = Settings.get(f"label_def.{check['checkname']}.label") - infodict[outlierlabel] = { - "N_labeled": n_outliers, - "N_checked": n_checked, - "N_all": ntotal, - } - - # remove the outliers of the previous check - already_rejected = already_rejected + n_outliers - - # Convert to a dataframe - checkdf = pd.DataFrame(infodict).transpose() - checkdf.index.name = "qc_check" - checkdf["name"] = self.stationname - checkdf = checkdf.reset_index().set_index(["name", "qc_check"]) - - return checkdf + + + empty_flags = { + flagged_cond: 0, + pass_cond: 0, + unmet_cond: 0, + saved_cond: 0, + unchecked_cond: 0} + + qcdict = {} + for qcres in self.outliers: + qcdict[qcres.checkname] = empty_flags | qcres.flags.value_counts().to_dict() + + #Convert to a pandas series with multiindex ['checkname', 'flag'] and the name is 'counts' + + qcdf = pd.DataFrame.from_dict(qcdict, orient='index') + qcdf.index.name = 'checkname' + qcseries = qcdf.stack(future_stack=True) + qcseries.index = qcseries.index.set_names('flag', level=-1) + qcseries.name = 'counts' + return qcseries + + # infodict = {} # checkname : details + # ntotal = self.series.shape[0] # gaps included !! + # already_rejected = self.gapsdf.shape[0] # initial gap records + # # add the 'ok' labels + # infodict[Settings.get("label_def.goodrecord.label")] = { + # "N_all": ntotal, + # "N_labeled": self.series[self.series.notnull()].shape[0], + # } + # # add the 'gap' labels + # # TODO: I think the filled and failed labels must be included as well + # infodict[Settings.get("label_def.regular_gap.label")] = { + # "N_all": ntotal, + # "N_labeled": already_rejected, + # } + + # # add the qc check labels + # for check in self.outliers: + # n_outliers = check["df"].shape[0] + # n_checked = ntotal - already_rejected + # outlierlabel = Settings.get(f"label_def.{check['checkname']}.label") + # infodict[outlierlabel] = { + # "N_labeled": n_outliers, + # "N_checked": n_checked, + # "N_all": ntotal, + # } + + # # remove the outliers of the previous check + # already_rejected = already_rejected + n_outliers + + # # Convert to a dataframe + # checkdf = pd.DataFrame(infodict).transpose() + # checkdf.index.name = "qc_check" + # checkdf["name"] = self.stationname + # checkdf = checkdf.reset_index().set_index(["name", "qc_check"]) + + # return checkdf # ------------------------------------------ # Gaps related diff --git a/src/metobs_toolkit/settings_collection/label_defenitions.py b/src/metobs_toolkit/settings_collection/label_defenitions.py index 8b2d0607..13877ae0 100644 --- a/src/metobs_toolkit/settings_collection/label_defenitions.py +++ b/src/metobs_toolkit/settings_collection/label_defenitions.py @@ -193,3 +193,26 @@ "buddy_check", "buddy_check_with_safetynets", ] + + +# ============================================================================ +# labels per QC check +# ============================================================================ + +per_check_possible_labels = { + 'pass': {'label':'passed', + "plotkwargs": {"color": "#00a824"} + }, + 'flagged': {'label':'flagged', + "plotkwargs": {"color": "#f0051c"} + }, + 'condition_unmet': {'label':'condition_unmet', + "plotkwargs": {"color": "#808080"} + }, + 'saved_whitelist': {'label':'saved', + "plotkwargs": {"color": "#0000ff"} + }, + 'unchecked': {'label':'unchecked', + "plotkwargs": {"color": "#f7cf07"} + }, +} \ No newline at end of file diff --git a/src/metobs_toolkit/settings_collection/plotting_defaults.py b/src/metobs_toolkit/settings_collection/plotting_defaults.py index ba0e7aa4..baadf72e 100644 --- a/src/metobs_toolkit/settings_collection/plotting_defaults.py +++ b/src/metobs_toolkit/settings_collection/plotting_defaults.py @@ -39,8 +39,12 @@ # "anchor_legend_small": (-3.5, 2.2), "radius_big": 1.0, "radius_small": 0.7, - "txt_size_big_pies": 7, - "txt_size_small_pies": 5, + "txt_label_size_big_pies": 7, + "txt_label_size_small_pies": 5, + "fig_title_kwargs": {"fontsize": 16}, + "big_pie_title_kwargs": {"fontsize": 14}, + "small_pie_title_kwargs": {"fontsize": 10}, + } # ============================================================================= diff --git a/src/metobs_toolkit/settings_collection/settings.py b/src/metobs_toolkit/settings_collection/settings.py index 06bb6776..b638146f 100644 --- a/src/metobs_toolkit/settings_collection/settings.py +++ b/src/metobs_toolkit/settings_collection/settings.py @@ -26,7 +26,9 @@ scatter, line, vline, + per_check_possible_labels, ) + from metobs_toolkit.settings_collection.plotting_defaults import default_plot_settings logger = logging.getLogger("") @@ -74,6 +76,8 @@ class Settings: "gapfill_label_group": gapfill_label_group, "failed_gapfill_label_group": failed_gapfill_label_group, "qc_label_group": qc_label_group, + "qc_status_labels_per_check": per_check_possible_labels, + # Logging defaults "log_level": "WARNING", "log_format": "LOG:: %(levelname)s - %(message)s", From 6a9f5c140f136b46ef382515ada1b518614fbfa8 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Tue, 20 Jan 2026 15:32:40 +0100 Subject: [PATCH 08/57] bugfix when empyt outliers --- .../qc_collection/repetitions_check.py | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/metobs_toolkit/qc_collection/repetitions_check.py b/src/metobs_toolkit/qc_collection/repetitions_check.py index 5304d09b..0c97f278 100644 --- a/src/metobs_toolkit/qc_collection/repetitions_check.py +++ b/src/metobs_toolkit/qc_collection/repetitions_check.py @@ -70,23 +70,24 @@ def repetitions_check( outlier_groups = group_sizes[group_sizes > max_N_repetitions] - # if outlier_groups.empty: - # logger.debug("No outliers detected. Exiting repetitions_check function.") - - # return timestamps_to_datetimeindex( - # timestamps=[], name="datetime", current_tz=None - # ) + if outlier_groups.empty: + logger.debug("No outliers detected. Exiting repetitions_check function.") + outliers_idx = timestamps_to_datetimeindex( + timestamps=[], name="datetime", current_tz=None + ) + outliers = pd.Series(index=outliers_idx) + else: - # Combine all outlier groups - outliers = pd.concat( - [ - groups.get_group( - outlgroup, - ) - for outlgroup in outlier_groups.index - ] - ) - logger.debug("Outliers detected. Exiting repetitions_check function.") + # Combine all outlier groups + outliers = pd.concat( + [ + groups.get_group( + outlgroup, + ) + for outlgroup in outlier_groups.index + ] + ) + # Catch the white records outliers_after_white_idx = sensorwhiteset.catch_white_records(outliers.index) From e512a3cd50a1ff1729f8f52bd520765952c237fb Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Tue, 20 Jan 2026 15:33:55 +0100 Subject: [PATCH 09/57] fix test --- .../qc_collection/step_check.py | 2 +- .../all_labels/datatype.json | 1 + .../all_labels/solution_series.parquet | Bin 0 -> 2247 bytes .../test_qc_stats_check/datatype.json | 2 +- .../outlier_labels/datatype.json | 1 + .../outlier_labels/solution_series.parquet | Bin 0 -> 2225 bytes .../per_check_labels/datatype.json | 1 + .../per_check_labels/solution_series.parquet | Bin 0 -> 2893 bytes tests/solutionclass.py | 13 +++++++ tests/test_qc.py | 36 +++++++++--------- 10 files changed, 37 insertions(+), 19 deletions(-) create mode 100644 tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/all_labels/datatype.json create mode 100644 tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/all_labels/solution_series.parquet create mode 100644 tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/outlier_labels/datatype.json create mode 100644 tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/outlier_labels/solution_series.parquet create mode 100644 tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/per_check_labels/datatype.json create mode 100644 tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/per_check_labels/solution_series.parquet diff --git a/src/metobs_toolkit/qc_collection/step_check.py b/src/metobs_toolkit/qc_collection/step_check.py index 2644185a..67d63e00 100644 --- a/src/metobs_toolkit/qc_collection/step_check.py +++ b/src/metobs_toolkit/qc_collection/step_check.py @@ -106,7 +106,7 @@ def step_check( #Create and add details if not outliers_after_white_idx.empty: detailseries = pd.Series( - data = 'step >' + str(max_increase_per_second) + ' per second or step <' + str(max_decrease_per_second) + ' per second', + data = f'step > {max_increase_per_second:.4g} per second or step < {max_decrease_per_second:.4g} per second', index = outliers_after_white_idx ) qcresult.add_details_by_series(detail_series = detailseries) diff --git a/tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/all_labels/datatype.json b/tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/all_labels/datatype.json new file mode 100644 index 00000000..bbf7c5bc --- /dev/null +++ b/tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/all_labels/datatype.json @@ -0,0 +1 @@ +{"class": "Series"} \ No newline at end of file diff --git a/tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/all_labels/solution_series.parquet b/tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/all_labels/solution_series.parquet new file mode 100644 index 0000000000000000000000000000000000000000..99756aa49d0c8fa24cb8fa26b542ee11979264a4 GIT binary patch literal 2247 zcmbVOOK%!i6uw}Bv6@IY(GZ9%m5vmYNW|q~k`g6SFT~)XwiADbE3n?K$GjKAaDFYyJAUt_;ht$nlt&ynohg2mMx-6uY= zEiZ2ezCm=C12M7kp07LWxBUFbwF74ZWLn@DM3sGA%lpLCJzqAR^+M`1gktq#NG#0` z^T)DFS)3xnbP;zwFBM`?@^}M|n#BT3EFwX-kBLK-F-NlL@qIsvTH9;;z%ZAuGCZ7wyvGGU?n#u2?`DTq0LP$#gC< z<>Hxi=C^p}hj`(;Lh9q%kt;$65En=ANW=2velk};X~;(NTwmm2Fj$_S6Pb4_g?|!- zALASUmxfD8Ge5<1i@ce?l9_ZNm3fmyxnefy$d)F1*?(6~d1#+LJydNYFfEUNod1Sr zu=sC_d44WEWF|UOp7NG#>R@ICh5;5sq8r-bMcF*~p+9q|As<*owKbgwYb!9F*-~LE z6J7PG+|+$plYJS?PkA^rwnnTG^?mhq>%kZN+1YzA7f}F0=qe3y0myst2Uv*ZKis;E zWGOxegcf*K1Uyl35iOTe&DjF+2dO@;Y?h5yDf_-ll)z_np8^10b#>XdUB-<;6jyd< zlp1FyyRIG5Vvgyw*%p;ns+*NdiJm;00)y*n@hO*ajxBFatJfD#4pR1qKt3GOC zj1SEJFdpINh{+m_3;K`>x0m{6Nd4g*E*6F91&l)sez3A2+jN0E?MUj64Rt0w z6k0y`*T#zYiuweaIQ4d$m`J8!DDCc0FYj($t+aCDOo1OZwcLDnewHK=%S>mE~ReOQf6eu=R7JHu5ss{tHY*1SP;%Z`Tp4V!O z6|*~5%k`4pZ$x?{(4GwQ0im2IVy`q1yJM}rz2s{6GNky=<%>9HJV=ft zPLKMP1-}rZ-6E10vUqB{1$}FsoaYI!I)h662zaISKtIZ%-aC)CPGqw;iR$$w;1FVz z2Fo5z>wUl^n5~kw({Tn?uO#)^d3Z#z0v|I;Y?YL%Kx=^BPr;@$R;?q}Cw0ewedk!4 zvHTUO-#L-Rdce4x2q(J}9PB|~C?D_fONMIgJ4$7X#n>ZTV7Etx^GRsaqq?w#Ej{D7 zOCRizKYK+-2VNSDxlpAVcE0Hb)A_+T;*D-$J`U!3Fajma#Q--#yth=+4Gl47D;t^* z;{nHI#bN;2d;zGPXug>59QxCzD4Ta=_jREA4^+o_z&;rH=!1}-e=2fC_z|SPgO64b JdI5i7{sZ6>cijL0 literal 0 HcmV?d00001 diff --git a/tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/datatype.json b/tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/datatype.json index 79b62692..f73490f0 100644 --- a/tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/datatype.json +++ b/tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/datatype.json @@ -1 +1 @@ -{"class": "DataFrame"} \ No newline at end of file +{"class": "Dict"} \ No newline at end of file diff --git a/tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/outlier_labels/datatype.json b/tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/outlier_labels/datatype.json new file mode 100644 index 00000000..bbf7c5bc --- /dev/null +++ b/tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/outlier_labels/datatype.json @@ -0,0 +1 @@ +{"class": "Series"} \ No newline at end of file diff --git a/tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/outlier_labels/solution_series.parquet b/tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/outlier_labels/solution_series.parquet new file mode 100644 index 0000000000000000000000000000000000000000..6ce975bed7263a45a001425efd137b51fcc7d370 GIT binary patch literal 2225 zcmbVO&2rjS6uw}B@s!NuXG9?51=UQMbcVPBOj0t*;zA6F+D=Rw1nP8L{Q(#2FG#Y$ z9`CxyveQM^o#{LD5&8^WbkT?CvWuR3C5Rn+lWGRtt8>o%&Ue0hkHl--uW%{u2d=x% zWjS;YAtCh;Z6(lyt<4m=cRPbNZ)cw0LeZ6sM-Fj0?qL~L+~Lw(dOM$a7<=-WRZI>& zy1{z)E(Ct|hz4a78yoNcNJ4hF{hVCo|1I(UK6vMbYxu+`j_nnlz&D98=fF(tqURfK z@%DYUIGHIV863gvC>3PL#yx?r>`_5eTj^ z_XyzFno)GNg2aO^V0j)ynE_Z<>8=*}1U#cA;EC+gz$pN9j9-IFw7~vbmpjM z>0Gvy$^CT;70UUvtJu2Y<^S0@=VAMd#j)m?fn|IAv*LF=qs4z;F7lD}n1vY3dCuF4 zWk8r6m?lJwiDBx;S8WmavA=X_ARpL7b994;Xgjdn<=Ww>Geh&K-7$=ZC02rclU41D6^Vp*=G ziqr!0N2xxoY?h5ysrde!sDaPqJ_iE4Ha8UCnKNxnqRy51l1k$)mHFHWX)&j?+Z>x( ztF;|rN8stYn9pVBPHm^gU!Z&z_xrD;XHkdb|J?kFUX4i$V|)ZZdVPcOE^bb68{-LX zO_;6Oyy6e3ac}K!g>*eU!^KdTU&J`XuntBBvPTDa#`yIS49%yaWd?F*usf_Co~qq} zui6LX6=|xXwU~~qfhU@RC$@SswYsRNQhlsQ^%HqGHMOPqL~Q%8zCKl@w{%UQOABwm zg^6sLrrPO`RjcjGZy>Hyuc)1n;)EkuKN=p6^{yE*U#nM9EfetjrbxmG;8oONvp%f# z>sp86Vxm@i?_}vvi2M~(@3aAsYI zUFQv235T;ILN!sPfiRN#Q@yjd)@uAFr2H@Ci#2CD$gV6cPKMQ$zJSqb6Iluwp4Mr@ zzO~OT>jYfgQMGXby3%@JKPsUyxXiaf6l*Yx>-A;O5HN(%dXE;3A#f6{wxD-=?#Lbp z@{pZ}r<5z`v6Q8@pw>iM1MK|*VtP}}K4JT$bxoM}PW2_rUzLZwGev3yOv{;gwm-wc z0qhIa;{$)$)a*l7t?n|61F{Qo2V@*|LWdsJRV+;D8OMEkV~70ZTiQDC&}>FVje6Mm zrV~v22jf^bI)(l?iu@1+PUwpfZie_^?W7Y5F=Zobnvd~7!QR5$J~wl<>v q;sweVU1k0*F#N}w>po^LjAHykC`PY}LK!}S^gH-;6QS4eXXQT^j&7y^ literal 0 HcmV?d00001 diff --git a/tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/per_check_labels/datatype.json b/tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/per_check_labels/datatype.json new file mode 100644 index 00000000..bbf7c5bc --- /dev/null +++ b/tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/per_check_labels/datatype.json @@ -0,0 +1 @@ +{"class": "Series"} \ No newline at end of file diff --git a/tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/per_check_labels/solution_series.parquet b/tests/pkled_solutions/test_qc_solutions/testbreakingdata/test_qc_stats_check/per_check_labels/solution_series.parquet new file mode 100644 index 0000000000000000000000000000000000000000..9fa1603f19675016173fb60a3d544ed963e52e84 GIT binary patch literal 2893 zcmcIm&2Jl35Pxxn*hyED42;B#LDip~5NZfWfm>I%cVRDO1jtMh<#y=Mggux=VXuzEb ziI9aesI#-cpHsas<~)2JNvxq$ex)6Q|?-dWy`tdpCz>&--v+`aJG1?5TcOZABty!eRu z1e|z!COD zoj38*RN`Ad+In#UPV4_?VyHjzYcP^a1R{?EB(@m!o4hXZR`hS*Q5-I(e6S-Ls-x*v z{ObG%@nJUNi}P_zLi|HmxDq>5T&99YaZDK;(H&JqQAJjzos%@z(2m_VQ84c4ifBkO zD%Ks%><<}+uqTT)(&3u&5^wV$eiVnoG9*QBe%BPkUQk`BotH=dVX-9ufrO99vO7y7G=S} zQb$IZST% z*v9&Y_hbX#189_q3lM=v0UEv@19`e~{drmsKx(t_K!Is-Ac|7(JV7JTd6Vznab)|l fXquPl!#F>9_~zXwF_wd08}}m&{A=BWAL)Mqj6^3m literal 0 HcmV?d00001 diff --git a/tests/solutionclass.py b/tests/solutionclass.py index 06574a2a..a6f050c1 100644 --- a/tests/solutionclass.py +++ b/tests/solutionclass.py @@ -178,6 +178,11 @@ def _store_dataframe(df: pd.DataFrame, dir_path: Path) -> None: _write_datatype(dir_path, "DataFrame") df.to_parquet(dir_path / "solution_df.parquet") +def _store_series(series: pd.Series, dir_path: Path) -> None: + """Store a Series solution.""" + _write_datatype(dir_path, "Series") + series.to_frame().to_parquet(dir_path / "solution_series.parquet") + def _store_dataset(dataset, dir_path: Path) -> None: """Store a Dataset solution.""" @@ -221,6 +226,8 @@ def _store_dict(data_dict: dict, dir_path: Path) -> None: _store_station(val, key_dir) elif isinstance(val, pd.DataFrame): _store_dataframe(val, key_dir) + elif isinstance(val, pd.Series): + _store_series(val, key_dir) else: raise NotImplementedError( f"store_dict does not support {obj_classname} objects." @@ -251,6 +258,10 @@ def _read_dataframe(dir_path: Path) -> pd.DataFrame: """Read a DataFrame solution.""" return pd.read_parquet(dir_path / "solution_df.parquet") +def _read_series(dir_path: Path) -> pd.Series: + """Read a Series solution.""" + return pd.read_parquet(dir_path / "solution_series.parquet").iloc[:,0] + def _read_dataset(dir_path: Path) -> SerializedDataset: """Read a Dataset solution.""" @@ -285,6 +296,8 @@ def _read_dict(dir_path: Path) -> dict: result[subdir.stem] = _read_station(subdir) elif solution_class == "DataFrame": result[subdir.stem] = _read_dataframe(subdir) + elif solution_class == "Series": + result[subdir.stem] = _read_series(subdir) else: raise NotImplementedError( f"read_dict does not support {solution_class} objects." diff --git a/tests/test_qc.py b/tests/test_qc.py index 23c382e5..0e8e99cc 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -129,17 +129,17 @@ def test_qc_with_solution(self, regular_qc_on_dataset, overwrite_solution=False) **TestBreakingDataset.solkwargs, methodname=method_name ) - assert_equality(dataset, solutionobj) # dataset comparison + assert_equality(dataset, solutionobj, exclude_columns=['details'] ) # dataset comparison def test_qc_stats_check(self, regular_qc_on_dataset, overwrite_solution=False): method_name = "test_qc_stats_check" dataset = copy.deepcopy(regular_qc_on_dataset) - df = dataset.get_qc_stats(obstype="temp", make_plot=False) + countdicts = dataset.get_qc_stats(obstype="temp", make_plot=False) if overwrite_solution: TestBreakingDataset.solutionfixer.create_solution( - solution=df, + solution=countdicts, methodname=method_name, **TestBreakingDataset.solkwargs, ) @@ -147,7 +147,9 @@ def test_qc_stats_check(self, regular_qc_on_dataset, overwrite_solution=False): solutiondf = TestBreakingDataset.solutionfixer.get_solution( methodname=method_name, **TestBreakingDataset.solkwargs ) - assert_equality(df, solutiondf) + + for key, val in countdicts.items(): + assert_equality(val, solutiondf[key]) @pytest.mark.mpl_image_compare def test_make_plot_by_label_with_outliers(self, regular_qc_on_dataset): @@ -1244,24 +1246,24 @@ def test_all_qc_methods_with_whiteset(self, import_dataset): if __name__ == "__main__": # When running outside pytest OVERWRITE = False - # test_breaking_dataset = TestBreakingDataset() + test_breaking_dataset = TestBreakingDataset() # Manually call fixtures and pass results to tests # Access the original unwrapped function via __wrapped__ - # imported_dataset = test_breaking_dataset.import_dataset.__wrapped__(test_breaking_dataset) - # qc_dataset = test_breaking_dataset.regular_qc_on_dataset.__wrapped__( - # test_breaking_dataset, imported_dataset - # ) + imported_dataset = test_breaking_dataset.import_dataset.__wrapped__(test_breaking_dataset) + qc_dataset = test_breaking_dataset.regular_qc_on_dataset.__wrapped__( + test_breaking_dataset, imported_dataset + ) # test_breaking_dataset.test_qc_labels(qc_dataset) # test_breaking_dataset.test_qc_with_solution(qc_dataset, overwrite_solution=False) - # test_breaking_dataset.test_qc_stats_check(qc_dataset, overwrite_solution=False) + # test_breaking_dataset.test_qc_stats_check(qc_dataset, overwrite_solution=OVERWRITE) # test_breaking_dataset.test_make_plot_by_label_with_outliers(qc_dataset) # test_breaking_dataset.test_get_info(qc_dataset) - test_demo_dataset = TestDemoDataset() - imported_demo_dataset = test_demo_dataset.import_dataset.__wrapped__( - test_demo_dataset - ) + # test_demo_dataset = TestDemoDataset() + # imported_demo_dataset = test_demo_dataset.import_dataset.__wrapped__( + # test_demo_dataset + # ) # test_demo_dataset.test_import_data(imported_demo_dataset, overwrite_solution=OVERWRITE) # test_demo_dataset.test_qc_when_some_stations_missing_obs(imported_demo_dataset) @@ -1269,9 +1271,9 @@ def test_all_qc_methods_with_whiteset(self, import_dataset): # test_demo_dataset.test_buddy_check_one_iteration(imported_demo_dataset, overwrite_solution=OVERWRITE) # test_demo_dataset.test_buddy_check_more_iterations(imported_demo_dataset, overwrite_solution=OVERWRITE) # test_demo_dataset.test_buddy_check_no_outliers(imported_demo_dataset) - test_demo_dataset.test_buddy_check_with_big_radius( - imported_demo_dataset, overwrite_solution=OVERWRITE - ) + # test_demo_dataset.test_buddy_check_with_big_radius( + # imported_demo_dataset, overwrite_solution=OVERWRITE + # ) # test_demo_dataset.test_buddy_check_with_safety_nets(imported_demo_dataset, overwrite_solution=OVERWRITE) # test_demo_dataset.test_buddy_check_with_safety_nets_missing_min_sample_size(imported_demo_dataset) From c7263c036fab4a6296becffa9ecc27fcc13602e8 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Wed, 21 Jan 2026 09:40:51 +0100 Subject: [PATCH 10/57] fix issue on get_qc_stats with empty outliersdf --- src/metobs_toolkit/dataset.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/metobs_toolkit/dataset.py b/src/metobs_toolkit/dataset.py index 19b97bdf..29268e42 100644 --- a/src/metobs_toolkit/dataset.py +++ b/src/metobs_toolkit/dataset.py @@ -2404,9 +2404,21 @@ def get_qc_stats( logger.warning("No stations with obstype '%s' found for QC stats.", obstype) return None + df, outliersdf = self.df, self.outliersdf + if df.empty: + logger.warning("Dataset is empty, cannot compute QC stats.") + return None + if obstype not in df.index.get_level_values("obstype"): + logger.warning("No data for obstype '%s' in dataset, cannot compute QC stats.", obstype) + return None - all_label_counts = self.df.xs(obstype, level="obstype")["label"].value_counts() - outlier_label_counts = self.outliersdf.xs(obstype, level="obstype")["label"].value_counts() + all_label_counts = df.xs(obstype, level="obstype")["label"].value_counts() + if obstype in outliersdf.index.get_level_values("obstype"): + outlier_label_counts = outliersdf.xs(obstype, level="obstype")["label"].value_counts() + else: + outlier_label_counts = pd.Series(index=pd.Index([], dtype=int, name="label"), + dtype=int) + per_check_counts = [] From b271f4969ea66aaaf814a20c9855032cd97d1f6a Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Wed, 21 Jan 2026 09:41:12 +0100 Subject: [PATCH 11/57] bugfix for unmet condition error --- .../qc_collection/persistence_check.py | 48 +++++++++---------- .../qc_collection/window_variation_check.py | 7 ++- 2 files changed, 26 insertions(+), 29 deletions(-) diff --git a/src/metobs_toolkit/qc_collection/persistence_check.py b/src/metobs_toolkit/qc_collection/persistence_check.py index 5e6099f6..b449f6b2 100644 --- a/src/metobs_toolkit/qc_collection/persistence_check.py +++ b/src/metobs_toolkit/qc_collection/persistence_check.py @@ -19,6 +19,25 @@ logger = logging.getLogger("") +def _has_window_unique_values(window: pd.Series) -> bool: + """ + Check if all non-NaN values in the window are identical. + + Parameters + ---------- + window : pd.Series + A pandas Series representing the rolling window. + + Returns + ------- + bool + True if all non-NaN values are identical, False otherwise. + """ + a = window.values + a = a[~np.isnan(a)] + return (a[0] == a).all() if len(a) > 0 else False + + @log_entry def persistence_check( records: pd.Series, @@ -88,34 +107,13 @@ def persistence_check( checkname="persistence", checksettings=locals().pop('records', None), flags=flags, - outliers=timestamps_to_datetimeindex( - name="datetime", timestamps=[], current_tz=None - ), - detail="Minimum number of records per window not met.", + outliers=pd.Series(index=timestamps_to_datetimeindex( + name="datetime", timestamps=[], current_tz=None)), + detail=f"Minimum number of records ({min_records_per_window}) per window ({timewindow}) not met.", ) return qcresult - # Apply persistence - - def is_unique(window: pd.Series) -> bool: - """ - Check if all non-NaN values in the window are identical. - - Parameters - ---------- - window : pd.Series - A pandas Series representing the rolling window. - - Returns - ------- - bool - True if all non-NaN values are identical, False otherwise. - """ - a = window.values - a = a[~np.isnan(a)] - return (a[0] == a).all() if len(a) > 0 else False - # This is very expensive if no coarsening is applied! Can we speed this up? window_flags = ( @@ -126,7 +124,7 @@ def is_unique(window: pd.Series) -> bool: center=True, min_periods=min_records_per_window, ) - .apply(is_unique) + .apply(_has_window_unique_values) ) # The returns are numeric values (0 --> oke, NaN --> not checked (members/window condition not met), 1 --> outlier) window_flags = window_flags.map( diff --git a/src/metobs_toolkit/qc_collection/window_variation_check.py b/src/metobs_toolkit/qc_collection/window_variation_check.py index c8829b00..ddf08e77 100644 --- a/src/metobs_toolkit/qc_collection/window_variation_check.py +++ b/src/metobs_toolkit/qc_collection/window_variation_check.py @@ -100,10 +100,9 @@ def window_variation_check( checkname="window_variation", checksettings=locals().pop('records', None), flags=flags, - outliers=timestamps_to_datetimeindex( - name="datetime", timestamps=[], current_tz=None - ), - detail="Minimum number of records per window not met.", + outliers=pd.Series(index=timestamps_to_datetimeindex( + name="datetime", timestamps=[], current_tz=None)), + detail=f"Minimum number of records ({min_records_per_window}) per window ({timewindow}) not met.", ) return qcresult From 2d55b16479c6efba7940e937ff682bdbd3d56329 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Wed, 21 Jan 2026 09:41:26 +0100 Subject: [PATCH 12/57] colorbugfix --- src/metobs_toolkit/plot_collection/qc_info_pies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/metobs_toolkit/plot_collection/qc_info_pies.py b/src/metobs_toolkit/plot_collection/qc_info_pies.py index fc602062..0b986a02 100644 --- a/src/metobs_toolkit/plot_collection/qc_info_pies.py +++ b/src/metobs_toolkit/plot_collection/qc_info_pies.py @@ -81,7 +81,7 @@ def qc_overview_pies( if end_labels_from_outliers.empty: # No outliers --> full pie with "No QC outliers" in the color of 'ok' end_labels_from_outliers = pd.Series([100], index=["No QC outliers"]) - colors = [Settings.get("label_def.goodrecord.color")] + colors = [Settings._get_color_from_label('ok')] end_labels_from_outliers.plot( ax=ax_thr, From 3b0d0811b0c96b2ca986e57aab6db6971d6e1ec6 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Wed, 21 Jan 2026 10:11:27 +0100 Subject: [PATCH 13/57] bugfix when no whitelist is used --- .../spatial_checks/buddywrapstation.py | 116 +++++++++--------- 1 file changed, 61 insertions(+), 55 deletions(-) diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py index e0e53483..698e3d87 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py @@ -23,6 +23,7 @@ BC_SAFETYNET_OUTLIER = "safetynet_outlier" # Flagged but not saved by safetynet BC_WHITELIST_SAVED = "whitelist_saved" # Flagged but saved by whitelist BC_WHITELIST_NOT_SAVED = "whitelist_not_saved" # Flagged but not saved by whitelist +BC_CHECK_SKIPPED = "skipped" #This check was skipped, e.g. due to arugments of the user (not whitelist, not safetynets etc) if TYPE_CHECKING: from metobs_toolkit.station import Station @@ -311,68 +312,68 @@ def update_whitelist_details(self, whitelistseries: pd.Series, # Remove duplicates keeping first self.details['whitelist_check'][iteration] = combined[~combined.index.duplicated(keep='first')].sort_index() - def get_combined_status_df(self) -> pd.DataFrame: - """Combine all time-dependent status information into a single DataFrame. + # def get_combined_status_df(self) -> pd.DataFrame: + # """Combine all time-dependent status information into a single DataFrame. - Combines flags and details across all iterations into a unified DataFrame. + # Combines flags and details across all iterations into a unified DataFrame. - Returns - ------- - pandas.DataFrame - DataFrame with MultiIndex (datetime, iteration) and columns: + # Returns + # ------- + # pandas.DataFrame + # DataFrame with MultiIndex (datetime, iteration) and columns: - * 'check_type' : str - Type of check ('spatial_check', 'safetynet_check:', 'whitelist_check') - * 'flag' : str - Status flag (BC_FLAGGED, BC_SAFETYNET_SAVED, BC_WHITELIST_SAVED) - * 'details' : str - Detailed message about the record + # * 'check_type' : str - Type of check ('spatial_check', 'safetynet_check:', 'whitelist_check') + # * 'flag' : str - Status flag (BC_FLAGGED, BC_SAFETYNET_SAVED, BC_WHITELIST_SAVED) + # * 'details' : str - Detailed message about the record - Returns empty DataFrame if no records exist. - """ - records = [] - - # Process flags DataFrame - if not self.flags.empty: - for (timestamp, iteration), row in self.flags.iterrows(): - for col in self.flags.columns: - flag_value = row[col] - if pd.notna(flag_value): - # Determine check_type and get details - check_type = col - detail = '' + # Returns empty DataFrame if no records exist. + # """ + # records = [] + + # # Process flags DataFrame + # if not self.flags.empty: + # for (timestamp, iteration), row in self.flags.iterrows(): + # for col in self.flags.columns: + # flag_value = row[col] + # if pd.notna(flag_value): + # # Determine check_type and get details + # check_type = col + # detail = '' - if col == 'spatial_check': - if iteration in self.details['spatial_check']: - detail_series = self.details['spatial_check'][iteration] - if timestamp in detail_series.index: - detail = detail_series.loc[timestamp] - elif col.startswith('safetynet_check:'): - groupname = col.split(':', 1)[1] - if groupname in self.details['safetynet_check']: - if iteration in self.details['safetynet_check'][groupname]: - detail_series = self.details['safetynet_check'][groupname][iteration] - if timestamp in detail_series.index: - detail = detail_series.loc[timestamp] - elif col == 'whitelist_check': - if iteration in self.details['whitelist_check']: - detail_series = self.details['whitelist_check'][iteration] - if timestamp in detail_series.index: - detail = detail_series.loc[timestamp] + # if col == 'spatial_check': + # if iteration in self.details['spatial_check']: + # detail_series = self.details['spatial_check'][iteration] + # if timestamp in detail_series.index: + # detail = detail_series.loc[timestamp] + # elif col.startswith('safetynet_check:'): + # groupname = col.split(':', 1)[1] + # if groupname in self.details['safetynet_check']: + # if iteration in self.details['safetynet_check'][groupname]: + # detail_series = self.details['safetynet_check'][groupname][iteration] + # if timestamp in detail_series.index: + # detail = detail_series.loc[timestamp] + # elif col == 'whitelist_check': + # if iteration in self.details['whitelist_check']: + # detail_series = self.details['whitelist_check'][iteration] + # if timestamp in detail_series.index: + # detail = detail_series.loc[timestamp] - records.append({ - 'datetime': timestamp, - 'iteration': iteration, - 'check_type': check_type, - 'flag': flag_value, - 'details': detail - }) + # records.append({ + # 'datetime': timestamp, + # 'iteration': iteration, + # 'check_type': check_type, + # 'flag': flag_value, + # 'details': detail + # }) - if not records: - return pd.DataFrame(columns=['check_type', 'flag', 'details']) + # if not records: + # return pd.DataFrame(columns=['check_type', 'flag', 'details']) - result_df = pd.DataFrame(records) - result_df = result_df.set_index(['datetime', 'iteration']) - result_df = result_df.sort_index() + # result_df = pd.DataFrame(records) + # result_df = result_df.set_index(['datetime', 'iteration']) + # result_df = result_df.sort_index() - return result_df + # return result_df def _get_iterations(self) -> List[int]: """Get all iterations that have been processed.""" @@ -391,8 +392,13 @@ def _get_iterations(self) -> List[int]: return sorted(iterations) def get_final_labels(self) -> pd.Series: + flags = self.flags + + #if no whitelist check has been performed, create an empyt column + if 'whitelist_check' not in flags.columns: + flags['whitelist_check'] = BC_CHECK_SKIPPED #'skipped' - final_labels = self.flags.groupby('datetime').apply(final_label_logic) + final_labels = flags.groupby('datetime').apply(final_label_logic) final_labels.name = 'final_label' return final_labels @@ -629,7 +635,7 @@ def final_label_logic(subset: pd.DataFrame) -> str: #fail in last iteration of spatial check if ((subset['spatial_check'].iloc[-1] == BC_FLAGGED) and all(subset.iloc[-1][saftynet_cols] != BC_PASSED) and #not passed is [nan, flagged, no-buddies] - (subset['whitelist_check'].iloc[-1] == BC_WHITELIST_NOT_SAVED)): + (subset['whitelist_check'].iloc[-1] != BC_WHITELIST_SAVED)): #not saved is [nan, skipped, not-saved] return BC_FLAGGED #fail in any previous iteration of spatial check From 4208e3040d47fbfb93c1ebe172a132993de88f59 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Wed, 21 Jan 2026 10:11:49 +0100 Subject: [PATCH 14/57] drop unused method --- .../spatial_checks/buddywrapstation.py | 63 +------------------ 1 file changed, 1 insertion(+), 62 deletions(-) diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py index 698e3d87..b94d56e1 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py @@ -312,68 +312,7 @@ def update_whitelist_details(self, whitelistseries: pd.Series, # Remove duplicates keeping first self.details['whitelist_check'][iteration] = combined[~combined.index.duplicated(keep='first')].sort_index() - # def get_combined_status_df(self) -> pd.DataFrame: - # """Combine all time-dependent status information into a single DataFrame. - - # Combines flags and details across all iterations into a unified DataFrame. - - # Returns - # ------- - # pandas.DataFrame - # DataFrame with MultiIndex (datetime, iteration) and columns: - - # * 'check_type' : str - Type of check ('spatial_check', 'safetynet_check:', 'whitelist_check') - # * 'flag' : str - Status flag (BC_FLAGGED, BC_SAFETYNET_SAVED, BC_WHITELIST_SAVED) - # * 'details' : str - Detailed message about the record - - # Returns empty DataFrame if no records exist. - # """ - # records = [] - - # # Process flags DataFrame - # if not self.flags.empty: - # for (timestamp, iteration), row in self.flags.iterrows(): - # for col in self.flags.columns: - # flag_value = row[col] - # if pd.notna(flag_value): - # # Determine check_type and get details - # check_type = col - # detail = '' - - # if col == 'spatial_check': - # if iteration in self.details['spatial_check']: - # detail_series = self.details['spatial_check'][iteration] - # if timestamp in detail_series.index: - # detail = detail_series.loc[timestamp] - # elif col.startswith('safetynet_check:'): - # groupname = col.split(':', 1)[1] - # if groupname in self.details['safetynet_check']: - # if iteration in self.details['safetynet_check'][groupname]: - # detail_series = self.details['safetynet_check'][groupname][iteration] - # if timestamp in detail_series.index: - # detail = detail_series.loc[timestamp] - # elif col == 'whitelist_check': - # if iteration in self.details['whitelist_check']: - # detail_series = self.details['whitelist_check'][iteration] - # if timestamp in detail_series.index: - # detail = detail_series.loc[timestamp] - - # records.append({ - # 'datetime': timestamp, - # 'iteration': iteration, - # 'check_type': check_type, - # 'flag': flag_value, - # 'details': detail - # }) - - # if not records: - # return pd.DataFrame(columns=['check_type', 'flag', 'details']) - - # result_df = pd.DataFrame(records) - # result_df = result_df.set_index(['datetime', 'iteration']) - # result_df = result_df.sort_index() - - # return result_df + def _get_iterations(self) -> List[int]: """Get all iterations that have been processed.""" From 0975bed49b890b7d19cd4e5e38569b5c83050770 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Wed, 21 Jan 2026 10:12:05 +0100 Subject: [PATCH 15/57] drop unused variable --- .../qc_collection/spatial_checks/methods/whitesaving.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/whitesaving.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/whitesaving.py index 87c057bb..505ccd6f 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/methods/whitesaving.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/whitesaving.py @@ -57,9 +57,6 @@ def save_whitelist_records( logger.debug("Whitelist is empty, no records saved") return outliers - # Create a name map of the wrapped stations - name_map = {sta.name: sta for sta in wrappedstations} - # Track which records are not saved # saved_records = pd.MultiIndex.from_tuples([], names=['name', 'datetime']) remaining_outliers = pd.MultiIndex.from_tuples([], names=['name', 'datetime']) From b0a021593e01261e348e16dab9041fa565c0af60 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Wed, 21 Jan 2026 10:12:29 +0100 Subject: [PATCH 16/57] add get_qc in test, for sanity checking --- tests/test_qc.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/test_qc.py b/tests/test_qc.py index 0e8e99cc..20cac259 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -226,6 +226,9 @@ def test_qc_when_some_stations_missing_obs(self, import_dataset): dataset.buddy_check(obstype=obstype) assert orig_count == len(dataset.stations) + + #test if test_qc works if some stations are missing obstype + dataset.get_qc_stats(obstype=obstype) def test_buddy_check_raise_errors(self, import_dataset): # 1. get_startpoint data @@ -606,7 +609,7 @@ def test_whiteset_datetime_only( methodname=_method_name, **TestWhiteRecords.solkwargs ) - assert_equality(dataset, solutionobj) + assert_equality(dataset, solutionobj, exclude_columns=['details'] ) def test_whiteset_name_only(self, dataset_with_outliers, overwrite_solution=False): """Test white_records with Index containing only station names.""" @@ -636,7 +639,7 @@ def test_whiteset_name_only(self, dataset_with_outliers, overwrite_solution=Fals methodname=_method_name, **TestWhiteRecords.solkwargs ) - assert_equality(dataset, solutionobj) + assert_equality(dataset, solutionobj, exclude_columns=['details']) def test_whiteset_name_only_on_station(self, dataset_with_outliers): """Test white_records with name-only Index on Station object.""" @@ -687,7 +690,7 @@ def test_whiteset_name_and_datetime( methodname=_method_name, **TestWhiteRecords.solkwargs ) - assert_equality(dataset, solutionobj) + assert_equality(dataset, solutionobj, exclude_columns=['details']) def test_whiteset_full_multiindex( self, dataset_with_outliers, overwrite_solution=False @@ -718,7 +721,7 @@ def test_whiteset_full_multiindex( methodname=_method_name, **TestWhiteRecords.solkwargs ) - assert_equality(dataset, solutionobj) + assert_equality(dataset, solutionobj, exclude_columns=['details']) def test_white_dt_only_records_buddy_check( self, import_dataset, overwrite_solution=False From bec30822efc6bf63264e69f760ae1b0dbb2f2821 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Mon, 26 Jan 2026 12:33:32 +0100 Subject: [PATCH 17/57] add test with buddy and mf=true --- tests/test_qc.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_qc.py b/tests/test_qc.py index 20cac259..977f4235 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -204,6 +204,16 @@ def test_import_data(self, import_dataset, overwrite_solution=False): ) assert_equality(dataset, solutionobj) + + + def test_buddy_check_with_paralelism(self, import_dataset): + dataset = copy.deepcopy(import_dataset) + obstype = "temp" + + dataset.buddy_check(obstype=obstype, use_mp=True) + assert not dataset.outliersdf.empty + + def test_qc_when_some_stations_missing_obs(self, import_dataset): # 1. get_startpoint data From 99d5be84115f1f29c124a666d1cea0b5402065a3 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Mon, 26 Jan 2026 16:05:00 +0100 Subject: [PATCH 18/57] fix the processing of safetynet details + creation of a final deatail string --- .../spatial_checks/buddywrapstation.py | 360 +++++++++++------- .../spatial_checks/methods/samplechecks.py | 43 ++- 2 files changed, 244 insertions(+), 159 deletions(-) diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py index b94d56e1..09a03973 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py @@ -223,55 +223,77 @@ def add_spatial_details(self, iteration: int, detail_series: pd.Series) -> None: detail_series=detail_series, groupname='spatial_check' ) - - def update_safetynet_details(self, - detailseries: pd.Series, - iteration: int, - groupname: str, - is_saved: bool = True,) -> None: - """Update safetynet check saved information for an iteration. - Parameters - ---------- - detailseries : pd.Series - Series with DatetimeIndex containing detail messages for saved records. - iteration : int - The iteration number. - groupname : str - The name of the safetynet group that saved the records. - is_saved : bool, optional - Whether the records were saved by the safetynet (True) or failed (False). - Default is True. - """ + def add_safetynet_details(self, iteration: int, safetynetname: str, detail_series: pd.Series) -> None: + if detail_series.empty: + return # Remove duplicates (keep first occurrence) - detailseries = detailseries[~detailseries.index.duplicated(keep='first')] + detail_series = detail_series[~detail_series.index.duplicated(keep='first')] - if detailseries.empty: - return - - if is_saved: - flag = BC_SAFETYNET_SAVED - else: - flag = BC_SAFETYNET_OUTLIER - - # Add flags to the flags DataFrame - column_name = f'safetynet_check:{groupname}' - flag_series = pd.Series(flag, index=detailseries.index) - self.add_flags(iteration=iteration, flag_series=flag_series, column_name=column_name) + # check if the groupname entry exists + if safetynetname not in self.details['safetynet_check']: + self.details['safetynet_check'][safetynetname] = {} + # Store details in the details dictionary - # Ensure the groupname entry exists - if groupname not in self.details['safetynet_check']: - self.details['safetynet_check'][groupname] = {} - - if iteration not in self.details['safetynet_check'][groupname]: - self.details['safetynet_check'][groupname][iteration] = detailseries + if iteration not in self.details['safetynet_check'][safetynetname]: + self.details['safetynet_check'][safetynetname][iteration] = detail_series else: # Append to existing series for this iteration - existing = self.details['safetynet_check'][groupname][iteration] - combined = pd.concat([existing, detailseries]) + existing = self.details['safetynet_check'][safetynetname][iteration] + combined = pd.concat([existing, detail_series]) # Remove duplicates keeping first - self.details['safetynet_check'][groupname][iteration] = combined[~combined.index.duplicated(keep='first')].sort_index() + self.details['safetynet_check'][safetynetname][iteration] = combined[~combined.index.duplicated(keep='first')].sort_index() + + + # def update_safetynet_details(self, + # detailseries: pd.Series, + # iteration: int, + # groupname: str, + # is_saved: bool = True,) -> None: + # """Update safetynet check saved information for an iteration. + + # Parameters + # ---------- + # detailseries : pd.Series + # Series with DatetimeIndex containing detail messages for saved records. + # iteration : int + # The iteration number. + # groupname : str + # The name of the safetynet group that saved the records. + # is_saved : bool, optional + # Whether the records were saved by the safetynet (True) or failed (False). + # Default is True. + # """ + # # Remove duplicates (keep first occurrence) + # detailseries = detailseries[~detailseries.index.duplicated(keep='first')] + + # if detailseries.empty: + # return + + # if is_saved: + # flag = BC_SAFETYNET_SAVED + # else: + # flag = BC_SAFETYNET_OUTLIER + + # # Add flags to the flags DataFrame + # column_name = f'safetynet_check:{groupname}' + # flag_series = pd.Series(flag, index=detailseries.index) + # self.add_flags(iteration=iteration, flag_series=flag_series, column_name=column_name) + + # # Store details in the details dictionary + # # Ensure the groupname entry exists + # if groupname not in self.details['safetynet_check']: + # self.details['safetynet_check'][groupname] = {} + + # if iteration not in self.details['safetynet_check'][groupname]: + # self.details['safetynet_check'][groupname][iteration] = detailseries + # else: + # # Append to existing series for this iteration + # existing = self.details['safetynet_check'][groupname][iteration] + # combined = pd.concat([existing, detailseries]) + # # Remove duplicates keeping first + # self.details['safetynet_check'][groupname][iteration] = combined[~combined.index.duplicated(keep='first')].sort_index() def update_whitelist_details(self, whitelistseries: pd.Series, iteration: int, @@ -354,12 +376,53 @@ def get_final_details(self) -> pd.Series: Series with DatetimeIndex containing detailed description strings. The series name is 'final_details'. """ - final_details = self.flags.groupby('datetime').apply( - lambda subset: final_detail_logic(subset, self.details) - ) - final_details.name = 'final_details' - return final_details - + + + detailstr = pd.Series('', index=self.details['spatial_check'][0].index) + + def reindex_details(detail_series: pd.Series) -> pd.Series: + return detail_series.reindex(detailstr.index).fillna('NA').astype(str) + + iter = 0 + while iter in self.details['spatial_check'].keys(): + detailstr = detailstr + f'iteration {iter}:[' + + #add spatial check details + spatial_details = reindex_details(self.details['spatial_check'][iter]) + detailstr = detailstr.str.cat(spatial_details, sep='') + + + detailstr = detailstr + " --> " + + #add safetynet details + if bool(self.details['safetynet_check']): + + for safetynetkey in self.details['safetynet_check'].keys(): + if iter in self.details['safetynet_check'][safetynetkey].keys(): + + savedetails = reindex_details(self.details['safetynet_check'][safetynetkey][iter]) + detailstr = detailstr.str.cat(savedetails, sep=f'{safetynetkey}:') + + else: + detailstr = detailstr + f'{safetynetkey}: NA ' + + detailstr = detailstr + " --> " + else: + detailstr = detailstr + "NA " + " --> " + + #add whitelist details + if iter in self.details['whitelist_check'].keys(): + savedetails = reindex_details(self.details['whitelist_check'][iter]) + detailstr = detailstr.str.cat(savedetails, sep='') + else: + detailstr = detailstr + 'NA' + + detailstr = detailstr + "] \n" + + iter += 1 + + detailstr.name = 'final_details' + return detailstr def get_iteration_summary(self, iteration: int) -> Dict[str, int]: """Get a summary of record counts for a specific iteration. @@ -586,107 +649,112 @@ def final_label_logic(subset: pd.DataFrame) -> str: raise ValueError(f"Unforeseen situartion encountered in final label logic: \n {subset}") - def final_detail_logic(subset: pd.DataFrame, details: Dict) -> str: - """Extract detailed description string based on the final label logic. - - This function mirrors the logic of `final_label_logic` but returns - a detailed description string extracted from the details dictionary. - Parameters - ---------- - subset : pd.DataFrame - DataFrame subset for a single timestamp with all iterations. - details : dict - The details dictionary from BuddyCheckStation containing: - - 'spatial_check': {iteration: Series} - - 'safetynet_check': {groupname: {iteration: Series}} - - 'whitelist_check': {iteration: Series} - - Returns - ------- - str - Detailed description string for the final label. - """ - # Get the timestamp from the subset index - timestamp = subset.index.get_level_values('datetime')[0] - last_iteration = subset.index.get_level_values('iteration')[-1] - - # Helper to get detail from a specific check type and iteration - def get_spatial_detail(iteration: int) -> str: - if iteration in details['spatial_check']: - detail_series = details['spatial_check'][iteration] - if timestamp in detail_series.index: - return str(detail_series.loc[timestamp]) - return "" - - def get_safetynet_detail(groupname: str, iteration: int) -> str: - if groupname in details['safetynet_check']: - if iteration in details['safetynet_check'][groupname]: - detail_series = details['safetynet_check'][groupname][iteration] - if timestamp in detail_series.index: - return str(detail_series.loc[timestamp]) - return "" - - def get_whitelist_detail(iteration: int) -> str: - if iteration in details['whitelist_check']: - detail_series = details['whitelist_check'][iteration] - if timestamp in detail_series.index: - return str(detail_series.loc[timestamp]) - return "" - - # Apply same logic as final_label_logic to determine which detail to return - - # Not tested condition - if subset['spatial_check'].apply(lambda x: x == 'not_tested').all(): - return "Value was NaN, not tested." - - # Passed condition - pass on last iteration of spatial check - if subset['spatial_check'].iloc[-1] == BC_PASSED: - detail = get_spatial_detail(last_iteration) - return detail if detail else "Passed spatial check." - - # No buddies condition - if subset['spatial_check'].iloc[-1] == BC_NO_BUDDIES: - detail = get_spatial_detail(last_iteration) - return detail if detail else "Not enough buddies to test." - - # Caught by safetynet - saftynet_cols = [col for col in subset.columns if col.startswith('safetynet_check:')] - for col in saftynet_cols: - if subset.iloc[-1][col] == BC_PASSED: - groupname = col.split(':', 1)[1] - detail = get_safetynet_detail(groupname, last_iteration) - return detail if detail else f"Saved by safetynet ({groupname})." - - # Caught by whitelist - if subset['whitelist_check'].iloc[-1] == BC_WHITELIST_SAVED: - detail = get_whitelist_detail(last_iteration) - return detail if detail else "Saved by whitelist." - - # Failed conditions - - # Fail in last iteration of spatial check - if ((subset['spatial_check'].iloc[-1] == BC_FLAGGED) and - all(subset.iloc[-1][saftynet_cols] != BC_PASSED) and - (subset['whitelist_check'].iloc[-1] == BC_WHITELIST_NOT_SAVED)): - # Combine details from spatial check and whitelist - spatial_detail = get_spatial_detail(last_iteration) - whitelist_detail = get_whitelist_detail(last_iteration) - parts = [p for p in [spatial_detail, whitelist_detail] if p] - return " | ".join(parts) if parts else "Flagged as outlier." - - # Fail in any previous iteration of spatial check - if ((any(subset['spatial_check'] == BC_FLAGGED)) and - (subset['spatial_check'].iloc[-1] == BC_NOT_TESTED)): - # Find the iteration where it was flagged - for idx, row in subset.iterrows(): - if row['spatial_check'] == BC_FLAGGED: - flagged_iteration = idx[1] # iteration from MultiIndex - detail = get_spatial_detail(flagged_iteration) - return detail if detail else f"Flagged in iteration {flagged_iteration}." - - return "Unknown condition." + pass + print("final_detail_logic is currently disabled") + + +# def final_detail_logic(subset: pd.DataFrame, details: Dict) -> str: +# """Extract detailed description string based on the final label logic. + +# This function mirrors the logic of `final_label_logic` but returns +# a detailed description string extracted from the details dictionary. + +# Parameters +# ---------- +# subset : pd.DataFrame +# DataFrame subset for a single timestamp with all iterations. +# details : dict +# The details dictionary from BuddyCheckStation containing: +# - 'spatial_check': {iteration: Series} +# - 'safetynet_check': {groupname: {iteration: Series}} +# - 'whitelist_check': {iteration: Series} + +# Returns +# ------- +# str +# Detailed description string for the final label. +# """ +# # Get the timestamp from the subset index +# timestamp = subset.index.get_level_values('datetime')[0] +# last_iteration = subset.index.get_level_values('iteration')[-1] + +# # Helper to get detail from a specific check type and iteration +# def get_spatial_detail(iteration: int) -> str: +# if iteration in details['spatial_check']: +# detail_series = details['spatial_check'][iteration] +# if timestamp in detail_series.index: +# return str(detail_series.loc[timestamp]) +# return "" + +# def get_safetynet_detail(groupname: str, iteration: int) -> str: +# if groupname in details['safetynet_check']: +# if iteration in details['safetynet_check'][groupname]: +# detail_series = details['safetynet_check'][groupname][iteration] +# if timestamp in detail_series.index: +# return str(detail_series.loc[timestamp]) +# return "" + +# def get_whitelist_detail(iteration: int) -> str: +# if iteration in details['whitelist_check']: +# detail_series = details['whitelist_check'][iteration] +# if timestamp in detail_series.index: +# return str(detail_series.loc[timestamp]) +# return "" + +# # Apply same logic as final_label_logic to determine which detail to return + +# # Not tested condition +# if subset['spatial_check'].apply(lambda x: x == 'not_tested').all(): +# return "Value was NaN, not tested." + +# # Passed condition - pass on last iteration of spatial check +# if subset['spatial_check'].iloc[-1] == BC_PASSED: +# detail = get_spatial_detail(last_iteration) +# return detail if detail else "Passed spatial check." + +# # No buddies condition +# if subset['spatial_check'].iloc[-1] == BC_NO_BUDDIES: +# detail = get_spatial_detail(last_iteration) +# return detail if detail else "Not enough buddies to test." + +# # Caught by safetynet +# saftynet_cols = [col for col in subset.columns if col.startswith('safetynet_check:')] +# for col in saftynet_cols: +# if subset.iloc[-1][col] == BC_PASSED: +# groupname = col.split(':', 1)[1] +# detail = get_safetynet_detail(groupname, last_iteration) +# return detail if detail else f"Saved by safetynet ({groupname})." + +# # Caught by whitelist +# if subset['whitelist_check'].iloc[-1] == BC_WHITELIST_SAVED: +# detail = get_whitelist_detail(last_iteration) +# return detail if detail else "Saved by whitelist." + +# # Failed conditions + +# # Fail in last iteration of spatial check +# if ((subset['spatial_check'].iloc[-1] == BC_FLAGGED) and +# all(subset.iloc[-1][saftynet_cols] != BC_PASSED) and +# (subset['whitelist_check'].iloc[-1] == BC_WHITELIST_NOT_SAVED)): +# # Combine details from spatial check and whitelist +# spatial_detail = get_spatial_detail(last_iteration) +# whitelist_detail = get_whitelist_detail(last_iteration) +# parts = [p for p in [spatial_detail, whitelist_detail] if p] +# return " | ".join(parts) if parts else "Flagged as outlier." + +# # Fail in any previous iteration of spatial check +# if ((any(subset['spatial_check'] == BC_FLAGGED)) and +# (subset['spatial_check'].iloc[-1] == BC_NOT_TESTED)): +# # Find the iteration where it was flagged +# for idx, row in subset.iterrows(): +# if row['spatial_check'] == BC_FLAGGED: +# flagged_iteration = idx[1] # iteration from MultiIndex +# detail = get_spatial_detail(flagged_iteration) +# return detail if detail else f"Flagged in iteration {flagged_iteration}." + +# return "Unknown condition." def _map_dt_index(pdobj: pd.Series | pd.DataFrame, diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py index 9f2426e7..62d549dc 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py @@ -109,10 +109,15 @@ def buddy_test_a_station( for ts in timestamps_insufficient], index=timestamps_insufficient ) - centerwrapstation.add_spatial_details( - iteration=iteration, - detail_series=no_buddies_details - ) + if check_type == 'spatial_check': + centerwrapstation.add_spatial_details( + iteration=iteration, + detail_series=no_buddies_details + ) + else: + centerwrapstation.add_safetynet_details(iteration=iteration, + safetynetname=buddygroupname, + detail_series=no_buddies_details) # ---- Handle timestamps with sufficient samples ---- if timestamps_with_sufficient.empty: @@ -154,11 +159,17 @@ def buddy_test_a_station( for ts in passed_timestamps], index=passed_timestamps ) - centerwrapstation.add_spatial_details( - iteration=iteration, - detail_series=passed_details - ) - + + if check_type == 'spatial_check': + centerwrapstation.add_spatial_details( + iteration=iteration, + detail_series=passed_details + ) + else: + centerwrapstation.add_safetynet_details(iteration=iteration, + safetynetname=buddygroupname, + detail_series=passed_details) + # ---- Update FLAGGED (outlier) flags and details ---- if not outlier_timestamps.empty: flagged_flags = pd.Series(BC_FLAGGED, index=outlier_timestamps) @@ -177,10 +188,16 @@ def buddy_test_a_station( for ts in outlier_timestamps], index=outlier_timestamps ) - centerwrapstation.add_spatial_details( - iteration=iteration, - detail_series=outlier_details - ) + + if check_type == 'spatial_check': + centerwrapstation.add_spatial_details( + iteration=iteration, + detail_series=outlier_details + ) + else: + centerwrapstation.add_safetynet_details(iteration=iteration, + safetynetname=buddygroupname, + detail_series=outlier_details) # ---- Return outliers as MultiIndex ---- if not outlier_timestamps.empty: From 2324db437f6cf1bc424cbadb0ffa906ea18df3c5 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Mon, 26 Jan 2026 21:06:36 +0100 Subject: [PATCH 19/57] rename min_sample_spread and use the z_robust bool arg + robust z score --- src/metobs_toolkit/dataset.py | 16 +- .../spatial_checks/buddy_check.py | 33 ++-- .../spatial_checks/methods/safetynets.py | 6 +- .../spatial_checks/methods/samplechecks.py | 149 +++++++++--------- 4 files changed, 111 insertions(+), 93 deletions(-) diff --git a/src/metobs_toolkit/dataset.py b/src/metobs_toolkit/dataset.py index 29268e42..938832c9 100644 --- a/src/metobs_toolkit/dataset.py +++ b/src/metobs_toolkit/dataset.py @@ -1976,14 +1976,17 @@ def buddy_check( spatial_buddy_radius: Union[int, float] = 10000, min_sample_size: int = 4, max_alt_diff: Union[int, float, None] = None, - min_std: Union[int, float] = 1.0, + min_sample_spread: Union[int, float] = 1.0, spatial_z_threshold: Union[int, float] = 3.1, N_iter: int = 2, instantaneous_tolerance: Union[str, pd.Timedelta] = pd.Timedelta("4min"), lapserate: Union[float, None] = None, # -0.0065 for temperature (in °C) whiteset: WhiteSet = WhiteSet(), + use_z_robust_method: bool = True, use_mp: bool = True, + ): + #TODO: update docstring """Spatial buddy check. The buddy check compares an observation against its neighbors @@ -2082,12 +2085,13 @@ def buddy_check( safety_net_configs=None, min_sample_size=min_sample_size, max_alt_diff=max_alt_diff, - min_std=min_std, + min_sample_spread=min_sample_spread, spatial_z_threshold=spatial_z_threshold, N_iter=N_iter, instantaneous_tolerance=instantaneous_tolerance, lapserate=lapserate, whiteset=whiteset, + use_z_robust_method=use_z_robust_method, use_mp=use_mp, ) @@ -2105,14 +2109,16 @@ def buddy_check_with_safetynets( safety_net_configs: List[Dict] = None, min_sample_size: int = 4, max_alt_diff: Union[int, float, None] = None, - min_std: Union[int, float] = 1.0, + min_sample_spread: Union[int, float] = 1.0, spatial_z_threshold: Union[int, float] = 3.1, N_iter: int = 2, instantaneous_tolerance: Union[str, pd.Timedelta] = pd.Timedelta("4min"), lapserate: Union[float, None] = None, # -0.0065 for temperature (in °C) whiteset: WhiteSet = WhiteSet(), + use_z_robust_method: bool = True, use_mp: bool = True, ): + #TODO: update docstring """Spatial buddy check with configurable safety nets. The buddy check compares an observation against its neighbors @@ -2333,7 +2339,7 @@ def buddy_check_with_safetynets( spatial_buddy_radius=spatial_buddy_radius, spatial_min_sample_size=min_sample_size, max_alt_diff=max_alt_diff, - min_std=min_std, + min_sample_spread=min_sample_spread, spatial_z_threshold=spatial_z_threshold, N_iter=N_iter, instantaneous_tolerance=instantaneous_tolerance, @@ -2341,6 +2347,8 @@ def buddy_check_with_safetynets( whiteset=whiteset, # Generalized safety net configuration safety_net_configs=safety_net_configs, + #statistical + use_z_robust_method=use_z_robust_method, # technical use_mp=use_mp, ) diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py index 15ebe171..3264bf16 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py @@ -37,7 +37,7 @@ def toolkit_buddy_check( spatial_buddy_radius: Union[int, float], spatial_min_sample_size: int, max_alt_diff: Union[int, float, None], - min_std: Union[int, float], + min_sample_spread: Union[int, float], spatial_z_threshold: Union[int, float], N_iter: int, instantaneous_tolerance: pd.Timedelta, @@ -45,11 +45,14 @@ def toolkit_buddy_check( whiteset: WhiteSet, # Safety nets safety_net_configs: List[Dict] = None, + #Statistical + use_z_robust_method: bool = True, # Technical lapserate: Union[float, None] = None, # -0.0065 for temperature use_mp: bool = True, ) -> List[QCresult]: """ + #TODO update the docstring accordingly Spatial buddy check. The buddy check compares an observation against its neighbors @@ -113,12 +116,14 @@ def toolkit_buddy_check( net test is not applied. * The safety net test is applied: - * The mean and std are computed of the category-buddy sample. If - the std is smaller than `min_std`, the latter is used. - * The z-value is computed for the target record (= flagged outlier). - * If the z-value is smaller than the safety net's `z_threshold`, - the tested outlier is "saved" and removed from the set of outliers - for the current iteration. + * If use_z_robust_method is True: + + * The mean and std are computed of the category-buddy sample. If + the std is smaller than `min_sample_spread`, the latter is used. + * The z-value is computed for the target record (= flagged outlier). + * If the z-value is smaller than the safety net's `z_threshold`, + the tested outlier is "saved" and removed from the set of outliers + for the current iteration. #. If `whiteset` contains records, any outliers that match the white-listed timestamps (and optionally station names) are removed from the outlier set @@ -143,9 +148,11 @@ def toolkit_buddy_check( max_alt_diff : int, float, or None The maximum altitude difference allowed for buddies. If None, no altitude filter is applied. - min_std : int or float - The minimum standard deviation for sample statistics. This should - represent the accuracy of the observations. + min_sample_spread : int or float + The minimum sample spread for sample statistics. When use_z_robust_method is True, + this is the equal to the minimum MAD to use (avoids division by near-zero). When + use_z_robust_method is False, this is the standard deviation. This parameter helps + to represent the accuracy of the observations. spatial_z_threshold : int or float The threshold, tested with z-scores, for flagging observations as outliers. N_iter : int @@ -292,10 +299,11 @@ def toolkit_buddy_check( 'buddygroupname': 'spatial', 'widedf': widedf, 'min_sample_size': spatial_min_sample_size, - 'min_std': min_std, + 'min_sample_spread': min_sample_spread, 'outlier_threshold': spatial_z_threshold, 'iteration': i, 'check_type': 'spatial_check', + 'use_z_robust_method': use_z_robust_method, } for sta in valid_targets ] @@ -332,7 +340,8 @@ def toolkit_buddy_check( wideobsds=widedf, safety_z_threshold=safety_net_config['z_threshold'], min_sample_size=safety_net_config['min_sample_size'], - min_std=min_std, #make this configurable? + min_sample_spread=min_sample_spread, #make this configurable? + use_z_robust_method=use_z_robust_method, iteration=i, ) diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py index 65df9a5f..c5f0023b 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py @@ -73,7 +73,8 @@ def apply_safety_net( wideobsds: pd.DataFrame, safety_z_threshold: Union[int, float], min_sample_size: int, - min_std: Union[int, float], + min_sample_spread: Union[int, float], + use_z_robust_method: bool, iteration: int, ) -> pd.MultiIndex: @@ -145,10 +146,11 @@ def apply_safety_net( buddygroupname=buddygroupname, widedf=widedf_subset, min_sample_size=min_sample_size, - min_std=min_std, + min_sample_spread=min_sample_spread, outlier_threshold=safety_z_threshold, iteration=iteration, check_type=f'safetynet_check:{buddygroupname}', + use_z_robust_method=use_z_robust_method, ) # Get passed timestamps from the flags DataFrame diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py index 62d549dc..85c0d0f4 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py @@ -21,11 +21,13 @@ def buddy_test_a_station( buddygroupname: str, widedf: pd.DataFrame, min_sample_size: int, - min_std: float, + min_sample_spread: float, outlier_threshold: float, iteration: int, check_type: str = 'spatial_check', + use_z_robust_method: bool = True, ) -> pd.MultiIndex: + #TODO update docstring """Find outliers in a buddy group and update station flags/details. This function tests whether the center station is an outlier compared to @@ -43,8 +45,9 @@ def buddy_test_a_station( Wide-format DataFrame with stations as columns and timestamps as index. min_sample_size : int Minimum number of valid buddy samples required for z-score calculation. - min_std : float - Minimum standard deviation to use (avoids division by near-zero). + min_sample_spread : float + when use_z_robust_method is True, this is the equal to the minimum MAD to use (avoids division by near-zero). + when use_z_robust_method is False, this is the standard deviation. outlier_threshold : float Z-score threshold above which a record is flagged as outlier. iteration : int @@ -130,12 +133,21 @@ def buddy_test_a_station( buddy_sample_sizes_filtered = buddy_sample_sizes.loc[timestamps_with_sufficient] # Compute z-scores for center station using buddy distribution - results_df = _compute_center_z_scores( - buddydf=buddydf_filtered, - center_values=center_filtered, - min_std=min_std, - outlier_threshold=outlier_threshold - ) + if use_z_robust_method: + results_df = _compute_robust_z_scores( + buddydf=buddydf_filtered, + center_values=center_filtered, + min_mad=min_sample_spread, + outlier_threshold=outlier_threshold + ) + + else: + results_df = _compute_center_z_scores( + buddydf=buddydf_filtered, + center_values=center_filtered, + min_std=min_sample_spread, + outlier_threshold=outlier_threshold + ) # Separate flagged (outliers) and passed outlier_timestamps = results_df.index[results_df['flagged']] @@ -151,14 +163,7 @@ def buddy_test_a_station( ) # Create detail messages for passed - passed_details = pd.Series( - [f"Passed {buddygroupname} check (z={results_df.loc[ts, 'z_score']:.2f}, " - f"threshold={outlier_threshold}, n={int(buddy_sample_sizes_filtered.loc[ts])}, " - f"mean={results_df.loc[ts, 'buddy_mean']:.2f}, " - f"std={results_df.loc[ts, 'buddy_std']:.2f})" - for ts in passed_timestamps], - index=passed_timestamps - ) + passed_details = f"Passed {buddygroupname} check: " + results_df.loc[passed_timestamps, 'details'] if check_type == 'spatial_check': centerwrapstation.add_spatial_details( @@ -180,14 +185,9 @@ def buddy_test_a_station( ) # Create detail messages for outliers - outlier_details = pd.Series( - [f"Outlier in {buddygroupname} buddy group centered on {center_name} " - f"(z={results_df.loc[ts, 'z_score']:.2f}, threshold={outlier_threshold}, " - f"n={int(buddy_sample_sizes_filtered.loc[ts])}, mean={results_df.loc[ts, 'buddy_mean']:.2f}, " - f"std={results_df.loc[ts, 'buddy_std']:.2f})" - for ts in outlier_timestamps], - index=outlier_timestamps - ) + outlier_details = f"Outlier in {buddygroupname} buddy group centered on {center_name}: " + results_df.loc[outlier_timestamps, 'details'] + + if check_type == 'spatial_check': centerwrapstation.add_spatial_details( @@ -210,58 +210,51 @@ def buddy_test_a_station( return pd.MultiIndex.from_tuples([], names=['name', 'datetime']) -# def _update_details( -# wrapsta: BuddyCheckStation, -# detail_series: pd.Series, -# iteration: int, -# check_type: str -# ) -> None: -# """Update details dictionary for a wrapped station. - -# Parameters -# ---------- -# wrapsta : BuddyCheckStation -# The wrapped station to update. -# detail_series : pd.Series -# Series with DatetimeIndex containing detail messages. -# iteration : int -# The iteration number. -# check_type : str -# The check type (e.g., 'spatial_check', 'safetynet_check:groupname'). -# """ -# if detail_series.empty: -# return - -# # Handle safetynet_check with groupname -# if check_type.startswith('safetynet_check:'): -# groupname = check_type.split(':', 1)[1] -# if groupname not in wrapsta.details['safetynet_check']: -# wrapsta.details['safetynet_check'][groupname] = {} - -# if iteration not in wrapsta.details['safetynet_check'][groupname]: -# wrapsta.details['safetynet_check'][groupname][iteration] = detail_series -# else: -# existing = wrapsta.details['safetynet_check'][groupname][iteration] -# combined = pd.concat([existing, detail_series]) -# wrapsta.details['safetynet_check'][groupname][iteration] = combined[ -# ~combined.index.duplicated(keep='first') -# ].sort_index() -# else: -# # spatial_check or whitelist_check -# if iteration not in wrapsta.details[check_type]: -# wrapsta.details[check_type][iteration] = detail_series -# else: -# existing = wrapsta.details[check_type][iteration] -# combined = pd.concat([existing, detail_series]) -# wrapsta.details[check_type][iteration] = combined[ -# ~combined.index.duplicated(keep='first') -# ].sort_index() - # ------------------------------------------ # Statistical sample scoring # ------------------------------------------ +def _compute_robust_z_scores( + buddydf: pd.DataFrame, + center_values: pd.Series, + min_mad: float, + outlier_threshold: float + ) -> pd.DataFrame: + #TODO:Docstring + + buddy_not_na_counts = buddydf.notna().sum(axis=1) + #Calculate MADFM (Median Absolute Deviation From Median) + def MAD(x): + "MEDIAN absolute deviation from median" + return (x - x.median()).abs().median() + + mad_series = buddydf.apply(MAD, axis=1) + # Replace std below minimum with the minimum (avoid division by near-zero) + mad_series.loc[mad_series < min_mad] = np.float32(min_mad) + + # Calculate robust z-score for center station + + robust_z_scores = (center_values - buddydf.median(axis=1)).abs() / (1.4826 * mad_series) + + details = ('z (robust)=' + robust_z_scores.map('{:.2f}'.format) + + ', threshold=' + str(outlier_threshold) + + ', n=' + buddy_not_na_counts.map(str) + + ', MAD=' + mad_series.map('{:.2f}'.format) + + ', median=' + buddydf.median(axis=1).map('{:.2f}'.format)) + + # Build result DataFrame + result_df = pd.DataFrame( + index=buddydf.index, + data={ + 'z_score': robust_z_scores, + 'flagged': robust_z_scores > outlier_threshold, + 'details': details, + } + ) + return result_df + + def _compute_center_z_scores( buddydf: pd.DataFrame, center_values: pd.Series, @@ -294,21 +287,27 @@ def _compute_center_z_scores( # Compute mean and std from buddies only (center station excluded) buddy_mean_series = buddydf.mean(axis=1) buddy_std_series = buddydf.std(axis=1) - + buddy_not_na_counts = buddydf.notna().sum(axis=1) # Replace std below minimum with the minimum (avoid division by near-zero) buddy_std_series.loc[buddy_std_series < min_std] = np.float32(min_std) # Calculate z-score for center station z_scores = (center_values - buddy_mean_series).abs() / buddy_std_series + + details = ('z=' + z_scores.map('{:.2f}'.format) + + ', threshold=' + str(outlier_threshold) + + ', n=' + buddy_not_na_counts.map(str) + + ', mean=' + buddy_mean_series.map('{:.2f}'.format) + + ', std=' + buddy_std_series.map('{:.2f}'.format)) + # Build result DataFrame result_df = pd.DataFrame( index=buddydf.index, data={ 'z_score': z_scores, 'flagged': z_scores > outlier_threshold, - 'buddy_mean': buddy_mean_series, - 'buddy_std': buddy_std_series, + 'details': details, } ) return result_df \ No newline at end of file From 0ab00ac6d33643c95069154343d007008e067469 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Tue, 27 Jan 2026 13:28:45 +0100 Subject: [PATCH 20/57] implement the MP version of buddy check --- .../spatial_checks/buddy_check.py | 88 ++++-- .../spatial_checks/buddywrapstation.py | 291 +++++++----------- .../spatial_checks/methods/samplechecks.py | 10 +- .../settings_collection/settings.py | 4 + 4 files changed, 181 insertions(+), 212 deletions(-) diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py index 3264bf16..5376b030 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py @@ -1,12 +1,12 @@ from __future__ import annotations -import os import logging -import concurrent.futures -from typing import Union, List, Dict, Tuple, TYPE_CHECKING, Optional - +from typing import Union, List, Dict, TYPE_CHECKING +import os import numpy as np +from concurrent.futures import ProcessPoolExecutor +import itertools import pandas as pd @@ -16,7 +16,8 @@ from metobs_toolkit.qc_collection.distancematrix_func import generate_distance_matrix from metobs_toolkit.qcresult import QCresult, flagged_cond -from .buddywrapstation import BuddyCheckStation, to_qc_labels_map +from .buddywrapstation import BuddyCheckStation, to_qc_labels_map, reconstruct_fractured_targets +from metobs_toolkit.settings_collection import Settings from ..whitelist import WhiteSet # Import methods from . import methods as buddymethods @@ -29,6 +30,10 @@ logger = logging.getLogger("") +def _run_buddy_test(kwargs): + #executer for mutliprocessing + return buddymethods.buddy_test_a_station(**kwargs) + @log_entry def toolkit_buddy_check( target_stations: list[Station], @@ -252,7 +257,7 @@ def toolkit_buddy_check( # valid_targets = [budsta for budsta in targets if budsta.has_enough_buddies( # groupname='spatial', min_buddies = spatial_min_sample_size)] - valid_targets = targets + outliersbin = [] for i in range(N_iter): logger.debug("Starting iteration %s of %s", i + 1, N_iter) @@ -265,32 +270,45 @@ def toolkit_buddy_check( widedf.loc[outlier_time, outlier_station] = np.nan if use_mp: - #TODO: implement multiprocessing (make chunks along the time dimension) - pass - # Use multiprocessing generator (parallelization) - # Use multiprocessing generator (parallelization) - # # since this check is an instantaneous check --> - # # perfect for splitting the dataset in chunks in time - # chunks = np.array_split(combdf, num_cpus) - + + num_cores = Settings.get('use_N_cores_for_MP') - # inputargs = [ - # ( - # budsta, - # chunk, - # spatial_min_sample_size, - # min_std, - # spatial_z_threshold, - # ) - # for budsta in valid_targets - # for chunk in chunks - # ] + logger.debug(f"Running spatial buddy check with multiprocessing on {num_cores} cores") + # split dataframe along time/index dimension + chunks = np.array_split(widedf, num_cores) - # outliers = executor.map(find_buddy_group_outlier, inputargs) - # outliers = list(outliers) + # build input arguments for each station and each chunk + inputargs = [ + { + 'centerwrapstation': sta, + 'buddygroupname': 'spatial', + 'widedf': chunk, + 'min_sample_size': spatial_min_sample_size, + 'min_sample_spread': min_sample_spread, + 'outlier_threshold': spatial_z_threshold, + 'iteration': i, + 'check_type': 'spatial_check', + 'use_z_robust_method': use_z_robust_method, + } + for sta in targets + for chunk in chunks + ] + logger.debug( + f"Submitting {len(inputargs)} multiprocessing tasks " + f"({len(targets)} stations × {len(chunks)} chunks)" + ) + # run in parallel + with ProcessPoolExecutor(max_workers=num_cores) as executor: + buddy_output = list( + executor.map( + _run_buddy_test, + inputargs + ) + ) + else: # create inputargs for each buddygroup, and for each chunk in time inputargs = [ @@ -305,17 +323,21 @@ def toolkit_buddy_check( 'check_type': 'spatial_check', 'use_z_robust_method': use_z_robust_method, } - for sta in valid_targets + for sta in targets ] logger.debug("Finding outliers in each buddy group") - outlier_indices = list(map(lambda kwargs: buddymethods.buddy_test_a_station(**kwargs), inputargs)) + buddy_output = list(map(lambda kwargs: buddymethods.buddy_test_a_station(**kwargs), inputargs)) - + #buddy output is [(MultiIndex, BuddyCheckStation), ...], that needs to be unpacked + outlier_indices, updated_stations = zip(*buddy_output) + #overload the Buddycheckstation + targets = reconstruct_fractured_targets(list(updated_stations), iteration = i) + # Concatenate all outlier MultiIndices # Each element is a MultiIndex with (name, datetime) - spatial_outliers = buddymethods.concat_multiindices(outlier_indices) + spatial_outliers = buddymethods.concat_multiindices(list(outlier_indices)) # Start with spatial outliers for further processing current_outliers_idx = spatial_outliers @@ -331,7 +353,7 @@ def toolkit_buddy_check( current_outliers_idx = buddymethods.apply_safety_net( outliers=current_outliers_idx, - buddycheckstations = valid_targets, + buddycheckstations = targets, buddygroupname=safety_net_config['category'], metadf = metadf, distance_df = dist_matrix, @@ -352,7 +374,7 @@ def toolkit_buddy_check( # Apply whitelist filtering current_outliers_idx = buddymethods.save_whitelist_records( outliers=current_outliers_idx, - wrappedstations=valid_targets, + wrappedstations=targets, whiteset=whiteset, obstype=obstype, iteration=i, diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py index 09a03973..69a7988b 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py @@ -1,8 +1,9 @@ from __future__ import annotations import os import logging -import concurrent.futures -from typing import Union, List, Dict, Tuple, TYPE_CHECKING, Optional + +from typing import Union, List, Dict, TYPE_CHECKING +from collections import defaultdict from metobs_toolkit.qcresult import ( unchecked_cond, @@ -12,8 +13,15 @@ saved_cond, ) -import numpy as np import pandas as pd +if TYPE_CHECKING: + from metobs_toolkit.station import Station + +logger = logging.getLogger("") + +#=============================== +# Labels +#=============================== # Constants for buddy check status labels BC_NOT_TESTED = "not_tested" # Value was NaN, not tested BC_NO_BUDDIES = "no_buddies" # Not enough buddies to test @@ -25,10 +33,21 @@ BC_WHITELIST_NOT_SAVED = "whitelist_not_saved" # Flagged but not saved by whitelist BC_CHECK_SKIPPED = "skipped" #This check was skipped, e.g. due to arugments of the user (not whitelist, not safetynets etc) -if TYPE_CHECKING: - from metobs_toolkit.station import Station -logger = logging.getLogger("") +to_qc_labels_map = { + BC_NOT_TESTED: unchecked_cond, # Value was NaN, not tested + BC_NO_BUDDIES: unmet_cond, # Not enough buddies to test + BC_PASSED : pass_cond, # Tested and passed + BC_FLAGGED : flagged_cond, # Tested and flagged as outlier + BC_SAFETYNET_SAVED : pass_cond, # IMPORTANT !!! + # BC_SAFETYNET_OUTLIER : flagged_cond # Flagged but not saved by safetynet + BC_WHITELIST_SAVED : saved_cond, # Flagged but saved by whitelist + # BC_WHITELIST_NOT_SAVED : flagged_cond +} + +#=============================== +# Buddy check station class +#=============================== class BuddyCheckStation: """Wrapper for a Station with buddy check-specific details. @@ -246,55 +265,7 @@ def add_safetynet_details(self, iteration: int, safetynetname: str, detail_serie self.details['safetynet_check'][safetynetname][iteration] = combined[~combined.index.duplicated(keep='first')].sort_index() - # def update_safetynet_details(self, - # detailseries: pd.Series, - # iteration: int, - # groupname: str, - # is_saved: bool = True,) -> None: - # """Update safetynet check saved information for an iteration. - - # Parameters - # ---------- - # detailseries : pd.Series - # Series with DatetimeIndex containing detail messages for saved records. - # iteration : int - # The iteration number. - # groupname : str - # The name of the safetynet group that saved the records. - # is_saved : bool, optional - # Whether the records were saved by the safetynet (True) or failed (False). - # Default is True. - # """ - # # Remove duplicates (keep first occurrence) - # detailseries = detailseries[~detailseries.index.duplicated(keep='first')] - - # if detailseries.empty: - # return - - # if is_saved: - # flag = BC_SAFETYNET_SAVED - # else: - # flag = BC_SAFETYNET_OUTLIER - - # # Add flags to the flags DataFrame - # column_name = f'safetynet_check:{groupname}' - # flag_series = pd.Series(flag, index=detailseries.index) - # self.add_flags(iteration=iteration, flag_series=flag_series, column_name=column_name) - - # # Store details in the details dictionary - # # Ensure the groupname entry exists - # if groupname not in self.details['safetynet_check']: - # self.details['safetynet_check'][groupname] = {} - - # if iteration not in self.details['safetynet_check'][groupname]: - # self.details['safetynet_check'][groupname][iteration] = detailseries - # else: - # # Append to existing series for this iteration - # existing = self.details['safetynet_check'][groupname][iteration] - # combined = pd.concat([existing, detailseries]) - # # Remove duplicates keeping first - # self.details['safetynet_check'][groupname][iteration] = combined[~combined.index.duplicated(keep='first')].sort_index() - + def update_whitelist_details(self, whitelistseries: pd.Series, iteration: int, is_saved: bool = True) -> None: @@ -604,7 +575,10 @@ def map_timestamps(self, ts_map=timestamp_map ) - +#=============================== +# helpers for BCS +#=============================== + def final_label_logic(subset: pd.DataFrame) -> str: #the flag not tested is present on ALL iterations ! if subset['spatial_check'].apply(lambda x: x=='not_tested').all(): @@ -648,115 +622,6 @@ def final_label_logic(subset: pd.DataFrame) -> str: raise ValueError(f"Unforeseen situartion encountered in final label logic: \n {subset}") - -def final_detail_logic(subset: pd.DataFrame, details: Dict) -> str: - - pass - print("final_detail_logic is currently disabled") - - -# def final_detail_logic(subset: pd.DataFrame, details: Dict) -> str: -# """Extract detailed description string based on the final label logic. - -# This function mirrors the logic of `final_label_logic` but returns -# a detailed description string extracted from the details dictionary. - -# Parameters -# ---------- -# subset : pd.DataFrame -# DataFrame subset for a single timestamp with all iterations. -# details : dict -# The details dictionary from BuddyCheckStation containing: -# - 'spatial_check': {iteration: Series} -# - 'safetynet_check': {groupname: {iteration: Series}} -# - 'whitelist_check': {iteration: Series} - -# Returns -# ------- -# str -# Detailed description string for the final label. -# """ -# # Get the timestamp from the subset index -# timestamp = subset.index.get_level_values('datetime')[0] -# last_iteration = subset.index.get_level_values('iteration')[-1] - -# # Helper to get detail from a specific check type and iteration -# def get_spatial_detail(iteration: int) -> str: -# if iteration in details['spatial_check']: -# detail_series = details['spatial_check'][iteration] -# if timestamp in detail_series.index: -# return str(detail_series.loc[timestamp]) -# return "" - -# def get_safetynet_detail(groupname: str, iteration: int) -> str: -# if groupname in details['safetynet_check']: -# if iteration in details['safetynet_check'][groupname]: -# detail_series = details['safetynet_check'][groupname][iteration] -# if timestamp in detail_series.index: -# return str(detail_series.loc[timestamp]) -# return "" - -# def get_whitelist_detail(iteration: int) -> str: -# if iteration in details['whitelist_check']: -# detail_series = details['whitelist_check'][iteration] -# if timestamp in detail_series.index: -# return str(detail_series.loc[timestamp]) -# return "" - -# # Apply same logic as final_label_logic to determine which detail to return - -# # Not tested condition -# if subset['spatial_check'].apply(lambda x: x == 'not_tested').all(): -# return "Value was NaN, not tested." - -# # Passed condition - pass on last iteration of spatial check -# if subset['spatial_check'].iloc[-1] == BC_PASSED: -# detail = get_spatial_detail(last_iteration) -# return detail if detail else "Passed spatial check." - -# # No buddies condition -# if subset['spatial_check'].iloc[-1] == BC_NO_BUDDIES: -# detail = get_spatial_detail(last_iteration) -# return detail if detail else "Not enough buddies to test." - -# # Caught by safetynet -# saftynet_cols = [col for col in subset.columns if col.startswith('safetynet_check:')] -# for col in saftynet_cols: -# if subset.iloc[-1][col] == BC_PASSED: -# groupname = col.split(':', 1)[1] -# detail = get_safetynet_detail(groupname, last_iteration) -# return detail if detail else f"Saved by safetynet ({groupname})." - -# # Caught by whitelist -# if subset['whitelist_check'].iloc[-1] == BC_WHITELIST_SAVED: -# detail = get_whitelist_detail(last_iteration) -# return detail if detail else "Saved by whitelist." - -# # Failed conditions - -# # Fail in last iteration of spatial check -# if ((subset['spatial_check'].iloc[-1] == BC_FLAGGED) and -# all(subset.iloc[-1][saftynet_cols] != BC_PASSED) and -# (subset['whitelist_check'].iloc[-1] == BC_WHITELIST_NOT_SAVED)): -# # Combine details from spatial check and whitelist -# spatial_detail = get_spatial_detail(last_iteration) -# whitelist_detail = get_whitelist_detail(last_iteration) -# parts = [p for p in [spatial_detail, whitelist_detail] if p] -# return " | ".join(parts) if parts else "Flagged as outlier." - -# # Fail in any previous iteration of spatial check -# if ((any(subset['spatial_check'] == BC_FLAGGED)) and -# (subset['spatial_check'].iloc[-1] == BC_NOT_TESTED)): -# # Find the iteration where it was flagged -# for idx, row in subset.iterrows(): -# if row['spatial_check'] == BC_FLAGGED: -# flagged_iteration = idx[1] # iteration from MultiIndex -# detail = get_spatial_detail(flagged_iteration) -# return detail if detail else f"Flagged in iteration {flagged_iteration}." - -# return "Unknown condition." - - def _map_dt_index(pdobj: pd.Series | pd.DataFrame, ts_map: pd.Series, datetime_level: str = 'datetime' @@ -800,14 +665,90 @@ def _map_dt_index(pdobj: pd.Series | pd.DataFrame, return df +#=============================== +# Combine multiple BCS func +#=============================== +# combining multiple buddycheckstations is needed when buddy check is applied +# on fractured datasets (used with multiprocessing). -to_qc_labels_map = { - BC_NOT_TESTED: unchecked_cond, # Value was NaN, not tested - BC_NO_BUDDIES: unmet_cond, # Not enough buddies to test - BC_PASSED : pass_cond, # Tested and passed - BC_FLAGGED : flagged_cond, # Tested and flagged as outlier - BC_SAFETYNET_SAVED : pass_cond, # IMPORTANT !!! - # BC_SAFETYNET_OUTLIER : flagged_cond # Flagged but not saved by safetynet - BC_WHITELIST_SAVED : saved_cond, # Flagged but saved by whitelist - # BC_WHITELIST_NOT_SAVED : flagged_cond -} \ No newline at end of file + +def combine_series_dicts(list_of_dicts, iteration: int): + #if not data is given, return empty dict + if all([len(d)==0 for d in list_of_dicts]): + return {} + + combined = defaultdict(list) + + # collect all series per key + for d in list_of_dicts: + for iter in d.keys(): + if iter == iteration: + combined[iteration].append(d[iter]) + else: + combined[iter] = d[iter] #keep other iterations as is (overwrite, not concat else duplicates may occur) + + + combined[iteration] = pd.concat(combined[iteration]).sort_index() + + #sanity check + for series in combined.values(): + if series.index.duplicated().any(): + raise ValueError("Duplicate indices found when combining series dictionaries.") + return combined + +def combine_buddycheckstations(stations: List[BuddyCheckStation], iteration: int) -> BuddyCheckStation: + # Take the first element and attriute for time independent attributes + trgstation = stations[0].station + trg_buddygroups = stations[0]._buddy_groups + trg_flag_lapsrate_corrections = stations[0].flag_lapsrate_corrections + trg_cor_term = stations[0].cor_term + + # Flags DataFrame with MultiIndex (datetime, iteration) + trg_flags = pd.concat([st.flags for st in stations], axis=0).sort_index() + trg_flags = trg_flags.loc[~trg_flags.index.duplicated(keep='first')] #remove duplicates, keep first occurrence + + trg_spatial_details = combine_series_dicts([st.details['spatial_check'] for st in stations], iteration=iteration) + trg_whitelist_check = combine_series_dicts([st.details['whitelist_check'] for st in stations], iteration=iteration) + + saftygroupnames = [list(st.details['safetynet_check'].keys()) for st in stations] + saftygroupnames = set([item for sublist in saftygroupnames for item in sublist]) #flatten and unique + trg_safetynet_check = {} + for groupname in saftygroupnames: + group_series_list = [] + for st in stations: + if groupname in st.details['safetynet_check']: + group_series_list.append(st.details['safetynet_check'][groupname]) + trg_safetynet_check[groupname] = combine_series_dicts(group_series_list, iteration=iteration) + + + #Construct the new BuddyCheckStation + trg_buddystation = BuddyCheckStation(station=trgstation) + trg_buddystation._buddy_groups = trg_buddygroups + trg_buddystation.flag_lapsrate_corrections = trg_flag_lapsrate_corrections + trg_buddystation.cor_term = trg_cor_term + trg_buddystation._flags = trg_flags + + + + trg_buddystation.details = { + 'spatial_check': trg_spatial_details, + 'safetynet_check': trg_safetynet_check, + 'whitelist_check': trg_whitelist_check, + } + + return trg_buddystation + +def reconstruct_fractured_targets(fractured_targets: List[BuddyCheckStation], iteration: int) -> List[BuddyCheckStation]: + combined = defaultdict(list) + + #combine fractured stations by name + for targ in fractured_targets: + combined[targ.name].append(targ) + + #combine each list of fractured stations into a single BuddyCheckStation + for name, comblist in combined.items(): + combined[name] = combine_buddycheckstations(comblist, iteration=iteration) #combine into a single budycheckstation + + return list(combined.values()) + + \ No newline at end of file diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py index 85c0d0f4..b6eac379 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py @@ -26,7 +26,8 @@ def buddy_test_a_station( iteration: int, check_type: str = 'spatial_check', use_z_robust_method: bool = True, -) -> pd.MultiIndex: +) -> Tuple[pd.MultiIndex, BuddyCheckStation]: + #TODO update docstring """Find outliers in a buddy group and update station flags/details. @@ -125,7 +126,7 @@ def buddy_test_a_station( # ---- Handle timestamps with sufficient samples ---- if timestamps_with_sufficient.empty: # No timestamps to process, return empty MultiIndex - return pd.MultiIndex.from_tuples([], names=['name', 'datetime']) + return (pd.MultiIndex.from_tuples([], names=['name', 'datetime']), centerwrapstation) # Filter to rows with enough valid buddy samples buddydf_filtered = buddydf.loc[timestamps_with_sufficient] @@ -205,9 +206,10 @@ def buddy_test_a_station( [[center_name] * len(outlier_timestamps), outlier_timestamps], names=['name', 'datetime'] ) - return outlier_multiindex + #Return the updated stations, this is needed when runned in multiprocessing + return (outlier_multiindex, centerwrapstation) else: - return pd.MultiIndex.from_tuples([], names=['name', 'datetime']) + return (pd.MultiIndex.from_tuples([], names=['name', 'datetime']), centerwrapstation) diff --git a/src/metobs_toolkit/settings_collection/settings.py b/src/metobs_toolkit/settings_collection/settings.py index b638146f..6f4f6dd1 100644 --- a/src/metobs_toolkit/settings_collection/settings.py +++ b/src/metobs_toolkit/settings_collection/settings.py @@ -14,6 +14,7 @@ import logging from typing import Dict, Any, Optional, Union from copy import deepcopy +import os # import settings modules @@ -91,6 +92,9 @@ class Settings: }, # Plotting defaults "plotting_settings": default_plot_settings, + + # Technical settings + "use_N_cores_for_MP": os.cpu_count() - 1 if os.cpu_count() > 1 else 1, } _config: Dict[str, Any] = {} From e69c1c4a66b98a653db40911246c741d70004a90 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Tue, 27 Jan 2026 13:36:04 +0100 Subject: [PATCH 21/57] fix the number of cpus by settings for MP qc checks --- src/metobs_toolkit/dataset.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/metobs_toolkit/dataset.py b/src/metobs_toolkit/dataset.py index 938832c9..589551a9 100644 --- a/src/metobs_toolkit/dataset.py +++ b/src/metobs_toolkit/dataset.py @@ -1818,7 +1818,9 @@ def gross_value_check( whiteset=whiteset, ) if use_mp: - with concurrent.futures.ProcessPoolExecutor() as executor: + num_cores = Settings.get('use_N_cores_for_MP') + logger.debug(f'Distributing gross_value_check computations over {num_cores} cores.') + with concurrent.futures.ProcessPoolExecutor(max_workers=num_cores) as executor: stationgenerator = executor.map( _qc_grossvalue_generatorfunc, func_feed_list ) @@ -1853,7 +1855,9 @@ def persistence_check( whiteset=whiteset, ) if use_mp: - with concurrent.futures.ProcessPoolExecutor() as executor: + num_cores = Settings.get('use_N_cores_for_MP') + logger.debug(f'Distributing persistence_check computations over {num_cores} cores.') + with concurrent.futures.ProcessPoolExecutor(max_workers=num_cores) as executor: stationgenerator = executor.map( _qc_persistence_generatorfunc, func_feed_list ) @@ -1885,7 +1889,9 @@ def repetitions_check( ) if use_mp: - with concurrent.futures.ProcessPoolExecutor() as executor: + num_cores = Settings.get('use_N_cores_for_MP') + logger.debug(f'Distributing repetitions_check computations over {num_cores} cores.') + with concurrent.futures.ProcessPoolExecutor(max_workers=num_cores) as executor: stationgenerator = executor.map( _qc_repetitions_generatorfunc, func_feed_list ) @@ -1919,7 +1925,9 @@ def step_check( ) if use_mp: - with concurrent.futures.ProcessPoolExecutor() as executor: + num_cores = Settings.get('use_N_cores_for_MP') + logger.debug(f'Distributing step_check computations over {num_cores} cores.') + with concurrent.futures.ProcessPoolExecutor(max_workers=num_cores) as executor: stationgenerator = executor.map(_qc_step_generatorfunc, func_feed_list) qced_stations = list(stationgenerator) else: @@ -1959,7 +1967,9 @@ def window_variation_check( ) if use_mp: - with concurrent.futures.ProcessPoolExecutor() as executor: + num_cores = Settings.get('use_N_cores_for_MP') + logger.debug(f'Distributing window_variation_check computations over {num_cores} cores.') + with concurrent.futures.ProcessPoolExecutor(max_workers=num_cores) as executor: stationgenerator = executor.map( _qc_window_var_generatorfunc, func_feed_list ) From aac7c2e10cbf8643046bd9b1a46dbeaf1b771b33 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Tue, 27 Jan 2026 20:08:25 +0100 Subject: [PATCH 22/57] min_std deprecation warning --- src/metobs_toolkit/dataset.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/metobs_toolkit/dataset.py b/src/metobs_toolkit/dataset.py index 589551a9..d614a964 100644 --- a/src/metobs_toolkit/dataset.py +++ b/src/metobs_toolkit/dataset.py @@ -1994,9 +1994,10 @@ def buddy_check( whiteset: WhiteSet = WhiteSet(), use_z_robust_method: bool = True, use_mp: bool = True, - + min_std = None, ): #TODO: update docstring + """Spatial buddy check. The buddy check compares an observation against its neighbors @@ -2088,6 +2089,11 @@ def buddy_check( buddy_check_with_safetynets : Buddy check with configurable safety nets. """ + if min_std is not None: + raise DeprecationWarning( + "The min_std parameter is deprecated and replaced by the min_sample_spread parameter. The min_sample_spread serves as the minimum STD, if use_z_robust_method is False. Else it acts as the minimum MAD (median absolute deviation from median)." + ) + # Delegate to buddy_check_with_safetynets with no safety nets self.buddy_check_with_safetynets( obstype=obstype, @@ -2127,6 +2133,7 @@ def buddy_check_with_safetynets( whiteset: WhiteSet = WhiteSet(), use_z_robust_method: bool = True, use_mp: bool = True, + min_std = None, ): #TODO: update docstring """Spatial buddy check with configurable safety nets. @@ -2316,6 +2323,10 @@ def buddy_check_with_safetynets( ... ) """ + if min_std is not None: + raise DeprecationWarning( + "The min_std parameter is deprecated and replaced by the min_sample_spread parameter. The min_sample_spread serves as the minimum STD, if use_z_robust_method is False. Else it acts as the minimum MAD (median absolute deviation from median)." + ) instantaneous_tolerance = fmt_timedelta_arg(instantaneous_tolerance) # Validate that the required metadata columns exist From c86471e0daa32988ccdd538ed16cf4bfc43b6c0b Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Wed, 28 Jan 2026 08:53:12 +0100 Subject: [PATCH 23/57] update some tests --- .../solution_df.parquet | Bin 62345 -> 59819 bytes .../solution_gapsdf.parquet | Bin 3312 -> 3362 bytes .../solution_metadf.parquet | Bin 11422 -> 11498 bytes .../solution_modeldatadf.parquet | Bin 3799 -> 3841 bytes .../solution_outliersdf.parquet | Bin 8277 -> 14267 bytes .../solution_df.parquet | Bin 62344 -> 59793 bytes .../solution_gapsdf.parquet | Bin 3312 -> 3362 bytes .../solution_metadf.parquet | Bin 11422 -> 11498 bytes .../solution_modeldatadf.parquet | Bin 3799 -> 3841 bytes .../solution_outliersdf.parquet | Bin 8207 -> 12164 bytes tests/test_qc.py | 69 +++++++++++++----- 11 files changed, 52 insertions(+), 17 deletions(-) diff --git a/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_more_iterations/solution_df.parquet b/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_more_iterations/solution_df.parquet index 5dafe41040b4ae84d5a5fbf98cb908bd160c3c40..5c7db4b22589f24470f70a262781307f318d4ae8 100644 GIT binary patch delta 39803 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%0A0uim(z(F*KH{rsG7xPkbdYhhZ7T-qKjF=Ia@0Mr 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 delta 42529 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 S0ezlFl4B1K14DpgkRbq50Czk9 delta 413 zcmZ1^^+9sOOGd`cn_n>|Gf$3Z)nhcA+{bFf*}$g{0#)ggPqNxE`KM3jWK-dBbS!st zbaDg&AK%TEZ0#(QZ*bW%R!kP-)~|O52`9U|l}5R{RRNh`z9mS|Cml#Q`+}GeAi@Pi zIJ$xezW|^eKr%#PC1UQAcfklKtdlR;TQxYfo=p5U{8P;j*gxn0_bRisED8fm_?}| zi_BdnNAk#vflbW@%4Q^nS5!HAyMPRgoLt8(Z{P;fo8<1685Iulg^MRh3g{@HYttQp z?(zVK7|?rA3n#zjmKFnf#mOi#yuvUF>{GC$>*RYpO2DvZ+a~obSJ! zbG{eUU)87H42_OH{pM$Mp2jZ+nc%1R>)`pAbz>k9C^f4at(w!B)HiL%9|29J>en`g z(-(y^W5kY^Let^!NMHm%5Gnx%ek*h`_$>Y=^zpez^B2mKe$AUqU%9g3je>W^_F8_; z_9l^U)x6rKdwArLiOIC>HofZ2!6VU{amVC6n_lqA5&T_W9ME=;gntX}Wi~nR6gZRnNNrL+R(88g|1|lE!$9_H%d*hkmF`^tB zUON2k4Uz8$V^565{=5l)($5n0XL}2_}bf8)F%xWw8{BHEn zJAFs0zQ1AXn|`fz+w$Z?SMlc~Y%snXy78y}Te;h=UJXQVK*eIdvfMJPnp=%N%+8UI zt>e(qyC+ihdk{hy7zov2rN|hrW1C9RF*@RcxZ?A8ZYmj+@W#~K4nj#1VkK$mZqYITElCii_6kC& z0$D4_Kq!qsMj0ZPV9|?^tj|J35R%CvRe~c3bV(cEFduSzCz&=Sv&}Sy++VQ&qf=Q^t1}^Yf7jg*=V~M&EMOvI)o14oX$Vec? z);hU{;YoJhPA=~LaC9Mvr%olp+j2w0zt8Ytxhu8tiBpT9SbsS8QCV%MB@>PiMMvhu}Gzrd#fS@0#XrTjom9(Ltw?TaXqL`P6(zo>?_I zQbS_phRhRSwIVl6C1C40Ut+W5ZP<2wzhBYSMmi{rNpx7dXA7_H2AYl_Th z6^6D{^ki1`iU~uwL=m*gcB`r+yE^BrtCcoQUsbFoy^L9ZQFIfQUg`N3x$>4Kl$mNp zs_R0QsY1~j;E>Gl?3uBspB-S*n}sXo}WUmH*II1%6YsNehcj!!0*V!P(Mnis-0N zE51B_%MNQ2P$;*d$CD1}zPN5dTbu=*OE6GsUFCS+04S}JU_^m;#k8CeSLB2dhm$DH z@3jI7**jr9o@;5TJ-wUTs_oqZxDl)=SZmj}ByZTr&#xlVjkpFmDctt;wL$gFc0Nt* zn*hvIvwVxxxK}$|f%P`&t5(T&O|U`I-hRK~VWh6ettl)IFN(ZGR4C_4 ztE{o0DHfc1qFb%-n|YUYOd-1t5iH_E9&DPDRvpn-Nol1=VxaGF7MJZ9E+4<(nHy^F z(bEV2e|&jA0KFz;EAoMM=GX>IOCV*klxHZ9?Xf2HBDp5JDrar!9I%Vr1^-!uPtBe< zPBU1|$W=-?d))dUL2cuyv$yQfjD>6q&YdwUMTg?$7WV@pj=F-jAW`BW{7j$@KEh@K zoJTz^z$VI(g!sTuq#W2r67hw^_ctZcHr-7)lsO;W8bB+(+QojnnZ R%?|!$B*>^$-8#E>&M2# z?}*Cfp@PJrz@Q!ws*pfEfP{psBGevu9FUMWMil}9MZg6K&RnW8Gj>rV812r?_s{o# z|35ST{2^VJ-uMKN=7l%!e1ryYgY=MJgYU?-#o!5oAX1IOezR2T+vT=aYmwfEvV~S@ z-x{=po|+*Sd|=hEXe8q%uxm(x+lB@58u;1p&Bb7RD`Rh!bbD}bZ(n~H>Imd>zLKWf zxt3beOYQm;Vf}s19@Od$z3?b`a=N5A?VD}#RgxmXZ$|HMmim*#`JA0NgyWEU%SgWq zJ~PjQAIxqB0VDIFiTS`dXQS4rn`<^I{_WBlO+0x7LFyAAZ+V3JX@dCzG%UW!TNM2Y z#ay0b-T@z57QvxqY3j-Z{gsJ1Jjq<20whI)XROPUcaG8Do0-39<}TQ?vfzEIZ}N(n z{>#W*JI&kzzgSO!d)71b+YWk}vC!|$5wjO;6ODpiEHrJe5827vMsWYQlk^OoCw?_O z=6U?mCBk-$OD5ylm8PPW>IK_fw+l5l2b|NZ20j7w=~*&5d~N!snGv+zoSVa~3;R&9 zCU-e8u8y3f1i<@++* zcwS)x?JYf4DTFdDDdopl)VgI+Px_TmT8nF(mV`;>y%ALjsSU{;OQri_Q(TX-%Wtr$-CIbUoYFTiU^dgM%?ChM`9oQ*W|DRBvW?qKcwQ4`W=!MwG6ZnT-K7kuep zt(kB{g`SNfxa~SHa7UbH;I>f(wa69CW7tYMF_m__VG52|AiQ*D z%mw3M7nTOTH(?`{t_E`Pdd61_W!ef`>&TTxE1yfM`Ot=2!X@s6-&DCVU;P!7?NW!BP1kg;6DrywDDLRSqW` z#c-yY$0x8}LIKeN7efL^{V@Scjr`H0;hQq+<9Z!P#R;P532q%9y21sIBPKk*kr(*! u?5Q(2*-%d{Vi9S~#peJm0=V(?8HdN|cDiR@m?Mb)e#A=-g4pJgo__#$@W&(o diff --git a/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_more_iterations/solution_modeldatadf.parquet b/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_more_iterations/solution_modeldatadf.parquet index 5b68f8b54715fd0a3791a47c986ae5deb0188fec..57dae89016fefcb2a27cac6e6fab1d908dabc309 100644 GIT binary patch delta 619 zcmah_%TB^T6fGDrQACZ2(ZmNaZlx8Xpb3jR*cM1oXrfS7Qb4H`sBHy&1>L*r#)ThY z!dJL+=fV&0H=H}5A-b^X+?+de&Yd&oedS^GCFS+J_S4?av6+(NSh{xURPFV>;LV*I z@@;t{?ujov`83Pp*Ey5jXJ6c#0{6qW;a3R&-~zGUpPGn{&r3ojnKsqtu}~~z^h2FQ zPu;kmc&^=hKi{K76%`#dzg<@g8H&qvfekr;7$V$)1giyv`&d9^HH!u)%88T>AhS&d z8Dc)9t-8B}G}HwiWYx zf`T_ju16+NQUaMnM+^$I5=RRwhL$YW$_=BYHo68LRZze4OHc3#qxw2+d&2=g_7i7R z%7_1ZGR~3}qasVDmdwBv^1A>M0DX{3gB}D*^ZU+;fP(iAa%YiYr!ft6+>q?bAU~!V z5aMxeF=&>oi_5CBSvJkhl4Z3z!FJWM^;R<&j)a7euyni3aesgAF~D(IP`17R2_LH| delta 523 zcmZpayDq!IlZkQrW-q4a%#)qj^cW2%7qJ;}H3;Z~LAu-Ib!@gw?&Xu;vZ?U6IypKz zg0Q1Y@@83fbJoc@JhqG#lUMQR+qi>-lil4)qukx9fJ_e%-^bC_(J=tTasd&JSs=o# z97KW%uvv~SlYM#R>jQy;PNk9IhB+W<#{d^?eSM33kSjnEjzM72NDv9p1?GcoNiy=P zO7X}kj|y~*1gQ@V01_bc16@IeLY(3VW`Z?2gGl$Fj1>1UgG88p^{yd6U6vLg1Ax*n z3)4Yrku3BB@sq+Wv!lW*j3Ohv3L?Y3ih&jbZ2{@mE=JRz3{sDz-wVWt>W6zaALw8v zeap!{ysD|$Q4v8IiQyGhj=otyvn&D~odO+!E(V5gG9(m%rU6400&w^cs0HYtsEj0! fFmPx>v>-xY@;*K}U`*fTli^6>V_*ny3^D`&_X&|F diff --git a/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_more_iterations/solution_outliersdf.parquet b/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_more_iterations/solution_outliersdf.parquet index e6411ee6839124a02e05def2fe5c8475b96ebe4f..b765ec675fbda23188243c33833e55c25223519c 100644 GIT binary patch literal 14267 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 literal 8277 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 Q9e+)O&u)ZP!v9e4-?C5+PXGV_ diff --git a/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_one_iteration/solution_df.parquet b/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_one_iteration/solution_df.parquet index 1bb14962f068d682871dd1141f4dd8d008b28272..9fe8ac735406a8d034e01a2ad5520f31458759e2 100644 GIT binary patch delta 39822 zcmc$`X18BdJx>NGGbc2T3JR4X6g?>wdO^i9`s3a@r7YP~j->3H%d* zfgvPBaD^Zl5`{K*O@|;k3Q1wQa=GB|Xan!!Bv(nHhqgd{4LOBou5rWFykog=v6Xjb z?`V7W<&vdqzJ@Rig};gh5olc37=#SaP_!7RkAU=55?r}4eMkfvB1j~I!=*$5hi2bV zFDzq{DxB^uNb?PhOlv&?Nc3!?AuCLZCN?NARJz#PUN)OwKw*ihrJ+cuREK{@@Dr6R zb=@XD$BQ?pgqLuVQO$9z;X>M`wrgkxGmu~riNf9E!~`PsFeCykCI);BK@StHiWw$2 zqtU3Rv#cx1L9=+IUz~@dhiU#G@Pm||khp?|cpRW4 z?hsYStY--FDXh>=%*+$KwWF(iu=JQ7y2KRbY?-enGlxrPyT2aC#2H>Lv5JJ)J?=4R zNDqaCAi{Kssq>Brf-5biPo*D;#!Zqg8690scr(xA*kZoL8UM+idGxah$wbIW@qRQd ztF~c{YcNFqbu<~MM^J_Z`6d8Q3yHg44%Anm=}#}Id9LzFC9f&3nD!aG7w14>3JGSp z##b_&&9@g;nQb;DG<#^rj#O~;pHNP4=#wo#elc)4aSIpV^9ce`H8(^qJV9Zt zS^bAAYuMP^RsKdrPLPz)Lgn!Aty1tO#}mH{V>*8EQ_--wZ6`F1cT5qrohkR^G-9dC zsc1Y=D|8^+e>lw>2WPG}+32Jw3PpQb)))BgXNeUHyw^0}T$Tm!0=a*H`SR#t zb!4V3&LNBuO0?$06ol#_xYi!5LS(wt4uYCDtkan>|RTrC2DA>q8a zG?@&AX$mruwAOa*nCwXU@lxxN74cKw@7FWC5xF8`q9w{|?WsD?RKd7{(EcPl-;*KM zAa@rAVaPP78j>^!YDqLm{Vpc>ZpDh9_tsl60}P9;y)rfH8{xESBcJjcL(kev!i}wO zA8+4brhRZ>A4}8|Z}>8*7rNb48M8eG>OvaKbsh6M6#dzklP_kj?%R3v`>+0*Uwpo= z_~;Mto2Q8PzrBps9;6E^(Cr35wJmC;%!5GqV0~I>TyZN&8a5^3r5oe zHVBjiY1rzJ@(`Q)Y%BOqhHZYltHrYuyWb2ykB65y&`ffynVECAGR$L79C5keN|})v z2Nx0=JD|XuRc+}R`leU$(v(xN{S}>CV7!dKI-m2{P-?#+x~_WXW8b{D%#J$q(det? zKz)oJSbQ=|tU=77xWM%V)r5NBN(=^Hz3})|<0(}y>RAWkn4#E#-Z(hNzHM_Jws23n z-xETvrCQ*9*-jRxW3QB9Qyp;iquT~@an$<>;SEs(M_#4Oretv%2J4L1|1z~d^Jl*= z&2}}^y_=znq~@P5ymwuUTlXz22+e^Y_8miVvRK1GTszJTf#e@@^igX9m2BI z5BiVL3rvHQub85v{z1Qu*N|rOD$Nf0uin3?x8_U#h*zCRfo(Gaj|X z-Tr(g%J;D2CVapKxRtakE61OJ(JghW?=J2&iLa%nl~e=mWV%8=7LO zISqm;|Bw??HLvD;4>}x+*1TKLgdB^AIC|~(nTCy3jPVLILOY=hV=-E@I1)T06L71; z$$SBZK@-;{5$4BqO#)4jHY5Gq!O z4;$()1Ixi&IcGbdpetv4Yi_cCD0EzG7bm^cTO%chY`droiQ|6xx1z6m3?&RvK7CBL z^HAzAx|q4-DJm4>$xv4JP~}PtL&|Rw#<~PonGMvv9p;JG9y0fwMi*zt2b-9Ef%yf- zGRVRrGkArSSJ7QbbU?$RXtpz=b1o=ne6n)zoH`1<4^OSCF#PT44{dRm3;ss%`gY)} zKOCyAuDJQrnxcE!vR^laq-b~0Co102kb`+#NX$h_`+?B~%4#7MDa9~A{CDN~1rvOh ze<5rqF@;6uUh-ZwiHWYW+G?~B#$=9)OdW5}UDPAQ-4Dt^nsQPS$x9JAzDOsh0F9tG z=~t4AwN`rMp8M~rtrzkV9!|nki>$Wc82RU4J-8;fvU_0}VMG^HyyKz<(E#5>l=UmH zWPnFgKn}#P5m&ns=llrX9C2xCVYLSzd9M#uuBUG726Adt-Fkvk? zb003D5%_@@j?_*+s4$y_N{{F{T`>w#OHG~iXIb+d8mkWX#NV0O=_ihOo_Xx0*|0gG z)iZU5xezecXO3dg*Hoa1NCgI{Zwb!^#y5e0@z?8&{{cTZMDqst9%4dnrA0;@%D&SE zBs?kk`WTKA4ARYg_bt$bxIj!+%l)ohzo*sO?t@&;eYv(6Lh2)$uAB)IS>(AT{x0_Se*bM`AM~S33Jdo>WVb&|532x6J(%YSNi?Dl=`OIo6bS*ogBa~WLqQObkgUD9kIzwXlkI3XA3v8Up2{mg6Bu zOig$V$Vf^^{C2JpG05q#RTqza-bcJ^S{=$knh5YH`PwZhS9IMMbP~# zBt&^J)(iMvEICn!hpK7F7_j&|`0vhBnQK)}ypDuYGE-T!WThN+%abis(e`|S!f;aj zRWPj;oftx3luDBhqbaW3RK#J(mlNcOh(SAU)C09f{661hk%`w`8`4)487{Y+C@B~< z-06lRzM1oBq~pqZ=^~(BUR>Y-cAkus2wZ;$$avaw!OjQA;CbqtpK1v(J{oK|)e^ZJ z)lRe-HumY2qA;0F!1drADBLqr!ySU&4g!uQiRoh@jW`zGZEBEkVMwUaRlgK9`z6NL zh{}E+&cz8=58ZD|s301}i&K%0A0uim(z(F*KH{rsG7xPkbdYhhZ7T-qKjF=Ia@0Mr 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 delta 42581 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

xB2}-v?aq z!@S;(yHoi8J`T@L{G;pnG2PFHsWkR3h+bwyG8w`!?(*5?YE2QzoUL@BqoY4X@@m)Y zm{2t(9}*@!(bov$;Y8rp_VRCWV^S$p-|!)v=e6=l-T>v>o;Y5@yn8U4WuE8G98dZd z1EHK01}Z3pX>=z&;m+H&kxy;>bVoN_-dVrnIVIh^{+pWSoF6;p?yN5QasQpP7xhDQ z_8w(FoD0dY0ITpw3SeKD!6gJTX1Ey=X(Tpg7(FIGpEXO%(>_RNl{K*{B6q0W+ z4GND)uJPeU6GBypzZ&JHZDNjRCrM8Uy*1MNkc+bu;K8|3@wUElc`|9g0Rs98i=c=xY3&wO&TdM{8I%Zqcu-A^!g%vNX=n5^!UX^KA zDF{o3bfK(W9cDgDw19wT7LF(D z?#~_1(p`4j)rm1$r z%^k?(#bK%@&g#^${&3x?e_1Zu_a7y=k-?>~cG(9ir@ZLsYa9RkC`M`Sm_g zF9fLQQ)C}U7{(Olg~W^!F!3)`T<59uu=%Yhvt@@S&f%f*JPfWrt#B`RC9`Xh9E6hq zl6{`S0D5pK=m@3m{VbF!(Oa`vA73cS8M^Fum+_k&ErI6UpHkjB_r|)aRh!@VDb?Vm zT)VY>qH5i)11NHNYF~y();V*O6@3n`XThw=3hh@4B9tKyGFaNhSe=)uM_#{OaW@xc z$R6)Bf??FY6!W7$~~Mq8uMxdseJZQFUHU^12>?>8Ro$_IgmfO4`*n z)-sm}<;ySui)9BGkw_T^8$Y5*^%D1sHDZsuZIGkPT&?j|AQ#yA%~SeR;Yv*94#mn8 zJlW}K1uv-uS7;GxK&5#iN!pbUEiB{t2Dp#bbz*~G!C*7}X2&Ca`czYRw$AKFsTSx; zXg!&HQ)K?J^hG0m#r*ijy85~>jJY7b`W;U5+EqK)psdhOCw@-`U{wX!UJ`(hP=>xxn zy{52!?DvH=DlEWNn;Co8zW>+;zfsMdec!nOAY)+k}ddC$m+%&O7 zqmA3jA3tg_TDO)ogdKXX$_>}0`-i0EA#XNNK)Iq){N5I4;Rl6WpwRsdqbfe%IL})% z>pE~t5hQZlDjYdDlR=h`^=z5aWMyS{@kiLpn)Ikh)}^w=RFD}Lp^U9h8Bh-YW~1_( zaSiVirof!X-QNkOQQpec?J68G1}jvF7gA-N#p8|J-eZ1!FX+%#$S4{+DWDpu>K>LB z8?OxefpCRF3kZo*@vn<6sA&GhdX?anOglGm!~i;t6ss z+QXD4=Asf%?jxehwyrwx-%M_t`3>dZmR+AbewLT2eEckhb7UUH(&DDwzn2fpDc|KL z+1l&FP8WlO$okDyy8of~`fE!LeRFCAJ=1e#IOu(7o68#e&QlovLWL#Zm4K>Su|ax0 zHxOQmVA&iZH){DLOl-)(wiHp=Ii#K)j=%-n&j>lZ)}W(m05)NB9>_DCptpxDNSNu? z_y2~c9#V{}3VQXa9J67_L@lT+?AHj@AL~Q&SC6!vHtzixq$zu$IUn|3XH^69(8nbW z(j%;|TcY1HjyD>uk$Jah!HM#IdTQ!SKO9*`N??d&P)Xwlz&i*85)*}%k}3jCI~4rN zJxadW%Gyo9*aEJede|~20xD`qL0Msh=npIMgag68E1y)3daK)f^0=ez-=~zUo^uEJ z<@M?Rkg+ngxnawpZ|f^ibmisp=KA9${zAID3EZ>zw1pY?eq&|o&EgPZMTWH*NC=!OEq-BhUSi`z<>&N@1 zOnW~*U%v(71x&U>O=oPISEvF?wL~3XC~BiD3(hnv>-=BQ6E^?g`6`eaHFZP3%7&x{ z5s1;s>O~;wi|Uxw2Fs?xQ2r7OXt;HLYZu*HyjMW-`Aa3$hlBR&miv~M5>o}i0IDrO z)))aWha#IBjIQ1xUHRVk7H&p2_JG%5CAq@(?%3y{n_3kXvtXyF?Fg27u#FPFQ#g2p z8Z2?xs9ER4{q(d5Fo2kgr3OiCzryG{$bIic7%I9>Fguy`)d6_1z zPfqnp>Z$yK^BC*@jS#BKLYd3B1hdbzrSb8j#&Q?)hsF{TX4_u#j`RMq#*jC$z@3j3 zlmPF8mLmaK_I^sZd1C;)q+wfpHzjD>Jd6gnEf=H0C7R3ReZ`8-1q(~m4vKP_4XI}? z(Ma3H=2B8fToVV?5`IUmRB?Evp48j^Zb6n;o|uQ!M2JSB64HI7`ANt!4eL< zC4!O~v;?!8-28}ksAAxJB3{B=ZdcKKRi!UsU^4gwWLH%O$%+LtWy3=?3RCWdn`YCQ zC7RNGFyVQ87hpcVSO^AcNh23_s?61Q#lufOtWcRQHFdX8y;{dGpTK~s^uW@J%3-3oXJtz}iTpHB&bwN36c5H!o$6*`b(xVLa&5NFo<#*`{j z@#Kod0WgTg4&4!zJp@9ye%q1IuN#?%zBw=zWQ`mcDnHF^xe;xZHaCR1`X01|StDP^ zXn~1FEHLCCy!|D`*47{BS3INQhlHH;(1Xv3E!Zm+IBNyG%p)ymS36P*FNlb>GPBzq zU>T>K9$evs|C#r+8okm~BXBiG(SuOM$s-E5nwQMY-w23Z1trW`<5n+2$A@(PBUho$ zJyiy}#m=7b+LD&yeK3NWo^3I{-+Px0^Fp=Pnyl(d3VpY9dnV&v$o5VAvST&8)x7F_ zE6?@3+ z>YDbMD!A9BMAD za%;|AxIw;Pgw5Ytt+1gS5u%i8x~v4URiwWWisEeuidL&YHj~)iqWh=9YVX1qk9|gy z{|R0Y!FX<%JF&rx9HX^pN{VbpS6eo}K>wDzXvvA$Iy{lR zF7R%i)|~t9Z42bC~9*UE5idCm|r|ex{$uGsHaiG;c~3*E)B!=nR25ztzcn%Qn9oe=mK_#kqXL zZ>~-r^$D}toyy&$1X$nGogzx(^;nfsxKzZpw9jf;d#0`S6;)I6jrf{Wv6Hg`%dO$% zkH|N!U0tX;6EA)w3SMjITBS^S(&cj^={t#e5t=zLolBPXemJWBc4YqzDjd#HaKM!9sFVgTI^%)ug?A9 z4dH^}c#qt&;RPek3C2%)$>ujwa(j5nNQ>0*dIU{kSIX?L`N>F_W$o~) z9#B~|vSsI#m{8VkQA<9Z5rD>t+<#&Bha2k9C>juv%~(%x(FnyUjKQjEcq&gDETb|7 zA(J}gw<%P;k2ktm2+m@-CvM_MS(q{jRKoWdvW1!xI>$FoK|23k&`+koYcn?as7jA% zeySMsHuwihNt#M@g{=Jw&`g&cWx@36bA~K&fntVBX_PdkNCFiB^nNv<6yd|&c9`%$ zpBfHv)e|=y=RNaq2^Fh?R`7C!kYYlmCmuyA-Jx40I0)T67haQ|?Xe;WwCROA>yX4rm# zgxR-LH7)f&LHN&6-6D%9cXv8zE>hzocKL1Lb`#MW?hKp4Vd8}BbOl_mM>8*^hMn$g zOGBu|v0oG#pXX<<{yu=_mGYhGRdwHQ!)oAw~nemmXwSUB8&6xn{I`K%Ke z`UR$E4ECEp!>pNxUk4%&Cl0G3@8gm%Be_EoxP?HvvA!^73({5R$5~DgQqb$Y zCglVW4O_}fuh7x0hLTOOk2;337>o09DxI%o=)`K@$J?=0<}d57_6G_Nw)H2^68qJ` z1kwzqIShliPiMwS%OEfs?5X0curKbjvi7{V;=6Vwwvd9ykmgxk3hiCZ`(Spuo<<>M``@C6>Z8e?R1qZ>@7)Ufb3A@B@kx9z=w}F?N`x$UubUU zuKDs-zN5(ed8rSD`bT5@FrOc9@@yChnK;TO>d8d>Fth+(<@VGm_ncFDm@@4+ouAeQ zZ#s#PTzppu-RUgcptsbD){V$(MUp)`K@Zj%KFC{9)W6khzx~owG)c4N{mwR~tP!MO zvP0ht)yf)spQX!O5kHu3;%7pxQ3C%^z2Cx5k#|`bLB;w^EK6wVPkbfSeVuPQH%qMI zOHA;}jm0p#&mNr8z@qXzDZ$##E@;cvL&YNNaX@P0QWp8Gz|q@l4{*_%xK;6SL&1z) zr!}~uuPZ~C|3rUQRAd-CTT;$kWea=nac!FHgWg9iOtUMbg(W-FbDm7GE)|BI3Hh3! zhQwD{0@C!#I)<>!Xk(P>?KUh}Z>Izue$szEHfz2ug?YIhUIr0vZ?&=ccsTP6k_O%8<(rxDh=#BVoj<68#~L~`;9pPgZgP- zq3nal7hfws6Y|5&Xb_`KMW3k`S+^XCTvg|9Dy^gh1y{N^VkPw|l~0*op5kMww+!=3 zpTNc5tVBc?e3`75K1I$$J7Q*F*hkxGPmhwpMbDsRq{Lm^A+CX2rf3$aVAT4-+sR<2 z?=e#0H5MkSIW4i!F&453AKzSlC=w($hjsuAXun=7KeFRIsKDM64=lkLL7}D!@V4!< zP}B5w6_zIH@279n6Djmkt$RdJ8p2QU*)|U;IMM`T3$)Fm%^V`|)-2%qsyeR>@iv`@_|iVU-x%NrwrW-nx)rvF{s zw}3T$r)%a0gnJSK1PBmrA;`sms8DU2gm6b2u0^yqfzV<>3#gS^?LP_ODs~bP*mE{Zh`>;F9oaCcI*_acIiIO9<;Q_v(N0DegDCB+BxmaoH=cm$2SH7Z&y28Dia7~yy?kz*z60&#?{U!R=(0eD)kTs)*too>)Rn;61WI6o3kk}e)jra5 zlvU&$r+0-DDwS1#%Rbw&FURFGj91;3PfwPhmn=g&fecPkAah`rB*VPt>ssBGo5!=m-D%wsYH$>yaFCaO5W5+?@v#KyvK+i_lRepnLtKg+Byp|2b7_v$|hB)43@z_ zJ280F1TvkBMaoB8tsKv~E{0#P$a*nSX_#{2t*>Ua2KC0o6Qewp9*+M7)AWJN28xpA z@{)n^ zWN=h!i>a})cQmuJcx9b477jsV%|ZSpAcN(?OV2w~FSym>^4>j>5ucL-cP=^&A7PZ+zdUk_Y^TOb z*avKeu958o(;OK5!oquv5V1!cOEbi{g-DvV@>W$_ocsbRs%=4K`~mn@=Gfzsa3pFA z!uvEx_9u@l^wb2ncRLI&$g2UFA$h5{x731s@uinpdEWIDV?B4d%^@dFIymj9;5$s+ z3W>k`(`1Mru=(}L5I=32Uxf_u)28{wEkmH4h<~)9ejzr*Kh2$hA%4lh{Wr)Ek7pX<*PCj7lnwF!Y2N#m3}K&zf0e1`M`s%1KQ0NsDjDMMtWUoL z8FJqHcG@HU-U9V)L;XVSMEs{#%b%YN@y{>aKMNV+XWB>nvt)>0VpH=s$b38ReLL+D zzx)>KZ?IWaP5jFHy}u?y{Qum1f(-GOt?(}~)qHQJ`SFQ{_=$b_FG1$}^WL{?h=1sm z@Jp~+Fb(k!of7`K*Zh2)hWO7PS$v;{k13=&T-w4)OSoIGWHtTs&hn!8Pjf6z!TyP2 zD!tSo)yDf*<2^-Y1OyvbP9b>Pj985gj5`db&?IJYjXgMXhq;A!c(1ryN;CuvPb-?; z*4WrNOE%fmsqVG5Nm@C)Kn;p|`BbYCzJptV+hRJAM}rLaQr)`^Ey~t{(BkOh=iq?f zKYLHgaW7z{IZf#6vpAOor=UMA7{K+gSV?vA*Gr}JpenqEMMqjr&&E+7%L!p`A1r%Gf z^bG!#-l6nnMVoRLUnh)K}UxX`P?mD-?owa*#^jJU(il;U1X z3Sf~*KG129Ud~%-GZe#1hG30N?{4M!R+6o=X(29*ydEC9HsP;3e`lYvpMTWGuF$YY zIpv)RGDknY)rohg&N{|PpVP`6E7~-zoKk)l{Qr=KbPRu{2O2pF$pb1=WPtPlf@-iN z^jHxDU=WW$#8{Qetr=qJ-KxhPjC#cXNs#Wda>yd%KEVjdXB8!e=}P-&x5B+sj!+SV z_+(Wc5aIIv#O&!tEL{7>{STabI8NVrkds!@X{w!Ihl#0aq8!nKaSm~0)<_NWJg9RN z^@_UE!UcoYG=b<55@17n(1kac;bs<6AT_d~M=BFk_JmyVSVJjm;Kr)i2(D0D3w#p# zI1F408{QDSYn#knvclGBVs4Lu6T$9DXo^BY@BuXzOE@@u5x4eAvGBmTYfYRDOF3z( zLv4iFhZiQEO7}aZr@4qa4|dB%>9g8V-(Ed!L_*i1n;82LvrdN4;;7*l>@)17+TY75E;yu)^YV>_$>Y1HmPU!ddHeOO$eufQML!-jZY}=6g_rp zMD0O?LC+R;UEQia4bDlmcy48s~TLF&$Aewi*ZAZ z^hV^4Kn?q99)AroS!$EsHkBSw6ScRZTh;Kz+1c2gETQCOzFnnZ`}vbMOLk9kMnhkZ zAIOZ#sjF>`d`lC5DW+j>%|z7Eo+B51!|qa31@?B@&|@Iuo)TmNgZ-Gg=#^cY4VCeM)x;E4wr z3Q{<#dZ;6@8|Afcpj1z3DuTBmbW%$o9>8_QK`*4w73Z=Nbz@V%<*zyS>yaUTz~<*8 zL;QoLA^wJKem*uoO@{bK8|oJ#L;O>{=I0~xkG9h<)l9_CJf29)5dVqsL@Z1-ztHIZ z`|U)W_|>MGA0*}?mUS{aL*H@4gD6iPo9@^akjoAdFKvEjzY_7Drs(}-`f_D> zY0LWKeOiR&p6)gw%<^<2B5UBTY!kuPze+GA_Xavi5}+VMP|vz?b5?&xW?18+Ks#t;>_gXYkmL^s(aVHU}Smy<5V?b>?&qrO1#DLLTuTcNakCxDsotVaeeHkD z3)XTQs>8>{n}_zVF3m4^aA-@snwnQtQ?;!n;!SgHaRee3nuKz<0g140P}dT@_x8-q zK$GZ9ny-tw6y*=atWk!0fkH|j)zte0`&3Um(7ly{(S!=VQSR`0Vs-XnBY_OQxQzxx zpwb*@lJM2c#7S(iG`qY+J$GEWl<8}{!lYX-q;4pnhS>NG*SmHJ_H5x^-nvvl4X(3{ zu|Io8zs}f#AlMpS^tRhwsPs`4Z;Rf>e4$0yw`Ix&kt5SLeV!}0(-7~S$Vzh<-C}5q zzqEyN)$en;(wg8MUPhp>N2k@v@pHq6X?YtN!;WzqG-88GWiM4CSpDcuc7SkY&81k{ zkvoilGt5M~cv){*Lw5MP29r3RQA79K%9^jwuSp6EE=^)gvCHEA6mAl_m}_tOS2AC5 z@@=iQwld|jBNKu9whTB%9~TKtG?xmFmElvx8g=wb%te3kNVIBsV+xBUVJuOhci^v_ zmN-llSMTzY4_X_c#mH)LHtLb~xfVS$3pmtxN_h8q`e?2T9Wi}Xl> zTFta!K+V@?V_O=^a$bEqG@mQIu1bx|V+^?RSKL>OaT81z*lte_DLz|L$jIFvSJa1? z?bcZYb5{->$&5HKJJJx^H+zq85w=vE9F5onHldqTvPNIel`KcUUVTU-Z-;tP?&wiv zMzjW0g*c@mgtcU-VH$zi+UQj!=VUNN%@m1%=B`Ju+>S{o#gL|X-)-;>!5Y3gmHVVR zo`W5FE?t~l7wPmu9=G)QbZoowS+y*bU?MFOP4o}uWrj8(xklI`!uD@nF)jpjEoMhy zzi5xuFKY*9l;=sR8@N5x-iw8 zhQj33T#S58uhV%M9*w!?=_cIBNp?Wf5G9K>bgc2zec zOgYPT4C~@|+1T0h4=h2%PIdJJ^Dy6P4a(}Mkq4*mNHBOkBeA9hh{(+4Ap>hzGQ_|_ zfWhtRV(ih$1ZO+V>=0rFUku#DS827&S)2cJgO3{SNa(tdWOJ z-3+g+9SMrO4Q5<@hWB7Roi~WlPF)D*7IqNin$Uw8aP{Dfk-OAXmYP#|TXHR6$+RA6%AY!5T*AZZz59&E_lkyv9dlDaimx*>uR zPp$}>Y@DAUE)l}Gmg_0KU)oS;xi8Mkh`q0H@T|_^jAX22;K$2VsX6ZrN!*^06z8S{5(Tzz924^x z+Rwxn?kj>km$SlkI9e{W6M{Zf2qrX%5DR5OLLb;1C9eRwFEY1d3=`}uJ{l@($3uxI zth00i4aKIS*d04d)b$;kjkh`IHN9+_i=uPdCK2kVu(Vn+((cGVC)oNDQni%Oxk_8LZrG(>8P@BoS8*4tR$oi_1qmbH=qS&nGD%%#gx4r|XMr?h|v~ZV8MB z#^hKV*fa%#%%*aTr%<)7Wr55AQkxNxjl-&@sG`Q8u6estKf3H$lri&Jh=idvr4^A41vZ#qpw=ktFSoOqYh|Jf?}JVw$>uyQO@t+Z98kK^ zAg~#aJiKx;U4%kyQqgM%X``IOBJD2BB{uA^%;+@oo;;c0{XyI%p$FrI9%Ql*5rZ`z zAxp5|ODnu=cJ*AI6h_Y}%BdamOlHgkOOqK7K1+*T`SvZSBEEu~nsax2KxR)%Wot98 z*-U*Qw^U8<9!2ajf1lOwa16*C78$L6*=VP*UNvQu(~Gn$9)fclIewiwESA{RNH$U+ zHBB2}565nFBktNwqhCzJK7t!QT^pF0=TB)-O!md z;ohOVwuBewP;sz#E}ok38qQ0>sfGa?JB^#42RWYpfrZBt z<>$nECn=@8>6p*6^NeUHuZ?=h0nn4FmRQ=q-N6=%8Lr4%J-vtPTp7OfttBu2snlWB z<{`nVCy-6U3E9jY%uRdpT)c9lqq}T6Ii&yX79o;yaSt1KMnkicdGp-E3Dq{Y5EG>T zp@%9rCwLIcf)uPqI*yh;$vfImu>3|+^{C^m&KnXs(g3KOX;FJY-myX4P>cGI8D=na z;`aU#NhT>ok@tSml&iDo(Xd-igGD(KmoRU+l(Aw+krF8O4u4C!?0PP0H~&#_Be`MA zJr`eWu0ZDewbY2?DqUu!9MQ3%E<}tuEuvNQ#lH3XsPmpSZeiTQS6O$Q$bOdx(taewA)q)vrdL@cx23 zKI{Xe4C?kMot}5M&MOUB%GJ_%1iMR}_}mn&gKvQX4XB^8#RoM{xrRqzrddoIn7QN? zEywX`2tr4Tg_1k+yz`xt+5U2-Ug7rgkjg=(S9G~TB}VMzS*V~DtcuSt{#FaWLT3Px zUpLQYrhp7u{Ru5GP0W<*pnzi3sGhXjz(WFlEOPoMBoxmTLQ2v@3g@=m0@v5eqs z(d5LZuA7u3UBu=**_Vfs^p%f>N1e6inQVU}L#LCRMQz;0$lkzsnx;8ZDoqyIl6--> zA$=1horO~FBT?YJY~*6HvHJ5;kTA7%P1on3TNjf86J7p(NOvPyoM+8D8}d3 zZTw?rr`y%uQ)$sD6*af~*A49*i`ah~IhU0M$c^m}hqs43u?Ua%#Y-J#>byo7f$PjU zkfG8@m#2v?H2Yo@d}w8)8gljVUV_auiY5t%xnMIS)uK=)ZSh9b74^c3kiD3|LYckj zrSOO=?#Xi1NZfl0Yf50P!kFq&P^Z6|u{2}mx2&!AC4nAs6?gZoeU61h8l9@PCHna7 z+;M-?I$WG@cfOewz5R4!ix3AZEt@CsYGm*;lvandM3jhCD=Cite1$>_0b!}sVMI}_ z^Ex&^JnAAahba7`dk777o9Hz-KT$FlXS&}-rN>8k&D_{A<%o36ElFPB8t$GEkHr~b z7r86Otif&)#=vT5V%0kf_pT#1pMf|e=nD5R?Kv#b2ZnC6uGgJzM5l=w);jU)*l9d< zBF>Z?LB3n?Yy8aAb=DflV>LM= ziKXdXu-SC_m15l2oTU1xcW@dx6>ag391g$C#_m#3Mk9$AtDsPzzCUH-1tl4-lWfQ? zJmn}jAMSu7%fV^Amk@=)z0gizGjCVXfOoJ6$q{z7T$c0}3oai^?)pF|bU_PntP6KU zL-40y<;LbS{N!jfbd_(x{{-EH?Jb6uwK!ebf+}{fMvXST=qGN7~BHbF$5f(P}hrlVf;F*gck57#)M5sy4ap7fCd>VretrW}P7@VO5 z_327NSnLg4oNk*MS=+?nyZv4(N94RILJ+e%W^q@UK zf_xzJLf~_95w@1&?1mI~lF(6P_w*6)UiJrq+le-1YtV1?xmhKLHN(xE^n)|LK^|3i z#yJ(miqWV#QJ0hd$s~*)gV|UhP#~XY@iQ9nWbFAQh!-W(>!kw_5^Q1o-=r zy<*SRgGw3f#3a9FCS6d2H{T33@v-f04g98+V7AUiEMyp!n#eBHABEKW6Q(;YQMC;N zJ|ZO-XQ;z!%B_;Jsyw7Ck&!KsS)HT>852onhBhqu3z-#UIZr#8 zS_T_ZfLq9H#|+Xk#1OXJ2S_%dIJ_}UBBW=K2}UHDTdMvj>;O;n74p1ZX&5L{ZEx9w(voed8hl;trmxOi>fG2HUJrzyAC zuZtIw9d*i-aEbHwkI;BO$xcI){ns=|9Zd`Lw;5y_Di|Q;sPcIEo^e&EGISUzDv)F7 zkAg}1TMXm=t%2ANpFj^%zREqL(aGau-GTQiIn9x9g;0C#kBB_r>2&rWZ-oA@<&~plJ_B zJKF{_@Y{x}L8kRI?iu={u&)D|%;*#6u90Mp76-jLdGcAFW{TFjX~U5bdGLF&j%l&4 zc9ga<2D!ql60_+}^M52$yNe`q$%`Z-*+Be&%|aiM79%GOvLf_!vMlbB|41eiWN@lP zvp`VeJjk@350wQCjtwi3@VV#ab8kJm*AhioYO~gF>mOn8PFB&1Rh#=qS|tC$GCD#> zvdIny8&;fR`r7wQ^LScOlam^1MI@5uVm+z0TL$~MP&K-0=nzKB@hG9J@;Ihhx4!tQflr}JL8`(R}4uD;VgL4-x;6lv~jxX2mcjylJ_zQfqGK!*5UJ0+7|gaeay zGLsSov%{{2h;~vh^bfL=gt)OtjepFw3tzKUo(RP40?52s8ahP!*jof78S-nQFG?yy zMK|A!I%;`X#Ov%ieKY3O>D)BGLCr8Dmgw$KWr>aw^C6c?cJ1A>Z-LHP`{_sm;gd=x zE)^HpnBX!1n+~$W=)Gs}8d5Q_l5bDx+T{)TNBx3n7TW3jf@w%L+AO~#^ACveNY5R*izSr}oC${vfi~T-A-)ze2&7Umt`ai>gd~}0 z16F9-0W*BtkI58(0!s4a!HMjND%Y586;m>#sIeIs%g7%@KDUm!{XEi2`!RG%LkA&j zf4uxoN#%*tjpMi!eh4y5A?jcGi0?>vp5oy#PIlEais&Rt({P*}cEavzI+byl8=^(< zjM2qTh7>QNQJ#S^In?dUI!W++0?zy_?XZXBzM#o_aj~&MkVxilFTRc{<+$N=@Loph z!RB4=yb!Ui8i&PDz7SV4kj@CLRMr+(9zRdq@j=Mbos!V)?#AXdpJpiKJXIaLkshS~ zP)c{_si=!ytDDj%ebU^{SQb=QU*sRs3BioB3=S-#fi}-G22?7h9l$vy@*vqvZrqk7 z)rf%bUOsK6vx?>f4o}oxW?dH4;}%#}T>vsgjCd#ZT64EW70Nv0NJPJB&dc=es~FA_ zw&HGFNQ>!w)>lrczPRijzkL-iW1S{v>CN8MtINhsM^<^Q;(snp{`%1c&C*Niy}O+u zU_=WLbU{PE0qtu0bqKm6VCC4V({jb_oOJwV$i#N~JMk%o564 zre(rMUNw*!!C<01nKNHwS@u!$+eN%PlV4pa_TJ!ib*b{X{8h!D_ukmOl4&sXvO zJWIb>{IBZ#t1EdmUOo(#sc`oi>YBu>#UB}#`mkT3pwn#V4PjA)C>G~`hEhrsgfKY6 zOd)ZLMscR}$YkCt)i6z=#h4PeJcpXjmt2;5=aYjwzbFH}Cc&mz?YovKu&5jr`824E zXe88P;CDx#Fyk&&uKo-2mEQ*F&0wn|*^hQ>NLKz{QM$dUW_35vx zmK5=P9)uMYe_i2qbLGF!Z{EF>_^j?L_hn_q)2p8TB&XvB;eQrct^vpT9!Qz!HDgYw zrzBgn2udEzGw8^RB6`I#aShDiU347^MpvdOHjfn+MovbJg3Z{v^)gGJuE%!EcdbBz zcFNf-@kN_RsE`h}&}b|LM1-qm_R%QeV*Rp@CE_C9X9WeGC1rc*)}{CAzFM`EMHJ_I zBz>%=do2I`<#uoO2GsfvIMzG|GG^+S7g?wsu{1R(@~1(0hz)%bnrAFIpyCHH$1NX( z6fD-s0u+6y(iP-eJULzP39Ho}F;qWC^%?%pqSb?b62 zxe;C_<@p!n6IbK!^zO|`p5UUW_@%b8D+v`*`8mrgUQO{}Q^?8?$0#%jQ%Hf5ot+FS zZ_y|unb?<-q%yK(hu-^1axs97$XVBFU$bmaJb`wyuNUk0vu3b^s_1S|LYzwPQ#sV# z5USfqwfN)0p(TRYR&lbCp2}Y$XhU?1<+}JkN-U1X%YOStm z3VrUjwUuU?B~ooF)9nz=`s0IeF7K`AX7bXK%$dCM`?=PrF(pgtv~ya!Pt=e*spawK z2AUjJi47P}b}}Zjoea}C^cKdGoy@TxlEJ&%>*0_B5mrog_Dzlbi+Hf%nAA;-sZ4!R zhOiF>Ap@IK-Q%hz_5ekwbN(hPVt=u&C31(T=2_mW`aD|2Y`L(tPdlzUz%0rX?pt?f zBJc$Kw$UewuQ>=eeff1(X#cuB&b;i|kY{-Zr`gol%*288_=ewJlf(=dLZprrF*U7D zJL{%01(^rPPNRWoKqdpCp6J16pwr;LK|_)FqtnpHHd)wwvf{}4Gd|M!xjcJA!B~`ICV*u$7JZ#ZW%N4W3mVL*gFl#9FtJ(WMuaJ?_)NIc*V0pje?{g zUyxfS$>8X^7i8YOH-W2Fl8mwzWcpPI%!ukXt%bB)tLXVhrUm1zv%I%039?9~Nq3R1v3@~OdHk;dCohD0HWM84 z{J{5Z12RlXXb;uFP9~OYs2?#68Pk~N zGbusFgoA(Np>qw-^4^FkYTfis8$w@q5nJ|1mD+}r7fTLJ8`Fb#9XP4_?$#s-gf(Rt$@lUc9~$SAu%^6x*7a!&nU z-kXR<{43ailFo0}xf|%hTld@)ZFu$CSk$(Wht7JBga?^n4J|YtZ`%x2^+Ph`c>1=P zevAyJ8?u?G*zbV@BPSORoG2}&L6kP-Z&W1xeAPRFCJwU$CQ0!}Yg=wQtXUgi7^wnOUOlcg!euWXcy7fh ztLdrL^=)f-h7YCj9I<9qQlOyBmJql_V?|kC8cr;?IS32UP==W(EGi2i7Xwl79yG*zetL!{+?=y$!q9e7ufM zbvb(w!SrW)QG83-W0+tPbgdGnN($;sACp6RT06*gE)4%v)8M^F!LE;!Sf=V-^Tb=n z_)ChZ7sXBMUaWtm;BNI*8ij@Z9yKR_m_p|oEL0k5PtoWs7KO?bm?<=B&Jx?x^L6co zJoT4!MXL9wUOsF7Y(V7|kMdKyQ_G&qUtL`G;44qmQX(`c<8-@p81jdE(h$xAS(*(y z1T1F-q|{tCPIJZ!b52W|y_d}!9CDtts4wuZRwueC#A~SOPw-=xHmfC(jbqt4)7F-7qwlO>w{*tF8}i{*d~>ykz$ZCq=$w1xh|$No4R0 z6l%m!il+GA?zca~3tlNl@g!oV%$S=t^SR;4sEWB>+EP}nh_K~bR?MN6(o#pR(vtYcu`)vQq^HAR9?vsXK9$P- zxcxVozN_?8DcpPQAEa{nw|J(dNl$aQqInrD;HBFTDHrx|#+|f}N@W3B@_3+?mu80# zJ>oRyy=)V-c-JVX7r9TcXz0N+oG+!@wC~w+Sx_8IxuW{tgYAkH4?9?% zE9Vqt8e-+#5d9e{olmh){}`TlB{hmVui!-<8*G2(sgJ{^QjH(Ce<0^Ns68d$^!5Da zNzR64x#4G-(D!`FfQUs%mAAUklU3vufC3s^W=IGfd&FUuTgIsM-XYGl^i0eB=^XFE z9BHPzQ~3a0vDhlE-@5F!p_uxhgY|*x0b%)&wQ~7D%9R!W8^wcGJskXMHI>d4TToUx zpYoJ%vObR&`Q?_5XSl1>*E5aB2KQt}-Yerih4N{J+;8rxp9we4m%0XMp#X+p*UmJi z)=V9N9^o972~k)CuFP|op6{Kr)O)YFD`{|Btfo5gtM4l(1-bWGHmPJ>NsJ`V4zb#( z_jAfDDhgQ^?O$M(6%pYq$%=nbeEtR%Vs~;>>h+>L#V+i4hUc@hdXJp#&~YOrc$Th^ zv%jRXp8gYM%attZ5iTp>eFml)Eu{9i!du;=Ok)qGnjzDi6y=MQ&{Ah|ou9yNL64gn zNZMV6;*=YK6HFdiqmq%&A7{y*NFEDm5ITp8ZK`cN)(3$PSQ?2ly=p*1WAIp>v=o7| z1%2x6h9sYcBSpMVlhmHc6yuE#vc58ncrH`z-n;kd|J#=O##7!cOML=X+sWWgf*Wk; z#Be$SGO&zv(J^!yk_>q|A^x(Dczjf_@2~>NJ3nlP*^rT|$Q@Vhzb&QsGDN8CBu6dS z=;Kw=D=bty84aYcrnA_e>%-D`Y~GS0&cF9ITq&LqNcZ`=2lXz!xr`T+FJH#m(a!iD z8{zla`2LU%*pPRwET2<*_jN{4iBrqY70`&;dp85u3JhaDrh@MAz!tIgVvIQidz-Ef z>S$yIV$H&`+s1JFoV`+!75wUK?kMLF>S8Q9OPO?y+kmmcjOv7|BGdW$C8nYeF3q7n zOmhaj4?IXA>Fz7;i&Iz$=jMhzm=4lZR$kjX^bJxIc`M&HlNDMiBKBlN^i|08K9umu zhG^RCJs-9UE~sdx+tw`KeYn;PB|-*nmb^3Fb&>O$SmzXb*krsIwNtE1_ZuWPd57jv zKQEIC@79Jvl`K!lNSKU`Ofp_)u0A+tBgxPN28(@nU`=sLo}QGsTCXcpSPmmyDL&F2 zBRb*i2c}_KTxiEd%5m14++~+aS)uToywg+I z7^$4M+Jx`So+>}7u2H%2VIDsH zZ(ew~!@$E_07Vu{S$UF<=cAr@-~xkzX`z(W>nVO*?n8Hcew&&~F;gA5wkUjJpw=?p zI7(IMn3Fz~xXl#CoMVC~#V_5@W%Ut?U(i!76pCcMf+?x(+g}oltYg@pXhy#Mp6QW=?`c~nIb(mxa`D@j1Y-*?y~_G?7x~hj ziN`;!oOqswUvHgQc-`II@wEL%yDld(xW1I>_Kz=zlkaoBoqEN}x#r`2+voeu!fTg) z>ec?#r#=L1eav;f*Wp{EJ5NKJf0{}KXaF5x01kj7-~=!MXTSw;1>694zyt6ESO6Q~ z0A2vX&Z*vj4*+UZ9^eP~14IBG_&^{K1Ox*DAOr{n!hmof0uTa`fCz{J76H*f3=j*% z0gC}K5Dz2(OMpaRDX7cyumN}ps06Bjjld@0WuO|U0cwHGz!qRD@CvXEr~_UFwi9@G4cGzf1YQTo zuW0}+pabdwJd1&#s#4fyXsAJ7jR2TlNHU;sGv+T;X{I^Ev> zVV=vLSno3nvs3%Od>HJGu0QT0dED{a1Fz?_PhO7%0_cBWQrdHF47l#uMfdLYvJag0 z-`xuKAb(FX)K?)3ohgj3z3%qqy5+0Kb5AE|CMe`+fqV)yHvKB{C<@J51=8MCixoe=r{j~Qi<-)H{|?N6jK^1agQD%NZ)+Dv{0 ze<9x3P9T?jF~x4VGO7G8FOtvjEvxL`^rOEoqokrVqo_(MU9<4>96VY0`1X%_vb}9K bI5<8|94Gc$$EQ%f|BrIpjY3gK^Wy&>Xm(zh diff --git a/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_with_safety_nets/solution_gapsdf.parquet b/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_with_safety_nets/solution_gapsdf.parquet index b802b21c2dcb47ae3aaff147e76a35200d4f6209..5ec3fd78d9e25432e7d1c5b457133ef97824b8da 100644 GIT binary patch delta 410 zcmew$xkzfmOGd_{n_n>|GxH~wloVwqm6oIyD_JR2*G~3gHDolNT*GR_xqwd}1PmP~ zZ)de*3MilaomGV=)Un*r(a8}Ad_y;@v$eAbyBVZ-cojtEg&9Oe_-6WNI+>?UUdiRe zST*?#m%b%XVI&Ycl}Cn~W=BT&LisKRK!#%}i0}j&=;)LTVuEBH9Ropx!DJmCc`;WI zGbz`tBnm9(m<@7J#N*y_fW@>0HBOX2#~OtT+Jgboeq?C0$Lc9 zlj5EO)$U{h(q0VGE(X>Pl5{Ib^6+)c2Whv=n%vGUpB4(zS{xbfm6HTyIr?US3=MR2 z3UqV^TIuMR?C1g#2L=Mzc_4P6E7*M=AU4GPU=~n|qoYSuMv_NZ4oCo^1>%*-Yj~7^ S0ezlFl4B1K14DpgkRbq50Czk9 delta 413 zcmZ1^^+9sOOGd`cn_n>|Gf$3Z)nhcA+{bFf*}$g{0#)ggPqNxE`KM3jWK-dBbS!st zbaDg&AK%TEZ0#(QZ*bW%R!kP-)~|O52`9U|l}5R{RRNh`z9mS|Cml#Q`+}GeAi@Pi zIJ$xezW|^eKr%#PC1UQAcfklKtdlR;TQxYfo=p5U{8P;j*gxn0_bRisED8fm_?}| zi_BdnNAk#vflbW@%4Q^nS5!HAyMPRgoLt8(Z{P;fo8<1685Iulg^MRh3g{@HYttQp z?(zVK7|?rA3n#zjmKFnf#mOi#yuvUF>{GC$>*RYpO2DvZ+a~obSJ! zbG{eUU)87H42_OH{pM$Mp2jZ+nc%1R>)`pAbz>k9C^f4at(w!B)HiL%9|29J>en`g z(-(y^W5kY^Let^!NMHm%5Gnx%ek*h`_$>Y=^zpez^B2mKe$AUqU%9g3je>W^_F8_; z_9l^U)x6rKdwArLiOIC>HofZ2!6VU{amVC6n_lqA5&T_W9ME=;gntX}Wi~nR6gZRnNNrL+R(88g|1|lE!$9_H%d*hkmF`^tB zUON2k4Uz8$V^565{=5l)($5n0XL}2_}bf8)F%xWw8{BHEn zJAFs0zQ1AXn|`fz+w$Z?SMlc~Y%snXy78y}Te;h=UJXQVK*eIdvfMJPnp=%N%+8UI zt>e(qyC+ihdk{hy7zov2rN|hrW1C9RF*@RcxZ?A8ZYmj+@W#~K4nj#1VkK$mZqYITElCii_6kC& z0$D4_Kq!qsMj0ZPV9|?^tj|J35R%CvRe~c3bV(cEFduSzCz&=Sv&}Sy++VQ&qf=Q^t1}^Yf7jg*=V~M&EMOvI)o14oX$Vec? z);hU{;YoJhPA=~LaC9Mvr%olp+j2w0zt8Ytxhu8tiBpT9SbsS8QCV%MB@>PiMMvhu}Gzrd#fS@0#XrTjom9(Ltw?TaXqL`P6(zo>?_I zQbS_phRhRSwIVl6C1C40Ut+W5ZP<2wzhBYSMmi{rNpx7dXA7_H2AYl_Th z6^6D{^ki1`iU~uwL=m*gcB`r+yE^BrtCcoQUsbFoy^L9ZQFIfQUg`N3x$>4Kl$mNp zs_R0QsY1~j;E>Gl?3uBspB-S*n}sXo}WUmH*II1%6YsNehcj!!0*V!P(Mnis-0N zE51B_%MNQ2P$;*d$CD1}zPN5dTbu=*OE6GsUFCS+04S}JU_^m;#k8CeSLB2dhm$DH z@3jI7**jr9o@;5TJ-wUTs_oqZxDl)=SZmj}ByZTr&#xlVjkpFmDctt;wL$gFc0Nt* zn*hvIvwVxxxK}$|f%P`&t5(T&O|U`I-hRK~VWh6ettl)IFN(ZGR4C_4 ztE{o0DHfc1qFb%-n|YUYOd-1t5iH_E9&DPDRvpn-Nol1=VxaGF7MJZ9E+4<(nHy^F z(bEV2e|&jA0KFz;EAoMM=GX>IOCV*klxHZ9?Xf2HBDp5JDrar!9I%Vr1^-!uPtBe< zPBU1|$W=-?d))dUL2cuyv$yQfjD>6q&YdwUMTg?$7WV@pj=F-jAW`BW{7j$@KEh@K zoJTz^z$VI(g!sTuq#W2r67hw^_ctZcHr-7)lsO;W8bB+(+QojnnZ R%?|!$B*>^$-8#E>&M2# z?}*Cfp@PJrz@Q!ws*pfEfP{psBGevu9FUMWMil}9MZg6K&RnW8Gj>rV812r?_s{o# z|35ST{2^VJ-uMKN=7l%!e1ryYgY=MJgYU?-#o!5oAX1IOezR2T+vT=aYmwfEvV~S@ z-x{=po|+*Sd|=hEXe8q%uxm(x+lB@58u;1p&Bb7RD`Rh!bbD}bZ(n~H>Imd>zLKWf zxt3beOYQm;Vf}s19@Od$z3?b`a=N5A?VD}#RgxmXZ$|HMmim*#`JA0NgyWEU%SgWq zJ~PjQAIxqB0VDIFiTS`dXQS4rn`<^I{_WBlO+0x7LFyAAZ+V3JX@dCzG%UW!TNM2Y z#ay0b-T@z57QvxqY3j-Z{gsJ1Jjq<20whI)XROPUcaG8Do0-39<}TQ?vfzEIZ}N(n z{>#W*JI&kzzgSO!d)71b+YWk}vC!|$5wjO;6ODpiEHrJe5827vMsWYQlk^OoCw?_O z=6U?mCBk-$OD5ylm8PPW>IK_fw+l5l2b|NZ20j7w=~*&5d~N!snGv+zoSVa~3;R&9 zCU-e8u8y3f1i<@++* zcwS)x?JYf4DTFdDDdopl)VgI+Px_TmT8nF(mV`;>y%ALjsSU{;OQri_Q(TX-%Wtr$-CIbUoYFTiU^dgM%?ChM`9oQ*W|DRBvW?qKcwQ4`W=!MwG6ZnT-K7kuep zt(kB{g`SNfxa~SHa7UbH;I>f(wa69CW7tYMF_m__VG52|AiQ*D z%mw3M7nTOTH(?`{t_E`Pdd61_W!ef`>&TTxE1yfM`Ot=2!X@s6-&DCVU;P!7?NW!BP1kg;6DrywDDLRSqW` z#c-yY$0x8}LIKeN7efL^{V@Scjr`H0;hQq+<9Z!P#R;P532q%9y21sIBPKk*kr(*! u?5Q(2*-%d{Vi9S~#peJm0=V(?8HdN|cDiR@m?Mb)e#A=-g4pJgo__#$@W&(o diff --git a/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_with_safety_nets/solution_modeldatadf.parquet b/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_with_safety_nets/solution_modeldatadf.parquet index 5b68f8b54715fd0a3791a47c986ae5deb0188fec..57dae89016fefcb2a27cac6e6fab1d908dabc309 100644 GIT binary patch delta 619 zcmah_%TB^T6fGDrQACZ2(ZmNaZlx8Xpb3jR*cM1oXrfS7Qb4H`sBHy&1>L*r#)ThY z!dJL+=fV&0H=H}5A-b^X+?+de&Yd&oedS^GCFS+J_S4?av6+(NSh{xURPFV>;LV*I z@@;t{?ujov`83Pp*Ey5jXJ6c#0{6qW;a3R&-~zGUpPGn{&r3ojnKsqtu}~~z^h2FQ zPu;kmc&^=hKi{K76%`#dzg<@g8H&qvfekr;7$V$)1giyv`&d9^HH!u)%88T>AhS&d z8Dc)9t-8B}G}HwiWYx zf`T_ju16+NQUaMnM+^$I5=RRwhL$YW$_=BYHo68LRZze4OHc3#qxw2+d&2=g_7i7R z%7_1ZGR~3}qasVDmdwBv^1A>M0DX{3gB}D*^ZU+;fP(iAa%YiYr!ft6+>q?bAU~!V z5aMxeF=&>oi_5CBSvJkhl4Z3z!FJWM^;R<&j)a7euyni3aesgAF~D(IP`17R2_LH| delta 523 zcmZpayDq!IlZkQrW-q4a%#)qj^cW2%7qJ;}H3;Z~LAu-Ib!@gw?&Xu;vZ?U6IypKz zg0Q1Y@@83fbJoc@JhqG#lUMQR+qi>-lil4)qukx9fJ_e%-^bC_(J=tTasd&JSs=o# z97KW%uvv~SlYM#R>jQy;PNk9IhB+W<#{d^?eSM33kSjnEjzM72NDv9p1?GcoNiy=P zO7X}kj|y~*1gQ@V01_bc16@IeLY(3VW`Z?2gGl$Fj1>1UgG88p^{yd6U6vLg1Ax*n z3)4Yrku3BB@sq+Wv!lW*j3Ohv3L?Y3ih&jbZ2{@mE=JRz3{sDz-wVWt>W6zaALw8v zeap!{ysD|$Q4v8IiQyGhj=otyvn&D~odO+!E(V5gG9(m%rU6400&w^cs0HYtsEj0! fFmPx>v>-xY@;*K}U`*fTli^6>V_*ny3^D`&_X&|F diff --git a/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_with_safety_nets/solution_outliersdf.parquet b/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_with_safety_nets/solution_outliersdf.parquet index 12ecd1d01b29e9d055e2960d988dd35047c21c79..8b65b52d32e6adbecb154529c7b0513b8d9675de 100644 GIT binary patch literal 11174 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 W6)EYdUYs6$>!P+MkpFvi=>G$rkvi4@ literal 8646 zcmeHNe{@q-p1(<7+LVU$5qP03r9Lu{7HA*uz2v1yh0>&u{xB`2Kz~FjP1=;E<>}wF(0c;^4qpUD4H^mBVQLeHhn@?l`;d*?qg;`;wHD zR@XV>?*22J+kD==-+RB`&;8!-{oebY)>-R0il7E%)R4?U(G=1kRCaCc>#zO7`ub}| zD?%Z_+pGq|Q}BGr+R-rz&qv|!ui@DQZG+ask8XrMFG76?+8=~JgOx&*jj|U~2W1pV zrBbQ6Sp*)QMPQ*86(rRVLQ4iy;P;?RMko*>`ORCF?OdQtv7EZ{JKbEpI_1krgn(l` z)!?Ay62s-g%V2=Jr@g(OZtL{4?Vt;Ky^&5j?CS7D`U9RwcsU*HiTJ#pZeXINa*~*s zP{FbvG&1Xlbps+91#$kegEGu?{8w`7fZl;;kNx@i&t=!_Dj7oUo`}oq+vX2%FQGTY z=+UkSz0>Cnw0pW)ftDuG*6F2px_mtznrC>vkoJb@kgGdF2RrCn^XC>oZg|y;Qv5dHWh*%0-LhnCWBbfH*jWYpZN{^S8nFnVf^(#V+OjyS9o4=dG&mDsxRO+R$NA*B=;P#`OXNWH9r zqQ0zx5G^jwfw5pZdRU1B2t+#spFu1mCd-JuWiWiR`$e}YCwZDfw-vUO@WW0AK1FwuF@AVwuYl-eoQ-3qTJ!RCM zqO>q<-ccpuPGyp)*zH!p3W}NJWG0!DNx~rg(^+8D80}7lg+HpaL05y=oeJgUZXR4< zI6=xf5pVd3;w=J+_TKSXBq6ktGSa`b91id5)^)?+W;213@iUr-;`G`HScD}ezU%F2ZBu?i*QJ>Di6X8g;ap?#w6;6IO z;K+;HC@AV}6ewojY=vKgl)}W;RC{ZxxGxKedS4deG~)~QmO-#f6B|_82El8wrB{PK zvtBa~46t}CFYZ}r-?LC`UOL&lG^cqfPE;n#Gj;05dYA$1zWOLHeL&CR(qSH<%Xc*8i-jVFN6{rRQ$=f@&>8L1BF{VYg|qwg<< zkUgGZ1FdBQlzYlxlRc+9---;H=T|(omWQNWM%Y1?NZPPex3gnUFBcyzus>Shc2Y2W z^mqngWYqTD8hVsn07K~cHhO-Hss>~RMt2upBun&-T*zNWF>^^t<`Qv24MjbnCOAQ3 z&s|TCflmy7e5j!IjocRhLa>N8F()wU-%@Wuh+Qozy;|hnfcNwmDN{e9!Ht8;(m|#B z;!0>dMauHtTKp0aJ!&pJY6e$8(OjB~1Pv&Vd}PDBSu;s$a!Ab*5@$&8dx33Ffw-K9 zqFK%(f!`uTVGu7~%d}t1ge8S?u@$j-6Y4SW11YNpv-JE)dVUT)Uj_8oynV~hK^vM^ zg665!7O{096cuM=EHQ_3r1ze?j)%BaaQDyNJhIG(x5fVe010Dut*IuF%o*5g&!YhOT|f$1mkg zWhR6E>|zKp%L!WkwXZnl@fO?|K%#}ffdN{ zH+w3?i>pg7u2$b!7MsfwQeT%3!@$eiic7b}&-o$}8S+9|V-q(412k`ZtT1Lu1E!43 zppqzNC(jjW&lQcsnRDePC~92P&AFpjV8-Ijg6(EObmT)(LpiNlLq=EQb7M7STTQ7q z*N6_qq(kwy4%i5$lMa}xBOmcyyRzXf_?V1(Qa1fIc%S_H?`6DsmV24$hI?7!4kumC z5)~fb)DjgTxnfDGaBNbAPwvJSCrhLoopeKtU+Ey*b5mD3Ge33hkgj$~7Y}^3>rcMx z#cm)ouX9-s{SU5SxmjFc{Qq|aoaAQ1lq(Fv55L{w>&bWBZsBIX;aY9{YM(|84z`vR zWU24F+x>EsCBk`-_8J=etg}9RHcme(c@Ai$?;# z{ruZI&klzw3v>2eSPJ^27n%L()Zugobs+Y3aDn(kg&YmP{hQN=$c3nE?>F+pfD`NW zRX+zD6dU#p1JWbq8Ad3W|xPh$DX z>Mx$c@{b1$$FTg!vHnrOvMpDB{Yxyr_D0@0EPon#&SSZYJ9YufbKGD3J>bM!2a4YS z9DMMV-ERTX>;CY{JAf!Kbm)D+Vf_nNJ_Ia#|1;gEfD?~od9MNv)>S?G&wzCLz&Bq4 zo_#rb^S=NhgX+S!xc#U1G%cl|{g$!E@&Q|k&puv3Eu1C8WhoOvc#4M^yn)Zs`hD@9 zn_0Pze|&S{LWlji4qWRx%zqc^S7;Pa+scY$5(t??F%1$vukZ@oq4Ht zZ^cx+lhlI5i@P!x-uzL-;}6aDUQS>^mh=m~ZhWggBBNRzluCM~u4U7q$5d*?FTo~w z|K;G#7hZ=J=a^Y8sRL&LYbL5>dfrcu59iHUiAR-X)-{)G&N@@Vh7#qLj~pz2zGcXf3%aD1wAlF)PP7rwOWE#< zcp_fE$M?{@+||%C(KqN0NBTpaL|?3t2)O)-2JJyvc5b#$k8&wxwt!N}?6`4C4kGPK za_t4VepIGce%}|D>_YfRfhakG9}-~o^3-fSe18EFSg|UhSei*p@oRGJ+j9N66#XT6 z?vMTjWduq$A7=h?l27|ig=R+B&AN7;?w9NTp<~-1LMuQ}f@=xvp9JMgd^jG&Z z5QRs5>^&z*v z4QyGx4Gr}bj?LS>9ZlRc?rr_h-yQ*fBzS>sza8cko3GRF^SLYPJ7V*OzU9??b7LPD z=hfTXDC}r%s_kg6^z}j;miyfmmd^HdHT^OF37oshCM3_z>F?Xw+_Z6^#ybo53g}oTtL!_M~&Aw;OMIHc0fjkJZ!aA#$yBLPh7X*G`dd8*CCuw?NYv# zex&?r>w=_i2ElqePRy}E@%)VIQmVH}u_#s7Z-)LBD?SRKZnM=(tfi@k-WZhlR7-smM~T&1j_VcI<5&{Ic>o7Mg>-(z8t_peQN?G3)!wjW zV{f^nA8OyrXKH`jZJ|(+ba2!0gPD#U+1Wn$ Qo^V4!50ePh!T-VW-;7lUmjD0& diff --git a/tests/test_qc.py b/tests/test_qc.py index 166746d4..2410bcb5 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -479,11 +479,10 @@ def test_buddy_check_with_safety_nets( _method_name = sys._getframe().f_code.co_name # 1. get_startpoint data - dataset = copy.deepcopy(import_dataset) - + dataset_with_saftynet = copy.deepcopy(import_dataset) # Test 1: Using safety_net_configs with LCZ should match the LCZ safety net method - dataset = copy.deepcopy(dataset) - dataset.buddy_check_with_safetynets( + dataset_with_saftynet = copy.deepcopy(dataset_with_saftynet) + dataset_with_saftynet.buddy_check_with_safetynets( obstype="temp", spatial_buddy_radius=25000, safety_net_configs=[ @@ -501,16 +500,39 @@ def test_buddy_check_with_safety_nets( N_iter=1, instantaneous_tolerance=pd.Timedelta("4min"), lapserate=None, - use_mp=False, + use_mp=True, + ) + + #Use same settings without saftyenet to make a relative comparison + dataset_without_saftynet = copy.deepcopy(import_dataset) + dataset_without_saftynet.buddy_check_with_safetynets( + obstype="temp", + spatial_buddy_radius=25000, + safety_net_configs=[], # No safety nets + min_sample_size=3, + max_alt_diff=None, + min_sample_spread=1.0, + spatial_z_threshold=2.1, + N_iter=1, + instantaneous_tolerance=pd.Timedelta("4min"), + lapserate=None, + use_mp=True, ) - assert ( - dataset.outliersdf.shape[0] == 74 - ), f"Expected 74 outliers, got {dataset.outliersdf.shape[0]}" + + #Relative tests + oult_saftynet = dataset_with_saftynet.outliersdf + oult_without_saftynet = dataset_without_saftynet.outliersdf + + + assert oult_saftynet.index.isin(oult_without_saftynet.index).all() + assert oult_without_saftynet.shape[0] > oult_saftynet.shape[0] + + # overwrite solution? if overwrite_solution: TestDemoDataset.solutionfixer.create_solution( - solution=dataset, + solution=dataset_with_saftynet, **TestDemoDataset.solkwargs, methodname=_method_name, ) @@ -521,7 +543,7 @@ def test_buddy_check_with_safety_nets( ) # validate expression - assert_equality(dataset, solutionobj, exclude_columns=["details"]) + assert_equality(dataset_with_saftynet, solutionobj, exclude_columns=["details"]) def test_buddy_check_with_safety_nets_missing_min_sample_size(self, import_dataset): """Test that an error is raised when min_sample_size is missing from safety_net_configs.""" @@ -1343,12 +1365,12 @@ def test_all_qc_methods_with_whiteset(self, import_dataset): # test_demo_dataset.test_qc_when_some_stations_missing_obs(imported_demo_dataset) # test_demo_dataset.test_buddy_check_raise_errors(imported_demo_dataset) # test_demo_dataset.test_buddy_check_one_iteration(imported_demo_dataset, overwrite_solution=OVERWRITE) - test_demo_dataset.test_buddy_check_more_iterations(imported_demo_dataset, overwrite_solution=OVERWRITE) + # test_demo_dataset.test_buddy_check_more_iterations(imported_demo_dataset, overwrite_solution=OVERWRITE) # test_demo_dataset.test_buddy_check_no_outliers(imported_demo_dataset) # test_demo_dataset.test_buddy_check_with_big_radius( # imported_demo_dataset, overwrite_solution=OVERWRITE # ) - # test_demo_dataset.test_buddy_check_with_safety_nets(imported_demo_dataset, overwrite_solution=OVERWRITE) + test_demo_dataset.test_buddy_check_with_safety_nets(imported_demo_dataset, overwrite_solution=OVERWRITE) # test_demo_dataset.test_buddy_check_with_safety_nets_missing_min_sample_size(imported_demo_dataset) # Run white_records tests From 71b532824bcb9919a62c1fd3add78f395cf277b8 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Wed, 28 Jan 2026 11:36:44 +0100 Subject: [PATCH 26/57] without storing values in qcresult --- .../qc_collection/duplicated_timestamp.py | 1 - .../qc_collection/grossvalue_check.py | 1 - .../qc_collection/persistence_check.py | 3 -- .../qc_collection/repetitions_check.py | 2 - .../spatial_checks/buddy_check.py | 3 -- .../qc_collection/step_check.py | 1 - .../qc_collection/window_variation_check.py | 3 -- src/metobs_toolkit/qcresult.py | 47 ++++++++++++------- 8 files changed, 30 insertions(+), 31 deletions(-) diff --git a/src/metobs_toolkit/qc_collection/duplicated_timestamp.py b/src/metobs_toolkit/qc_collection/duplicated_timestamp.py index 28aacbfa..2ab91874 100644 --- a/src/metobs_toolkit/qc_collection/duplicated_timestamp.py +++ b/src/metobs_toolkit/qc_collection/duplicated_timestamp.py @@ -65,7 +65,6 @@ def duplicated_timestamp_check(records: pd.Series) -> QCresult: checkname="duplicated_timestamp", checksettings={}, flags=flags, - outliers =duplicates[~duplicates.index.duplicated(keep="first")], detail='no details') #Create and add details diff --git a/src/metobs_toolkit/qc_collection/grossvalue_check.py b/src/metobs_toolkit/qc_collection/grossvalue_check.py index 14ffb540..e2e7dcea 100644 --- a/src/metobs_toolkit/qc_collection/grossvalue_check.py +++ b/src/metobs_toolkit/qc_collection/grossvalue_check.py @@ -62,7 +62,6 @@ def gross_value_check( checkname="gross_value", checksettings=locals().pop('records', None), flags=flags, - outliers = records.loc[outliers_after_white_idx], detail='no details' ) diff --git a/src/metobs_toolkit/qc_collection/persistence_check.py b/src/metobs_toolkit/qc_collection/persistence_check.py index b449f6b2..8424441b 100644 --- a/src/metobs_toolkit/qc_collection/persistence_check.py +++ b/src/metobs_toolkit/qc_collection/persistence_check.py @@ -107,8 +107,6 @@ def persistence_check( checkname="persistence", checksettings=locals().pop('records', None), flags=flags, - outliers=pd.Series(index=timestamps_to_datetimeindex( - name="datetime", timestamps=[], current_tz=None)), detail=f"Minimum number of records ({min_records_per_window}) per window ({timewindow}) not met.", ) return qcresult @@ -148,7 +146,6 @@ def persistence_check( checkname="persistence", checksettings=locals().pop('records', None), flags=flags, - outliers = records.loc[outliers_after_white_idx], detail='no details' ) diff --git a/src/metobs_toolkit/qc_collection/repetitions_check.py b/src/metobs_toolkit/qc_collection/repetitions_check.py index 0c97f278..81a1575f 100644 --- a/src/metobs_toolkit/qc_collection/repetitions_check.py +++ b/src/metobs_toolkit/qc_collection/repetitions_check.py @@ -104,8 +104,6 @@ def repetitions_check( checkname="repetitions", checksettings=locals().pop('records', None), flags=flags, - outliers = records.loc[outliers_after_white_idx], - detail='no details' ) #Create and add details diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py index 5376b030..880679e3 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py @@ -405,13 +405,10 @@ def toolkit_buddy_check( qcflags = final_labels.map(to_qc_labels_map) #4 Create QCresult object - outliers = wrapsta.station.get_sensor(obstype).series.loc[qcflags[qcflags == flagged_cond].index] - qcres = QCresult( checkname='buddy_check', checksettings=qcsettings, flags=qcflags, - outliers=outliers, detail='', ) qcres.add_details_by_series(detail_series = wrapsta.get_final_details()) diff --git a/src/metobs_toolkit/qc_collection/step_check.py b/src/metobs_toolkit/qc_collection/step_check.py index 67d63e00..8a27b0ba 100644 --- a/src/metobs_toolkit/qc_collection/step_check.py +++ b/src/metobs_toolkit/qc_collection/step_check.py @@ -99,7 +99,6 @@ def step_check( checkname="step", checksettings=locals().pop('records', None), flags=flags, - outliers = records.loc[outliers_after_white_idx], detail='no details' ) diff --git a/src/metobs_toolkit/qc_collection/window_variation_check.py b/src/metobs_toolkit/qc_collection/window_variation_check.py index ddf08e77..92bf4b30 100644 --- a/src/metobs_toolkit/qc_collection/window_variation_check.py +++ b/src/metobs_toolkit/qc_collection/window_variation_check.py @@ -100,8 +100,6 @@ def window_variation_check( checkname="window_variation", checksettings=locals().pop('records', None), flags=flags, - outliers=pd.Series(index=timestamps_to_datetimeindex( - name="datetime", timestamps=[], current_tz=None)), detail=f"Minimum number of records ({min_records_per_window}) per window ({timewindow}) not met.", ) return qcresult @@ -174,7 +172,6 @@ def variation_test(window: pd.Series) -> int: checkname="window_variation", checksettings=locals().pop('records', None), flags=flags, - outliers = records.loc[outliers_after_white_idx], detail='no details' ) diff --git a/src/metobs_toolkit/qcresult.py b/src/metobs_toolkit/qcresult.py index eaba5e74..ee91f7a0 100644 --- a/src/metobs_toolkit/qcresult.py +++ b/src/metobs_toolkit/qcresult.py @@ -11,12 +11,16 @@ +#Basic labels pass_cond = Settings.get("qc_status_labels_per_check.pass.label") #checked and successfull pass flagged_cond = Settings.get("qc_status_labels_per_check.flagged.label") # checked and flagged as outlier unmet_cond = Settings.get("qc_status_labels_per_check.condition_unmet.label") #not checked due to unmet specific conditions saved_cond = Settings.get("qc_status_labels_per_check.saved_whitelist.label") #checked and flagged but saved due to whitelist unchecked_cond = Settings.get("qc_status_labels_per_check.unchecked.label") #not checked (was nan/gap before check) + + + class QCresult: """Store results of a quality control check. @@ -59,7 +63,7 @@ def __init__( checkname: str, checksettings: dict, flags: pd.Series, # index: timestamps, values: 'passed', 'flagged', 'condition_unmet', 'saved' - outliers: pd.Series, # index: timestamps, values: outlier values + # outliers: pd.Series, # index: timestamps, values: outlier values detail: str = "", ): self.checkname = checkname @@ -71,9 +75,9 @@ def __init__( - if not isinstance(outliers.index, pd.DatetimeIndex): - raise TypeError("The index of 'outliers' must be a pandas.DatetimeIndex.") - self.outliers = outliers + # if not isinstance(outliers.index, pd.DatetimeIndex): + # raise TypeError("The index of 'outliers' must be a pandas.DatetimeIndex.") + # self.outliers = outliers #Set details (Index is Flags thus includes all timestamps!) self.details = pd.Series([detail] * len(flags), @@ -110,7 +114,9 @@ def add_details_by_series(self, detail_series: pd.Series) -> None: def get_outlier_timestamps(self) -> pd.DatetimeIndex: """Return the timestamps of the outliers.""" - return self.outliers.index + + return self.flags[self.flags == flagged_cond].index + # return self.outliers.index def remap_timestamps(self, mapping: dict) -> None: @@ -123,7 +129,7 @@ def remap_timestamps(self, mapping: dict) -> None: new timestamps to map to. """ self.flags.index = self.flags.index.map(lambda ts: mapping.get(ts, ts)) - self.outliers.index = self.outliers.index.map(lambda ts: mapping.get(ts, ts)) + # self.outliers.index = self.outliers.index.map(lambda ts: mapping.get(ts, ts)) self.details.index = self.details.index.map(lambda ts: mapping.get(ts, ts)) def _flags_to_labels_map(self) -> dict: @@ -146,7 +152,7 @@ def _flags_to_labels_map(self) -> dict: unchecked_cond: Settings.get('label_def.goodrecord.label') } return label_mapping - def create_outliersdf(self) -> pd.DataFrame: + def create_outliersdf(self, subset_to_outliers=True) -> pd.DataFrame: """Create a DataFrame summarizing detected outliers. Constructs a DataFrame containing outlier values, their corresponding labels, @@ -162,21 +168,28 @@ def create_outliersdf(self) -> pd.DataFrame: - 'details': descriptive information about each outlier Returns empty DataFrame with correct structure if no outliers exist. """ - if self.outliers.empty: - # return empty dataframe - return pd.DataFrame( - columns=["value", "label", "details"], - index=pd.DatetimeIndex([], name="datetime"), - ) + outl_timestamps = self.get_outlier_timestamps() + + + if subset_to_outliers: + if outl_timestamps.empty: + # return empty dataframe + return pd.DataFrame( + columns=["label", "details"], + index=pd.DatetimeIndex([], name="datetime"), + ) + targets = self.flags.loc[outl_timestamps] + else: + targets = self.flags - labels = self.flags.loc[self.outliers.index].map(self._flags_to_labels_map()) + labels = targets.map(self._flags_to_labels_map()) outliers_df = pd.DataFrame({ - 'datetime': self.outliers.index, - 'value': self.outliers.values, + 'datetime': targets.index, + # 'value': self.outliers.values, 'label': labels.values, - 'details': self.details.loc[self.outliers.index].values, + 'details': self.details.loc[targets.index].values, }) outliers_df.set_index('datetime', inplace=True) From 7fd2e1edca89ad0fd3f91f7ee38ec8d926abffe0 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Wed, 28 Jan 2026 11:37:02 +0100 Subject: [PATCH 27/57] rename update --- src/metobs_toolkit/dataset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/metobs_toolkit/dataset.py b/src/metobs_toolkit/dataset.py index d614a964..8a7fcd18 100644 --- a/src/metobs_toolkit/dataset.py +++ b/src/metobs_toolkit/dataset.py @@ -2386,7 +2386,7 @@ def buddy_check_with_safetynets( for staname, qcres in qcresuldict.items(): sensordata = self.get_station(staname).get_sensor(obstype) - sensordata._update_outliers_NEW(qcresult=qcres, overwrite=False) + sensordata._update_outliers(qcresult=qcres, overwrite=False) From a11bc9214761fb26e53f5b173bc911b7fbce9ee1 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Wed, 28 Jan 2026 11:37:24 +0100 Subject: [PATCH 28/57] qc_overview_df for sensordata --- .../gf_collection/overview_df_constructors.py | 44 ++++++++- src/metobs_toolkit/sensordata.py | 93 +++++-------------- 2 files changed, 68 insertions(+), 69 deletions(-) diff --git a/src/metobs_toolkit/gf_collection/overview_df_constructors.py b/src/metobs_toolkit/gf_collection/overview_df_constructors.py index 94a32e14..ca9a1326 100644 --- a/src/metobs_toolkit/gf_collection/overview_df_constructors.py +++ b/src/metobs_toolkit/gf_collection/overview_df_constructors.py @@ -5,6 +5,9 @@ from metobs_toolkit.backend_collection.dev_collection import copy_doc from metobs_toolkit.backend_collection.df_helpers import save_concat +#=============================== +# Gap overiview +#=============================== def sensordata_gap_status_overview_df(sensordata) -> pd.DataFrame: """ @@ -80,7 +83,6 @@ def sensordata_gap_status_overview_df(sensordata) -> pd.DataFrame: index=pd.Index([], name="gapstart"), ) - @copy_doc(sensordata_gap_status_overview_df) def station_gap_status_overview_df(station) -> pd.DataFrame: concatlist = [] @@ -127,3 +129,43 @@ def dataset_gap_status_overview_df(dataset) -> pd.DataFrame: ), ) return combdf + + + +#=============================== +# QC overiew +#=============================== + +def sensordata_qc_overview_df(sensor) -> pd.DataFrame: + #TODO: docstring + possible_timestamps = sensor.series.index + qc_before_timecoarsening = ['duplicated_timestamp'] + + + to_concat = [] + for qcresult in sensor.outliers: + checkdf = qcresult.create_outliersdf(subset_to_outliers=False) #Get all flags + #add checkname to the index + checkdf["checkname"] = qcresult.checkname + if qcresult.checkname in qc_before_timecoarsening: + #Subset to coarsende timestmaps only + checkdf = checkdf.reindex(possible_timestamps) + + checkdf.set_index("checkname", append=True, inplace=True) + to_concat.append(checkdf) + + totaldf = save_concat(to_concat) + + if totaldf.empty: + return pd.DataFrame(columns=['value', 'label', 'details'], + index=pd.DatetimeIndex([], name='datetime')) + + #Unstack + totaldf = totaldf.unstack(level='checkname') + + #add values + allvals = pd.concat([sensor.series, sensor.outliers_values_bin]) #do not sort before removing the duplicates ! + allvals = allvals[~allvals.index.duplicated(keep='last')].sort_index() + totaldf['value'] = allvals.loc[totaldf.index] + + return totaldf[['value', 'label', 'details']] diff --git a/src/metobs_toolkit/sensordata.py b/src/metobs_toolkit/sensordata.py index e39cff37..7633fa90 100644 --- a/src/metobs_toolkit/sensordata.py +++ b/src/metobs_toolkit/sensordata.py @@ -21,6 +21,7 @@ ) from metobs_toolkit.gf_collection.overview_df_constructors import ( sensordata_gap_status_overview_df, + sensordata_qc_overview_df ) from metobs_toolkit.settings_collection import Settings from metobs_toolkit.xrconversions import sensordata_to_xr @@ -117,6 +118,7 @@ def __init__( # outliers self.outliers = [] # List of QCresult + self.outliers_values_bin = pd.Series(dtype=datadtype) # Series of outlier values # gaps self.gaps = [] # list of Gap's @@ -261,10 +263,12 @@ def outliersdf(self) -> pd.DataFrame: logger.debug("Creating outliers DataFrame for %s", self.stationname) to_concat = [] for qcresult in self.outliers: - checkdf = qcresult.create_outliersdf() + checkdf = qcresult.create_outliersdf(subset_to_outliers=True) to_concat.append(checkdf) totaldf = save_concat(to_concat) + #add the values column (values not stored in qcresult, only labels and details) + totaldf["value"] = self.outliers_values_bin.loc[totaldf.index] if totaldf.empty: # return empty dataframe @@ -275,9 +279,9 @@ def outliersdf(self) -> pd.DataFrame: totaldf.sort_index(inplace=True) - logger.debug("Outliers DataFrame created successfully for %s", self.stationname) - return totaldf + return totaldf[['value', 'label', 'details']] #fixed column order + @property def gapsdf(self) -> pd.DataFrame: """Return a DataFrame of the gap records.""" @@ -296,6 +300,10 @@ def gapsdf(self) -> pd.DataFrame: def gap_overview_df(self) -> pd.DataFrame: return sensordata_gap_status_overview_df(self) + @copy_doc(sensordata_qc_overview_df) + def qc_overview_df(self) -> pd.DataFrame: + return sensordata_qc_overview_df(self) + @property def stationname(self) -> str: """Return the name of the station this SensorData belongs to.""" @@ -436,73 +444,22 @@ def _setup( target_freq=pd.to_timedelta(timestamp_matcher.target_freq), ) - def _update_outliers_NEW(self, - qcresult: QCresult, - overwrite: bool = False) -> None: + def _update_outliers(self, + qcresult: QCresult, + overwrite: bool = False) -> None: #add the results to the outliers list self.outliers.append(qcresult) + #Fill the outliers value bin + self.outliers_values_bin = pd.concat([self.outliers_values_bin, + self.series.loc[qcresult.get_outlier_timestamps()]]) + + #convert the outlier timestamps to NaN in the series self.series.loc[qcresult.get_outlier_timestamps()] = np.nan - # #TODO: delete this method - # def _update_outliers( - # self, - # qccheckname: str, - # outliertimestamps: pd.DatetimeIndex, - # check_kwargs: dict, - # extra_columns: dict = {}, - # overwrite: bool = False, - # ) -> None: - # """ - # Update the outliers attribute. - - # Parameters - # ---------- - # qccheckname : str - # Name of the quality control check. - # outliertimestamps : pd.DatetimeIndex - # Datetime index of the outliers. - # check_kwargs : dict - # Additional arguments for the check. - # extra_columns : dict, optional - # Extra columns to add to the outliers DataFrame, by default {}. - # overwrite : bool, optional - # Whether to overwrite existing outliers, by default False. - - # Raises - # ------ - # MetobsQualityControlError - # If the check is already applied and overwrite is False. - # """ - # logger.debug( - # "Entering _update_outliers for %s with check %s", self, qccheckname - # ) - - # for applied_qc_info in self.outliers: - # if qccheckname == applied_qc_info.keys(): - # if overwrite: - # self.outliers.remove(applied_qc_info) - # else: - # raise MetObsQualityControlError( - # f"The {qccheckname} is already applied on {self}. Fix error or set overwrite=True" - # ) - - # outlier_values = self.series.loc[outliertimestamps] - # outlier_values = outlier_values[~outlier_values.index.duplicated(keep="first")] - - # datadict = {"value": outlier_values.to_numpy()} - # datadict.update(extra_columns) - # df = pd.DataFrame(data=datadict, index=outlier_values.index) - - # self.outliers.append( - # {"checkname": qccheckname, "df": df, "settings": check_kwargs} - # ) - - # self.series.loc[outliertimestamps] = np.nan - def _find_gaps(self, missingrecords: pd.Series, target_freq: pd.Timedelta) -> list: """ Identify gaps in the missing records based on the target frequency. @@ -801,7 +758,7 @@ def duplicated_timestamp_check(self) -> None: self.series = self.series[~self.series.index.duplicated(keep="first")] #Update the outliers - self._update_outliers_NEW(qcresult=qcresult, overwrite=False) + self._update_outliers(qcresult=qcresult, overwrite=False) @@ -818,7 +775,7 @@ def gross_value_check(self, **qckwargs) -> None: """ qcresult = qc.gross_value_check(records=self.series, **qckwargs) - self._update_outliers_NEW( + self._update_outliers( qcresult=qcresult, overwrite=False, ) @@ -835,7 +792,7 @@ def persistence_check(self, **qckwargs) -> None: """ qcresult = qc.persistence_check(records=self.series, **qckwargs) - self._update_outliers_NEW( + self._update_outliers( qcresult=qcresult, overwrite=False, ) @@ -852,7 +809,7 @@ def repetitions_check(self, **qckwargs) -> None: """ qcresult = qc.repetitions_check(records=self.series, **qckwargs) - self._update_outliers_NEW( + self._update_outliers( qcresult=qcresult, overwrite=False, ) @@ -869,7 +826,7 @@ def step_check(self, **qckwargs) -> None: """ qcresult = qc.step_check(records=self.series, **qckwargs) - self._update_outliers_NEW( + self._update_outliers( qcresult=qcresult, overwrite=False, ) @@ -886,7 +843,7 @@ def window_variation_check(self, **qckwargs) -> None: """ qcresult = qc.window_variation_check(records=self.series, **qckwargs) - self._update_outliers_NEW( + self._update_outliers( qcresult=qcresult, overwrite=False, ) From a2a946ba534d52833c1ce7f39c104355821c31c1 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Wed, 28 Jan 2026 12:15:39 +0100 Subject: [PATCH 29/57] qc_overview_df for station and dataset level --- src/metobs_toolkit/dataset.py | 10 ++- .../gf_collection/overview_df_constructors.py | 67 +++++++++++++++++++ src/metobs_toolkit/station.py | 6 ++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/metobs_toolkit/dataset.py b/src/metobs_toolkit/dataset.py index 8a7fcd18..7bf6b625 100644 --- a/src/metobs_toolkit/dataset.py +++ b/src/metobs_toolkit/dataset.py @@ -42,6 +42,7 @@ from metobs_toolkit.gf_collection.overview_df_constructors import ( dataset_gap_status_overview_df, + dataset_qc_overview_df, ) from metobs_toolkit.backend_collection.filter_modeldatadf import filter_modeldatadf from metobs_toolkit.timestampmatcher import simplify_time @@ -2388,7 +2389,14 @@ def buddy_check_with_safetynets( sensordata = self.get_station(staname).get_sensor(obstype) sensordata._update_outliers(qcresult=qcres, overwrite=False) - + @copy_doc(dataset_qc_overview_df) + @log_entry + def qc_overview_df(self, + subset_stations:Union[list[str], None] = None, + subset_obstypes:Union[list[str], None] = None) -> pd.DataFrame: + return dataset_qc_overview_df(self, + subset_stations=subset_stations, + subset_obstypes=subset_obstypes) @log_entry def get_qc_stats( diff --git a/src/metobs_toolkit/gf_collection/overview_df_constructors.py b/src/metobs_toolkit/gf_collection/overview_df_constructors.py index ca9a1326..64f2a948 100644 --- a/src/metobs_toolkit/gf_collection/overview_df_constructors.py +++ b/src/metobs_toolkit/gf_collection/overview_df_constructors.py @@ -2,6 +2,7 @@ (sensordata, station, dataset) for overviews and summaries of Gaps.""" import pandas as pd +from typing import Union from metobs_toolkit.backend_collection.dev_collection import copy_doc from metobs_toolkit.backend_collection.df_helpers import save_concat @@ -138,6 +139,8 @@ def dataset_gap_status_overview_df(dataset) -> pd.DataFrame: def sensordata_qc_overview_df(sensor) -> pd.DataFrame: #TODO: docstring + + #TODO rearange the order of qc columns to reflect the executeion order possible_timestamps = sensor.series.index qc_before_timecoarsening = ['duplicated_timestamp'] @@ -162,6 +165,7 @@ def sensordata_qc_overview_df(sensor) -> pd.DataFrame: #Unstack totaldf = totaldf.unstack(level='checkname') + totaldf.fillna('Not applied', inplace=True) #add values allvals = pd.concat([sensor.series, sensor.outliers_values_bin]) #do not sort before removing the duplicates ! @@ -169,3 +173,66 @@ def sensordata_qc_overview_df(sensor) -> pd.DataFrame: totaldf['value'] = allvals.loc[totaldf.index] return totaldf[['value', 'label', 'details']] + + +def station_qc_overview_df(station, subset_obstypes:Union[list[str], None] = None) -> pd.DataFrame: + #TODO: docstring + + if subset_obstypes is None: + sensortargets = station.sensordata.values() + else: + sensortargets = [] + for obstype in subset_obstypes: + if obstype in station.sensordata: + sensortargets.append(station.get_sensor(obstype)) + else: + #Log a warning? + pass + + to_concat = [] + for sensordata in sensortargets: + stadf = sensordata_qc_overview_df(sensordata).reset_index() + #add obstype to the index + if not stadf.empty: + stadf["obstype"] = sensordata.obstype.name + stadf = stadf.reset_index().set_index(['datetime', "obstype"]) + to_concat.append(stadf) + + + + totaldf = save_concat(to_concat) + totaldf.sort_index(inplace=True) + + if totaldf.empty: + return pd.DataFrame(columns=['value', 'label', 'details'], + index=pd.MultiIndex( + levels=[[], []], codes=[[], []], names=["datetime", "obstype"])) + + return totaldf[['value', 'label', 'details']] + +def dataset_qc_overview_df(dataset, subset_stations:Union[list[str], None] = None, + subset_obstypes:Union[list[str], None] = None) -> pd.DataFrame: + #TODO: docstring + if subset_stations is None: + stationtargets = dataset.stations + else: + stationtargets = [dataset.get_station(station_name) for station_name in subset_stations] + + to_concat = [] + for station in stationtargets: + stadf = station_qc_overview_df(station, subset_obstypes=subset_obstypes).reset_index() + #add obstype to the index + if not stadf.empty: + stadf["name"] = station.name + stadf = stadf.reset_index().set_index(['datetime', "obstype", "name"]) + to_concat.append(stadf) + + totaldf = save_concat(to_concat) + totaldf.sort_index(inplace=True) + + if totaldf.empty: + return pd.DataFrame(columns=['value', 'label', 'details'], + index=pd.MultiIndex( + levels=[[], [], []], codes=[[], [], []], names=["datetime", "obstype", "name"])) + + return totaldf[['value', 'label', 'details']] \ No newline at end of file diff --git a/src/metobs_toolkit/station.py b/src/metobs_toolkit/station.py index f329ffab..1e5cb3f4 100644 --- a/src/metobs_toolkit/station.py +++ b/src/metobs_toolkit/station.py @@ -35,6 +35,7 @@ ) from metobs_toolkit.gf_collection.overview_df_constructors import ( station_gap_status_overview_df, + station_qc_overview_df, ) from metobs_toolkit.backend_collection.filter_modeldatadf import filter_modeldatadf from metobs_toolkit.geedatasetmanagers import default_datasets as default_gee_datasets @@ -1612,6 +1613,11 @@ def window_variation_check( # apply check on the sensordata self.get_sensor(obstype).window_variation_check(**qc_kwargs) + @copy_doc(station_qc_overview_df) + @log_entry + def qc_overview_df(self, subset_obstypes:Union[list[str], None] = None) -> pd.DataFrame: + return station_qc_overview_df(self, subset_obstypes=subset_obstypes) + @log_entry def get_qc_stats( self, obstype: str = "temp", make_plot: bool = True From 610dd9dabfce8fd3017e0775eadb57bbb6a21c0b Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Wed, 28 Jan 2026 14:29:50 +0100 Subject: [PATCH 30/57] choose to use basic or detailed labels for outliersdf --- .../gf_collection/overview_df_constructors.py | 4 +++- src/metobs_toolkit/qcresult.py | 13 +++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/metobs_toolkit/gf_collection/overview_df_constructors.py b/src/metobs_toolkit/gf_collection/overview_df_constructors.py index 64f2a948..a2fd37a5 100644 --- a/src/metobs_toolkit/gf_collection/overview_df_constructors.py +++ b/src/metobs_toolkit/gf_collection/overview_df_constructors.py @@ -147,7 +147,9 @@ def sensordata_qc_overview_df(sensor) -> pd.DataFrame: to_concat = [] for qcresult in sensor.outliers: - checkdf = qcresult.create_outliersdf(subset_to_outliers=False) #Get all flags + checkdf = qcresult.create_outliersdf( + map_to_basic_labels=False, #get all flags (ok, outl, notchecked, unmet, saved) + subset_to_outliers=False) #Get all flags #add checkname to the index checkdf["checkname"] = qcresult.checkname if qcresult.checkname in qc_before_timecoarsening: diff --git a/src/metobs_toolkit/qcresult.py b/src/metobs_toolkit/qcresult.py index ee91f7a0..038a7ff6 100644 --- a/src/metobs_toolkit/qcresult.py +++ b/src/metobs_toolkit/qcresult.py @@ -132,7 +132,7 @@ def remap_timestamps(self, mapping: dict) -> None: # self.outliers.index = self.outliers.index.map(lambda ts: mapping.get(ts, ts)) self.details.index = self.details.index.map(lambda ts: mapping.get(ts, ts)) - def _flags_to_labels_map(self) -> dict: + def _flags_to_basic_labels(self) -> dict: """Create mapping from QC flag values to display labels. Constructs a dictionary mapping internal flag values ('passed', 'flagged', etc.) @@ -152,7 +152,9 @@ def _flags_to_labels_map(self) -> dict: unchecked_cond: Settings.get('label_def.goodrecord.label') } return label_mapping - def create_outliersdf(self, subset_to_outliers=True) -> pd.DataFrame: + def create_outliersdf(self, + map_to_basic_labels=True, + subset_to_outliers=True) -> pd.DataFrame: """Create a DataFrame summarizing detected outliers. Constructs a DataFrame containing outlier values, their corresponding labels, @@ -181,8 +183,11 @@ def create_outliersdf(self, subset_to_outliers=True) -> pd.DataFrame: targets = self.flags.loc[outl_timestamps] else: targets = self.flags - - labels = targets.map(self._flags_to_labels_map()) + + if map_to_basic_labels: + labels = targets.map(self._flags_to_basic_labels()) + else: + labels = targets outliers_df = pd.DataFrame({ From cf96677c82834a4eb16cd5818776718abb4638af Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Wed, 28 Jan 2026 15:38:34 +0100 Subject: [PATCH 31/57] bugfix when saftynet is triggerd in previous iteration, but not in current --- .../spatial_checks/buddywrapstation.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py index 69a7988b..57a002a8 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py @@ -687,13 +687,16 @@ def combine_series_dicts(list_of_dicts, iteration: int): else: combined[iter] = d[iter] #keep other iterations as is (overwrite, not concat else duplicates may occur) - - combined[iteration] = pd.concat(combined[iteration]).sort_index() + if combined[iteration] == []: + #can happen wen a saftynet is triggered in a previous iteration, but not in the current one + combined[iteration] = pd.Series(dtype=str, index=pd.DatetimeIndex([], name='datetime')) + else: + combined[iteration] = pd.concat(combined[iteration]).sort_index() #sanity check - for series in combined.values(): - if series.index.duplicated().any(): - raise ValueError("Duplicate indices found when combining series dictionaries.") + # for series in combined.values(): + # if series.index.duplicated().any(): + # raise ValueError("Duplicate indices found when combining series dictionaries.") return combined def combine_buddycheckstations(stations: List[BuddyCheckStation], iteration: int) -> BuddyCheckStation: From 17be95b04259d9babc5778e8746a872901d0abbe Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Thu, 29 Jan 2026 08:40:39 +0100 Subject: [PATCH 32/57] put the qc detail df constructors in a seperate module --- src/metobs_toolkit/dataset.py | 2 + .../qc_collection/overview_df_constructor.py | 110 ++++++++++++++++++ src/metobs_toolkit/sensordata.py | 43 +------ src/metobs_toolkit/station.py | 2 + 4 files changed, 119 insertions(+), 38 deletions(-) create mode 100644 src/metobs_toolkit/qc_collection/overview_df_constructor.py diff --git a/src/metobs_toolkit/dataset.py b/src/metobs_toolkit/dataset.py index 7bf6b625..a0e52f80 100644 --- a/src/metobs_toolkit/dataset.py +++ b/src/metobs_toolkit/dataset.py @@ -42,6 +42,8 @@ from metobs_toolkit.gf_collection.overview_df_constructors import ( dataset_gap_status_overview_df, +) +from metobs_toolkit.qc_collection.overview_df_constructor import ( dataset_qc_overview_df, ) from metobs_toolkit.backend_collection.filter_modeldatadf import filter_modeldatadf diff --git a/src/metobs_toolkit/qc_collection/overview_df_constructor.py b/src/metobs_toolkit/qc_collection/overview_df_constructor.py new file mode 100644 index 00000000..142d2a53 --- /dev/null +++ b/src/metobs_toolkit/qc_collection/overview_df_constructor.py @@ -0,0 +1,110 @@ +"""Collection of DF constructing functions on various levels +(sensordata, station, dataset) for overviews and summaries of QC checks.""" + +import pandas as pd +from typing import Union +from metobs_toolkit.backend_collection.dev_collection import copy_doc +from metobs_toolkit.backend_collection.df_helpers import save_concat + + +def sensordata_qc_overview_df(sensor) -> pd.DataFrame: + #TODO: docstring + + #TODO rearange the order of qc columns to reflect the executeion order + possible_timestamps = sensor.series.index + qc_before_timecoarsening = ['duplicated_timestamp'] + + + to_concat = [] + for qcresult in sensor.outliers: + checkdf = qcresult.create_outliersdf( + map_to_basic_labels=False, #get all flags (ok, outl, notchecked, unmet, saved) + subset_to_outliers=False) #Get all flags + #add checkname to the index + checkdf["checkname"] = qcresult.checkname + if qcresult.checkname in qc_before_timecoarsening: + #Subset to coarsende timestmaps only + checkdf = checkdf.reindex(possible_timestamps) + + checkdf.set_index("checkname", append=True, inplace=True) + to_concat.append(checkdf) + + totaldf = save_concat(to_concat) + + if totaldf.empty: + return pd.DataFrame(columns=['value', 'label', 'details'], + index=pd.DatetimeIndex([], name='datetime')) + + #Unstack + totaldf = totaldf.unstack(level='checkname') + totaldf.fillna('Not applied', inplace=True) + + #add values + allvals = pd.concat([sensor.series, sensor.outliers_values_bin]) #do not sort before removing the duplicates ! + allvals = allvals[~allvals.index.duplicated(keep='last')].sort_index() + totaldf['value'] = allvals.loc[totaldf.index] + + return totaldf[['value', 'label', 'details']] + + +def station_qc_overview_df(station, subset_obstypes:Union[list[str], None] = None) -> pd.DataFrame: + #TODO: docstring + + if subset_obstypes is None: + sensortargets = station.sensordata.values() + else: + sensortargets = [] + for obstype in subset_obstypes: + if obstype in station.sensordata: + sensortargets.append(station.get_sensor(obstype)) + else: + #Log a warning? + pass + + to_concat = [] + for sensordata in sensortargets: + stadf = sensordata_qc_overview_df(sensordata).reset_index() + #add obstype to the index + if not stadf.empty: + stadf["obstype"] = sensordata.obstype.name + stadf = stadf.reset_index().set_index(['datetime', "obstype"]) + to_concat.append(stadf) + + + + totaldf = save_concat(to_concat) + totaldf.sort_index(inplace=True) + + if totaldf.empty: + return pd.DataFrame(columns=['value', 'label', 'details'], + index=pd.MultiIndex( + levels=[[], []], codes=[[], []], names=["datetime", "obstype"])) + + return totaldf[['value', 'label', 'details']] + +def dataset_qc_overview_df(dataset, subset_stations:Union[list[str], None] = None, + subset_obstypes:Union[list[str], None] = None) -> pd.DataFrame: + #TODO: docstring + if subset_stations is None: + stationtargets = dataset.stations + else: + stationtargets = [dataset.get_station(station_name) for station_name in subset_stations] + + to_concat = [] + for station in stationtargets: + stadf = station_qc_overview_df(station, subset_obstypes=subset_obstypes).reset_index() + #add obstype to the index + if not stadf.empty: + stadf["name"] = station.name + stadf = stadf.reset_index().set_index(['datetime', "obstype", "name"]) + to_concat.append(stadf) + + totaldf = save_concat(to_concat) + totaldf.sort_index(inplace=True) + + if totaldf.empty: + return pd.DataFrame(columns=['value', 'label', 'details'], + index=pd.MultiIndex( + levels=[[], [], []], codes=[[], [], []], names=["datetime", "obstype", "name"])) + + return totaldf[['value', 'label', 'details']] \ No newline at end of file diff --git a/src/metobs_toolkit/sensordata.py b/src/metobs_toolkit/sensordata.py index 7633fa90..29c4350a 100644 --- a/src/metobs_toolkit/sensordata.py +++ b/src/metobs_toolkit/sensordata.py @@ -21,7 +21,9 @@ ) from metobs_toolkit.gf_collection.overview_df_constructors import ( sensordata_gap_status_overview_df, - sensordata_qc_overview_df +) +from metobs_toolkit.qc_collection.overview_df_constructor import ( + sensordata_qc_overview_df, ) from metobs_toolkit.settings_collection import Settings from metobs_toolkit.xrconversions import sensordata_to_xr @@ -153,7 +155,7 @@ def __str__(self) -> str: """Return a string representation of the SensorData object.""" return f"{self.obstype.name} data of station {self.stationname}." - #TODO: update this method to handle QCresult outliers + #TODO: update this method to handle QCresult outliers + outliers_values_bin def __add__(self, other: "SensorData") -> "SensorData": """ Combine two SensorData objects for the same station and obstype. @@ -894,42 +896,7 @@ def get_qc_freq_statistics(self) -> pd.DataFrame: qcseries.name = 'counts' return qcseries - # infodict = {} # checkname : details - # ntotal = self.series.shape[0] # gaps included !! - # already_rejected = self.gapsdf.shape[0] # initial gap records - # # add the 'ok' labels - # infodict[Settings.get("label_def.goodrecord.label")] = { - # "N_all": ntotal, - # "N_labeled": self.series[self.series.notnull()].shape[0], - # } - # # add the 'gap' labels - # # TODO: I think the filled and failed labels must be included as well - # infodict[Settings.get("label_def.regular_gap.label")] = { - # "N_all": ntotal, - # "N_labeled": already_rejected, - # } - - # # add the qc check labels - # for check in self.outliers: - # n_outliers = check["df"].shape[0] - # n_checked = ntotal - already_rejected - # outlierlabel = Settings.get(f"label_def.{check['checkname']}.label") - # infodict[outlierlabel] = { - # "N_labeled": n_outliers, - # "N_checked": n_checked, - # "N_all": ntotal, - # } - - # # remove the outliers of the previous check - # already_rejected = already_rejected + n_outliers - - # # Convert to a dataframe - # checkdf = pd.DataFrame(infodict).transpose() - # checkdf.index.name = "qc_check" - # checkdf["name"] = self.stationname - # checkdf = checkdf.reset_index().set_index(["name", "qc_check"]) - - # return checkdf + # ------------------------------------------ # Gaps related diff --git a/src/metobs_toolkit/station.py b/src/metobs_toolkit/station.py index 1e5cb3f4..4674cf82 100644 --- a/src/metobs_toolkit/station.py +++ b/src/metobs_toolkit/station.py @@ -35,6 +35,8 @@ ) from metobs_toolkit.gf_collection.overview_df_constructors import ( station_gap_status_overview_df, +) +from metobs_toolkit.qc_collection.overview_df_constructor import ( station_qc_overview_df, ) from metobs_toolkit.backend_collection.filter_modeldatadf import filter_modeldatadf From f5c19fa47a8c01adfff3992c6cbb23a7f0e7316a Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Thu, 29 Jan 2026 10:18:28 +0100 Subject: [PATCH 33/57] qc overview construction in seperate module --- .../gf_collection/overview_df_constructors.py | 107 ------------------ .../qc_collection/overview_df_constructor.py | 2 +- 2 files changed, 1 insertion(+), 108 deletions(-) diff --git a/src/metobs_toolkit/gf_collection/overview_df_constructors.py b/src/metobs_toolkit/gf_collection/overview_df_constructors.py index a2fd37a5..dcb00295 100644 --- a/src/metobs_toolkit/gf_collection/overview_df_constructors.py +++ b/src/metobs_toolkit/gf_collection/overview_df_constructors.py @@ -131,110 +131,3 @@ def dataset_gap_status_overview_df(dataset) -> pd.DataFrame: ) return combdf - - -#=============================== -# QC overiew -#=============================== - -def sensordata_qc_overview_df(sensor) -> pd.DataFrame: - #TODO: docstring - - #TODO rearange the order of qc columns to reflect the executeion order - possible_timestamps = sensor.series.index - qc_before_timecoarsening = ['duplicated_timestamp'] - - - to_concat = [] - for qcresult in sensor.outliers: - checkdf = qcresult.create_outliersdf( - map_to_basic_labels=False, #get all flags (ok, outl, notchecked, unmet, saved) - subset_to_outliers=False) #Get all flags - #add checkname to the index - checkdf["checkname"] = qcresult.checkname - if qcresult.checkname in qc_before_timecoarsening: - #Subset to coarsende timestmaps only - checkdf = checkdf.reindex(possible_timestamps) - - checkdf.set_index("checkname", append=True, inplace=True) - to_concat.append(checkdf) - - totaldf = save_concat(to_concat) - - if totaldf.empty: - return pd.DataFrame(columns=['value', 'label', 'details'], - index=pd.DatetimeIndex([], name='datetime')) - - #Unstack - totaldf = totaldf.unstack(level='checkname') - totaldf.fillna('Not applied', inplace=True) - - #add values - allvals = pd.concat([sensor.series, sensor.outliers_values_bin]) #do not sort before removing the duplicates ! - allvals = allvals[~allvals.index.duplicated(keep='last')].sort_index() - totaldf['value'] = allvals.loc[totaldf.index] - - return totaldf[['value', 'label', 'details']] - - -def station_qc_overview_df(station, subset_obstypes:Union[list[str], None] = None) -> pd.DataFrame: - #TODO: docstring - - if subset_obstypes is None: - sensortargets = station.sensordata.values() - else: - sensortargets = [] - for obstype in subset_obstypes: - if obstype in station.sensordata: - sensortargets.append(station.get_sensor(obstype)) - else: - #Log a warning? - pass - - to_concat = [] - for sensordata in sensortargets: - stadf = sensordata_qc_overview_df(sensordata).reset_index() - #add obstype to the index - if not stadf.empty: - stadf["obstype"] = sensordata.obstype.name - stadf = stadf.reset_index().set_index(['datetime', "obstype"]) - to_concat.append(stadf) - - - - totaldf = save_concat(to_concat) - totaldf.sort_index(inplace=True) - - if totaldf.empty: - return pd.DataFrame(columns=['value', 'label', 'details'], - index=pd.MultiIndex( - levels=[[], []], codes=[[], []], names=["datetime", "obstype"])) - - return totaldf[['value', 'label', 'details']] - -def dataset_qc_overview_df(dataset, subset_stations:Union[list[str], None] = None, - subset_obstypes:Union[list[str], None] = None) -> pd.DataFrame: - #TODO: docstring - if subset_stations is None: - stationtargets = dataset.stations - else: - stationtargets = [dataset.get_station(station_name) for station_name in subset_stations] - - to_concat = [] - for station in stationtargets: - stadf = station_qc_overview_df(station, subset_obstypes=subset_obstypes).reset_index() - #add obstype to the index - if not stadf.empty: - stadf["name"] = station.name - stadf = stadf.reset_index().set_index(['datetime', "obstype", "name"]) - to_concat.append(stadf) - - totaldf = save_concat(to_concat) - totaldf.sort_index(inplace=True) - - if totaldf.empty: - return pd.DataFrame(columns=['value', 'label', 'details'], - index=pd.MultiIndex( - levels=[[], [], []], codes=[[], [], []], names=["datetime", "obstype", "name"])) - - return totaldf[['value', 'label', 'details']] \ No newline at end of file diff --git a/src/metobs_toolkit/qc_collection/overview_df_constructor.py b/src/metobs_toolkit/qc_collection/overview_df_constructor.py index 142d2a53..ebf6e458 100644 --- a/src/metobs_toolkit/qc_collection/overview_df_constructor.py +++ b/src/metobs_toolkit/qc_collection/overview_df_constructor.py @@ -40,7 +40,7 @@ def sensordata_qc_overview_df(sensor) -> pd.DataFrame: totaldf.fillna('Not applied', inplace=True) #add values - allvals = pd.concat([sensor.series, sensor.outliers_values_bin]) #do not sort before removing the duplicates ! + allvals = save_concat([sensor.series, sensor.outliers_values_bin]) #do not sort before removing the duplicates ! allvals = allvals[~allvals.index.duplicated(keep='last')].sort_index() totaldf['value'] = allvals.loc[totaldf.index] From 06f5d299837f6eaacf788f1fcf5d978bb8a1baa7 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Thu, 29 Jan 2026 10:22:16 +0100 Subject: [PATCH 34/57] from buddywrapstation to buddywrapsensor --- .../spatial_checks/buddy_check.py | 15 +-- ...buddywrapstation.py => buddywrapsensor.py} | 97 ++++++++++++------- .../methods/lapsratecorrection.py | 12 +-- .../spatial_checks/methods/pdmethods.py | 17 ++-- .../spatial_checks/methods/safetynets.py | 6 +- .../spatial_checks/methods/samplechecks.py | 11 ++- .../spatial_checks/methods/whitesaving.py | 4 +- 7 files changed, 93 insertions(+), 69 deletions(-) rename src/metobs_toolkit/qc_collection/spatial_checks/{buddywrapstation.py => buddywrapsensor.py} (90%) diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py index 880679e3..fbd307c1 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py @@ -16,7 +16,7 @@ from metobs_toolkit.qc_collection.distancematrix_func import generate_distance_matrix from metobs_toolkit.qcresult import QCresult, flagged_cond -from .buddywrapstation import BuddyCheckStation, to_qc_labels_map, reconstruct_fractured_targets +from .buddywrapsensor import BuddyWrapSensor, to_qc_labels_map, reconstruct_fractured_targets from metobs_toolkit.settings_collection import Settings from ..whitelist import WhiteSet # Import methods @@ -34,6 +34,8 @@ def _run_buddy_test(kwargs): #executer for mutliprocessing return buddymethods.buddy_test_a_station(**kwargs) +#TODO: Trough all modules related to the buddy check, there is often the reference to wrappedbuddystation or buddywrapstation. Replace these to buddywrapsensor + @log_entry def toolkit_buddy_check( target_stations: list[Station], @@ -198,7 +200,7 @@ def toolkit_buddy_check( dict A dictionary mapping each synchronized timestamp to its original timestamp. - A dictionary mapping station names to BuddyCheckStationDetails objects + A dictionary mapping sensor names to BuddyCheckSensorDetails objects containing detailed tracking information for each timestamp. @@ -211,8 +213,8 @@ def toolkit_buddy_check( `Dataset.get_LCZ()` method. """ - targets = [BuddyCheckStation(station=sta) for sta in target_stations] - + targets = [BuddyWrapSensor(sensor=sta.get_sensor(obstype), + site=sta.site) for sta in target_stations] # Validate safety net configs if provided buddymethods.validate_safety_net_configs(safety_net_configs) @@ -241,14 +243,13 @@ def toolkit_buddy_check( # construct a wide observation dataframe widedf, timestamp_map = buddymethods.create_wide_obs_df( - wrappedstations=targets, - obstype=obstype, + wrappedsensors=targets, instantaneous_tolerance=instantaneous_tolerance, ) # lapse rate correction widedf = buddymethods.correct_lapse_rate(widedf=widedf, - wrappedstations=targets, + wrappedsensors=targets, lapserate=lapserate) diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapsensor.py similarity index 90% rename from src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py rename to src/metobs_toolkit/qc_collection/spatial_checks/buddywrapsensor.py index 57a002a8..915eb27c 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapstation.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapsensor.py @@ -15,7 +15,8 @@ import pandas as pd if TYPE_CHECKING: - from metobs_toolkit.station import Station + from metobs_toolkit.sensordata import SensorData + from metobs_toolkit.site import Site logger = logging.getLogger("") @@ -46,22 +47,22 @@ } #=============================== -# Buddy check station class +# Buddy wrap sensor class #=============================== -class BuddyCheckStation: - """Wrapper for a Station with buddy check-specific details. +class BuddyWrapSensor: + """Wrapper for a Sensor with buddy check-specific details. - This class wraps a Station object and adds information about how it is + This class wraps a sensor object and adds information about how it is handled during the buddy check process, including buddy assignment, filtering steps, and participation in buddy groups. Attributes ---------- - station : Station - The wrapped Station object. + sensor : SensorData + The wrapped Sensor object. _buddy_groups : dict - Dictionary mapping group names to lists of buddy station names. + Dictionary mapping group names to lists of buddy sensor names. flag_lapsrate_corrections : bool Whether lapse rate corrections have been applied. cor_term : float @@ -89,10 +90,11 @@ class BuddyCheckStation: }, } """ - station: Station + # station: Station - def __init__(self, station: Station): - self.station = station + def __init__(self, sensor: SensorData, site:Site): + self._sensor = sensor + self._site = site # Initialize instance-specific attributes (NOT class attributes!) self._buddy_groups: Dict[str, List[str]] = { 'spatial': [], @@ -112,10 +114,20 @@ def __init__(self, station: Station): 'whitelist_check': {}, } + @property + def sensor(self) -> SensorData: + """Get the wrapped SensorData object.""" + return self._sensor + + @property + def site(self) -> Site: + """Get the wrapped Site object.""" + return self._site + @property def name(self) -> str: """Get the station name.""" - return self.station.name + return self.sensor.stationname @property def flags(self) -> pd.DataFrame: @@ -164,9 +176,17 @@ def add_flags(self, iteration: int, flag_series: pd.Series, column_name: str) -> if flag_series.empty: return + + # Subset flag_series to only include indices present in self.sensor.series.index + #edgcase: this can be caused because of the wideobds desing, that creates empty rows + # for timestamps where no observations are present. + valid_index = flag_series.index.intersection(self.sensor.series.index) + flag_series = flag_series.loc[valid_index] # Remove duplicates (keep first occurrence) flag_series = flag_series[~flag_series.index.duplicated(keep='first')] + + # Create a DataFrame with MultiIndex for the new flags new_flags = pd.DataFrame({ column_name: flag_series.values @@ -213,6 +233,12 @@ def has_enough_buddies(self, groupname: str, min_buddies: int) -> bool: def _update_details(self, iteration: int, detail_series: pd.Series, groupname: str) -> None: if detail_series.empty: return + + # Subset detail_series to only include indices present in self.sensor.series.index + #edgcase: this can be caused because of the wideobds desing, that creates empty rows + # for timestamps where no observations are present. + valid_index = detail_series.index.intersection(self.sensor.series.index) + detail_series = detail_series.loc[valid_index] # Remove duplicates (keep first occurrence) detail_series = detail_series[~detail_series.index.duplicated(keep='first')] @@ -668,7 +694,7 @@ def _map_dt_index(pdobj: pd.Series | pd.DataFrame, #=============================== # Combine multiple BCS func #=============================== -# combining multiple buddycheckstations is needed when buddy check is applied +# combining multiple buddychecksensors is needed when buddy check is applied # on fractured datasets (used with multiprocessing). @@ -699,58 +725,55 @@ def combine_series_dicts(list_of_dicts, iteration: int): # raise ValueError("Duplicate indices found when combining series dictionaries.") return combined -def combine_buddycheckstations(stations: List[BuddyCheckStation], iteration: int) -> BuddyCheckStation: +def combine_buddychecksensors(sensors: List[BuddyWrapSensor], iteration: int) -> BuddyWrapSensor: # Take the first element and attriute for time independent attributes - trgstation = stations[0].station - trg_buddygroups = stations[0]._buddy_groups - trg_flag_lapsrate_corrections = stations[0].flag_lapsrate_corrections - trg_cor_term = stations[0].cor_term + trg_buddygroups = sensors[0]._buddy_groups + trg_flag_lapsrate_corrections = sensors[0].flag_lapsrate_corrections + trg_cor_term = sensors[0].cor_term # Flags DataFrame with MultiIndex (datetime, iteration) - trg_flags = pd.concat([st.flags for st in stations], axis=0).sort_index() + trg_flags = pd.concat([st.flags for st in sensors], axis=0).sort_index() trg_flags = trg_flags.loc[~trg_flags.index.duplicated(keep='first')] #remove duplicates, keep first occurrence - trg_spatial_details = combine_series_dicts([st.details['spatial_check'] for st in stations], iteration=iteration) - trg_whitelist_check = combine_series_dicts([st.details['whitelist_check'] for st in stations], iteration=iteration) - - saftygroupnames = [list(st.details['safetynet_check'].keys()) for st in stations] + trg_spatial_details = combine_series_dicts([st.details['spatial_check'] for st in sensors], iteration=iteration) + trg_whitelist_check = combine_series_dicts([st.details['whitelist_check'] for st in sensors], iteration=iteration) + saftygroupnames = [list(st.details['safetynet_check'].keys()) for st in sensors] saftygroupnames = set([item for sublist in saftygroupnames for item in sublist]) #flatten and unique trg_safetynet_check = {} for groupname in saftygroupnames: group_series_list = [] - for st in stations: + for st in sensors: if groupname in st.details['safetynet_check']: group_series_list.append(st.details['safetynet_check'][groupname]) trg_safetynet_check[groupname] = combine_series_dicts(group_series_list, iteration=iteration) - #Construct the new BuddyCheckStation - trg_buddystation = BuddyCheckStation(station=trgstation) - trg_buddystation._buddy_groups = trg_buddygroups - trg_buddystation.flag_lapsrate_corrections = trg_flag_lapsrate_corrections - trg_buddystation.cor_term = trg_cor_term - trg_buddystation._flags = trg_flags - + #Construct the new BuddyWrapSensor + trg_buddysensor = BuddyWrapSensor(sensor=sensors[0].sensor, site=sensors[0].site) + trg_buddysensor._buddy_groups = trg_buddygroups + trg_buddysensor.flag_lapsrate_corrections = trg_flag_lapsrate_corrections + trg_buddysensor.cor_term = trg_cor_term + trg_buddysensor._flags = trg_flags - trg_buddystation.details = { + trg_buddysensor.details = { 'spatial_check': trg_spatial_details, 'safetynet_check': trg_safetynet_check, 'whitelist_check': trg_whitelist_check, } - return trg_buddystation + return trg_buddysensor -def reconstruct_fractured_targets(fractured_targets: List[BuddyCheckStation], iteration: int) -> List[BuddyCheckStation]: +def reconstruct_fractured_targets(fractured_targets: List[BuddyWrapSensor], iteration: int) -> List[BuddyWrapSensor]: combined = defaultdict(list) - #combine fractured stations by name + #combine fractured sensors by name for targ in fractured_targets: combined[targ.name].append(targ) - #combine each list of fractured stations into a single BuddyCheckStation + #combine each list of fractured sensors into a single BuddyChecksensor for name, comblist in combined.items(): - combined[name] = combine_buddycheckstations(comblist, iteration=iteration) #combine into a single budycheckstation + combined[name] = combine_buddychecksensors(comblist, iteration=iteration) #combine into a single budychecksensor return list(combined.values()) diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/lapsratecorrection.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/lapsratecorrection.py index 04724973..87ec2553 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/methods/lapsratecorrection.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/lapsratecorrection.py @@ -11,32 +11,32 @@ if TYPE_CHECKING: - from ...buddystation import BuddyCheckStation + from metobs_toolkit.qc_collection.spatial_checks.buddywrapsensor import BuddyWrapSensor def correct_lapse_rate(widedf: pd.DataFrame, - wrappedstations: List[BuddyCheckStation], + wrappedsensors: List[BuddyWrapSensor], lapserate: float|None = None) -> pd.DataFrame: if lapserate is None: logger.debug("No lapse rate correction applied") - for wrapsta in wrappedstations: wrapsta.flag_lapsrate_corrections = False + for wrapsens in wrappedsensors: wrapsens.flag_lapsrate_corrections = False else: logger.debug("Applying lapse rate correction with rate: %s", lapserate) #Test if all stations have altitude - has_alts = [budsta.station.site.flag_has_altitude() for budsta in wrappedstations] + has_alts = [budsta.site.flag_has_altitude() for budsta in wrappedsensors] if not all(has_alts): raise ValueError( "At least one station has a NaN value for 'altitude', not lapse rate correction possible" ) - for budsta in wrappedstations: + for budsta in wrappedsensors: budsta.flag_lapsrate_corrections = True # Since buddy check works with relative differences, correct all # stations to the 0m altitude - correction_term = budsta.station.site.altitude * (-1) * lapserate + correction_term = budsta.site.altitude * (-1) * lapserate budsta.cor_term = correction_term #update it in the buddy station #apply the correction on the wide dataframe diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/pdmethods.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/pdmethods.py index 533e0d11..68d64636 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/methods/pdmethods.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/pdmethods.py @@ -10,22 +10,19 @@ if TYPE_CHECKING: - from ...buddystation import BuddyCheckStation + from ..buddywrapsensor import BuddyWrapSensor -def create_wide_obs_df(wrappedstations: List[BuddyCheckStation], - obstype: str, +def create_wide_obs_df(wrappedsensors: List[BuddyWrapSensor], instantaneous_tolerance: pd.Timedelta ) -> Tuple[pd.DataFrame, Dict]: - logger.debug("Constructing wide observation DataFrame for obstype: %s", obstype) + concatlist = [] - for wrapsta in wrappedstations: - if obstype in wrapsta.station.sensordata.keys(): - records = wrapsta.station.get_sensor(obstype).series - records.name = wrapsta.name - concatlist.append(records) + for wrapsens in wrappedsensors: + records = wrapsens.sensor.series + records.name = wrapsens.name + concatlist.append(records) - # synchronize the timestamps logger.debug("Synchronizing timestamps") combdf, timestamp_map = _synchronize_series( diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py index c5f0023b..939b38e6 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py @@ -10,10 +10,10 @@ from .findbuddies import filter_buddygroup_by_altitude from .samplechecks import buddy_test_a_station -from ..buddywrapstation import BC_PASSED +from ..buddywrapsensor import BC_PASSED if TYPE_CHECKING: - from ..buddywrapstation import BuddyCheckStation + from ..buddywrapsensor import BuddyWrapSensor def validate_safety_net_configs(safety_net_configs: List[Dict]) -> None: @@ -64,7 +64,7 @@ def validate_safety_net_configs(safety_net_configs: List[Dict]) -> None: def apply_safety_net( outliers: pd.Index, - buddycheckstations: List[BuddyCheckStation], + buddycheckstations: List[BuddyWrapSensor], buddygroupname: str, metadf: pd.DataFrame, distance_df: pd.DataFrame, diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py index b6eac379..d3f81a41 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py @@ -10,14 +10,14 @@ if TYPE_CHECKING: - from ..buddywrapstation import BuddyCheckStation + from ..buddywrapsensor import BuddyWrapSensor # Import constants from buddywrapstation -from ..buddywrapstation import BC_NO_BUDDIES, BC_PASSED, BC_FLAGGED, BC_NOT_TESTED +from ..buddywrapsensor import BC_NO_BUDDIES, BC_PASSED, BC_FLAGGED, BC_NOT_TESTED def buddy_test_a_station( - centerwrapstation: BuddyCheckStation, + centerwrapstation: BuddyWrapSensor, buddygroupname: str, widedf: pd.DataFrame, min_sample_size: int, @@ -26,7 +26,7 @@ def buddy_test_a_station( iteration: int, check_type: str = 'spatial_check', use_z_robust_method: bool = True, -) -> Tuple[pd.MultiIndex, BuddyCheckStation]: +) -> Tuple[pd.MultiIndex, BuddyWrapSensor]: #TODO update docstring """Find outliers in a buddy group and update station flags/details. @@ -76,6 +76,9 @@ def buddy_test_a_station( buddy_sample_sizes = buddydf.notna().sum(axis=1) # Mark timestamps where center station has no data as NOT_TESTED + + #Edgecaase: If a station has fewer records than others, they are present as NaN in widedf + #But these timestamps do not ex no_data = pd.Series(BC_NOT_TESTED, index=center_series[center_series.isna()].index) centerwrapstation.add_flags( iteration=iteration, diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/whitesaving.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/whitesaving.py index 505ccd6f..818a537d 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/methods/whitesaving.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/whitesaving.py @@ -11,13 +11,13 @@ from .findbuddies import filter_buddygroup_by_altitude from .samplechecks import buddy_test_a_station if TYPE_CHECKING: - from ..buddywrapstation import BuddyCheckStation + from ..buddywrapsensor import BuddyWrapSensor from ...whitelist import WhiteSet def save_whitelist_records( outliers: pd.MultiIndex, - wrappedstations: List[BuddyCheckStation], + wrappedstations: List[BuddyWrapSensor], whiteset: WhiteSet, obstype: str, iteration: int, From 651688f4d49cb2a120d97f075d0102a1c5057f72 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Thu, 29 Jan 2026 11:21:37 +0100 Subject: [PATCH 35/57] drop the MP executor locals from the qcresult so make it serializable again --- src/metobs_toolkit/qcresult.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/metobs_toolkit/qcresult.py b/src/metobs_toolkit/qcresult.py index 038a7ff6..0a99f032 100644 --- a/src/metobs_toolkit/qcresult.py +++ b/src/metobs_toolkit/qcresult.py @@ -67,7 +67,7 @@ def __init__( detail: str = "", ): self.checkname = checkname - self.checksettings = checksettings + self.checksettings = fmt_checksettingsdict(checksettings) if not isinstance(flags.index, pd.DatetimeIndex): raise TypeError("The index of 'flags' must be a pandas.DatetimeIndex.") @@ -82,11 +82,12 @@ def __init__( #Set details (Index is Flags thus includes all timestamps!) self.details = pd.Series([detail] * len(flags), index=flags.index) - + def __repr__(self) -> str: return f"QCresult(checkname={self.checkname})" + @log_entry def add_details_by_series(self, detail_series: pd.Series) -> None: @@ -198,4 +199,19 @@ def create_outliersdf(self, }) outliers_df.set_index('datetime', inplace=True) - return outliers_df \ No newline at end of file + return outliers_df + +#=============================== +# Helpers +#=============================== + +def fmt_checksettingsdict(checksettings: dict) -> str: + + blackkeys = ['executor'] + + #exector is added when using multiprocessing, it is not serializable and thus pkling fails. + + for key in blackkeys: + if key in checksettings: + del checksettings[key] + \ No newline at end of file From 5bca2ee7337e0466d147394ddf8c8a3efbd8fa07 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Fri, 30 Jan 2026 13:16:15 +0100 Subject: [PATCH 36/57] replace index for details by using the flags index --- .../qc_collection/spatial_checks/buddywrapsensor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapsensor.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapsensor.py index 915eb27c..ba060afc 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapsensor.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapsensor.py @@ -375,8 +375,10 @@ def get_final_details(self) -> pd.Series: """ - detailstr = pd.Series('', index=self.details['spatial_check'][0].index) - + # detailstr = pd.Series('', index=self.details['spatial_check'][0].index) + detailstr = pd.Series('', index=self.flags.index) + + def reindex_details(detail_series: pd.Series) -> pd.Series: return detail_series.reindex(detailstr.index).fillna('NA').astype(str) From bda96af4251b929e8ef3d512cdcb59c90b461e42 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Fri, 30 Jan 2026 13:19:02 +0100 Subject: [PATCH 37/57] add some logs --- src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py index fbd307c1..c3e0b61c 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py @@ -351,6 +351,7 @@ def toolkit_buddy_check( len(current_outliers_idx), ) for safety_net_config in safety_net_configs: + logger.debug(f"Applying safety net on category {safety_net_config['category']}") current_outliers_idx = buddymethods.apply_safety_net( outliers=current_outliers_idx, @@ -396,6 +397,7 @@ def toolkit_buddy_check( del qcsettings['target_stations'] del qcsettings['metadf'] for wrapsta in targets: + logger.debug(f"Preparing QCresult for station {wrapsta.name}") #1. Map timestamps back to original timestamps wrapsta.map_timestamps(timestamp_map=timestamp_map[wrapsta.name]) From b8771bf22210a31ab7351ea73dc8304e0f8dc437 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Fri, 30 Jan 2026 15:47:07 +0100 Subject: [PATCH 38/57] minor version bump --- pyproject.toml | 2 +- src/metobs_toolkit/settings_collection/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8bb86e87..5f5feac5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["poetry-core"] [project] name = "MetObs-toolkit" -version = "1.0.0a2" +version = "1.0.0a3" license = "LICENSE" authors = [{name = "Thomas Vergauwen", email = "thomas.vergauwen@ugent.be"}] description = "A Meteorological observations toolkit for scientists" diff --git a/src/metobs_toolkit/settings_collection/version.py b/src/metobs_toolkit/settings_collection/version.py index 65590392..8d70ed60 100644 --- a/src/metobs_toolkit/settings_collection/version.py +++ b/src/metobs_toolkit/settings_collection/version.py @@ -1 +1 @@ -__version__ = "1.0.0a2" +__version__ = "1.0.0a3" From 07e6f7c1fdd69cb6c87c30b90570f528a377df12 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Mon, 2 Feb 2026 13:05:41 +0100 Subject: [PATCH 39/57] log full wrapstation on error (debugging) --- pyproject.toml | 2 +- .../qc_collection/spatial_checks/buddy_check.py | 13 ++++++++++++- src/metobs_toolkit/settings_collection/version.py | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5f5feac5..a6da86fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["poetry-core"] [project] name = "MetObs-toolkit" -version = "1.0.0a3" +version = "1.0.0a4" license = "LICENSE" authors = [{name = "Thomas Vergauwen", email = "thomas.vergauwen@ugent.be"}] description = "A Meteorological observations toolkit for scientists" diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py index c3e0b61c..f46b2ac2 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py @@ -414,7 +414,18 @@ def toolkit_buddy_check( flags=qcflags, detail='', ) - qcres.add_details_by_series(detail_series = wrapsta.get_final_details()) + #DEBUG MODE + try: + qcres.add_details_by_series(detail_series = wrapsta.get_final_details()) + except Exception as e: + + logger.debug(f"Failed to add details for station {wrapsta.name}: {e}") + logger.warning(f" Here are the deatils: \n {wrapsta.__dict__}") + import sys + sys.exit(1) + + + return_results[wrapsta.name] = qcres return return_results diff --git a/src/metobs_toolkit/settings_collection/version.py b/src/metobs_toolkit/settings_collection/version.py index 8d70ed60..6ba69d97 100644 --- a/src/metobs_toolkit/settings_collection/version.py +++ b/src/metobs_toolkit/settings_collection/version.py @@ -1 +1 @@ -__version__ = "1.0.0a3" +__version__ = "1.0.0a4" From c52520128daa4d2673e98bf078ee54f3a97732f2 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Mon, 2 Feb 2026 15:16:09 +0100 Subject: [PATCH 40/57] Potential bug fixes --- .../spatial_checks/buddywrapsensor.py | 97 +++++++++++-------- 1 file changed, 59 insertions(+), 38 deletions(-) diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapsensor.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapsensor.py index ba060afc..81bc9ac9 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapsensor.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapsensor.py @@ -231,7 +231,10 @@ def has_enough_buddies(self, groupname: str, min_buddies: int) -> bool: def _update_details(self, iteration: int, detail_series: pd.Series, groupname: str) -> None: + # Handle empty input - still store an empty entry to ensure the iteration key exists if detail_series.empty: + if iteration not in self.details[groupname]: + self.details[groupname][iteration] = pd.Series(dtype=str, index=pd.DatetimeIndex([], name='datetime')) return # Subset detail_series to only include indices present in self.sensor.series.index @@ -242,16 +245,18 @@ def _update_details(self, iteration: int, detail_series: pd.Series, groupname: s # Remove duplicates (keep first occurrence) detail_series = detail_series[~detail_series.index.duplicated(keep='first')] - # Store details in the details dictionary + # Store details in the details dictionary (even if now empty after filtering) if iteration not in self.details[groupname]: self.details[groupname][iteration] = detail_series else: - #FIXME: i do not think this branch is ever used # Append to existing series for this iteration existing = self.details[groupname][iteration] - combined = pd.concat([existing, detail_series]) - # Remove duplicates keeping first - self.details[groupname][iteration] = combined[~combined.index.duplicated(keep='first')].sort_index() + if existing.empty: + self.details[groupname][iteration] = detail_series + elif not detail_series.empty: + combined = pd.concat([existing, detail_series]) + # Remove duplicates keeping first + self.details[groupname][iteration] = combined[~combined.index.duplicated(keep='first')].sort_index() def add_spatial_details(self, iteration: int, detail_series: pd.Series) -> None: """Add spatial check detail information for an iteration. @@ -373,23 +378,39 @@ def get_final_details(self) -> pd.Series: Series with DatetimeIndex containing detailed description strings. The series name is 'final_details'. """ - - - # detailstr = pd.Series('', index=self.details['spatial_check'][0].index) - detailstr = pd.Series('', index=self.flags.index) + # Handle edge case: if flags are empty, return empty series + if self.flags.empty: + return pd.Series(dtype=str, name='final_details') + detailstr = pd.Series('', index=self.flags.index) def reindex_details(detail_series: pd.Series) -> pd.Series: return detail_series.reindex(detailstr.index).fillna('NA').astype(str) - iter = 0 - while iter in self.details['spatial_check'].keys(): - detailstr = detailstr + f'iteration {iter}:[' + # Get all iterations that have been processed (using the helper method) + iterations = self._get_iterations() + + # If no iterations found, try to infer from flags index + if not iterations: + # Fallback: get unique iterations from the flags MultiIndex + if isinstance(self.flags.index, pd.MultiIndex): + iterations = sorted(self.flags.index.get_level_values('iteration').unique()) + else: + iterations = [0] # Default to iteration 0 + + for iter_num in iterations: + # Check if this iteration exists in spatial_check details + if iter_num not in self.details['spatial_check']: + # If iteration doesn't exist in spatial_check, add a placeholder message + detailstr = detailstr + f'iteration {iter_num}:[No spatial check details available' + else: + detailstr = detailstr + f'iteration {iter_num}:[' #add spatial check details - spatial_details = reindex_details(self.details['spatial_check'][iter]) - detailstr = detailstr.str.cat(spatial_details, sep='') - + if iter_num in self.details['spatial_check']: + spatial_details = reindex_details(self.details['spatial_check'][iter_num]) + detailstr = detailstr.str.cat(spatial_details, sep='') + # else: placeholder already added above detailstr = detailstr + " --> " @@ -397,9 +418,9 @@ def reindex_details(detail_series: pd.Series) -> pd.Series: if bool(self.details['safetynet_check']): for safetynetkey in self.details['safetynet_check'].keys(): - if iter in self.details['safetynet_check'][safetynetkey].keys(): + if iter_num in self.details['safetynet_check'][safetynetkey].keys(): - savedetails = reindex_details(self.details['safetynet_check'][safetynetkey][iter]) + savedetails = reindex_details(self.details['safetynet_check'][safetynetkey][iter_num]) detailstr = detailstr.str.cat(savedetails, sep=f'{safetynetkey}:') else: @@ -410,16 +431,14 @@ def reindex_details(detail_series: pd.Series) -> pd.Series: detailstr = detailstr + "NA " + " --> " #add whitelist details - if iter in self.details['whitelist_check'].keys(): - savedetails = reindex_details(self.details['whitelist_check'][iter]) + if iter_num in self.details['whitelist_check'].keys(): + savedetails = reindex_details(self.details['whitelist_check'][iter_num]) detailstr = detailstr.str.cat(savedetails, sep='') else: detailstr = detailstr + 'NA' detailstr = detailstr + "] \n" - iter += 1 - detailstr.name = 'final_details' return detailstr @@ -701,31 +720,33 @@ def _map_dt_index(pdobj: pd.Series | pd.DataFrame, def combine_series_dicts(list_of_dicts, iteration: int): - #if not data is given, return empty dict - if all([len(d)==0 for d in list_of_dicts]): - return {} - + # Always ensure we return a dict with at least the current iteration key + # This prevents KeyError when accessing details['spatial_check'][iteration] combined = defaultdict(list) - - # collect all series per key + + # Collect all series per key for d in list_of_dicts: - for iter in d.keys(): - if iter == iteration: - combined[iteration].append(d[iter]) + for iter_key in d.keys(): + if iter_key == iteration: + combined[iteration].append(d[iter_key]) else: - combined[iter] = d[iter] #keep other iterations as is (overwrite, not concat else duplicates may occur) + # Keep other iterations as is (overwrite, not concat else duplicates may occur) + combined[iter_key] = d[iter_key] + # Ensure the current iteration key always exists if combined[iteration] == []: - #can happen wen a saftynet is triggered in a previous iteration, but not in the current one + # Can happen when a safetynet is triggered in a previous iteration, but not in the current one + # Or when all input dicts are empty - we still need the iteration key to exist combined[iteration] = pd.Series(dtype=str, index=pd.DatetimeIndex([], name='datetime')) else: - combined[iteration] = pd.concat(combined[iteration]).sort_index() + # Filter out empty series before concatenating to avoid issues + non_empty_series = [s for s in combined[iteration] if not s.empty] + if non_empty_series: + combined[iteration] = pd.concat(non_empty_series).sort_index() + else: + combined[iteration] = pd.Series(dtype=str, index=pd.DatetimeIndex([], name='datetime')) - #sanity check - # for series in combined.values(): - # if series.index.duplicated().any(): - # raise ValueError("Duplicate indices found when combining series dictionaries.") - return combined + return dict(combined) # Convert from defaultdict to regular dict def combine_buddychecksensors(sensors: List[BuddyWrapSensor], iteration: int) -> BuddyWrapSensor: # Take the first element and attriute for time independent attributes From 01adb9688a0754aacc89cca5482884fc9b1aeb76 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Mon, 2 Feb 2026 18:09:29 +0100 Subject: [PATCH 41/57] improve memory and load distribution for multiprocessing --- .../spatial_checks/buddy_check.py | 130 +++++++++++++----- .../spatial_checks/methods/samplechecks.py | 4 +- 2 files changed, 96 insertions(+), 38 deletions(-) diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py index f46b2ac2..c66f94a7 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py @@ -3,10 +3,8 @@ import logging from typing import Union, List, Dict, TYPE_CHECKING -import os import numpy as np from concurrent.futures import ProcessPoolExecutor -import itertools import pandas as pd @@ -31,9 +29,79 @@ def _run_buddy_test(kwargs): - #executer for mutliprocessing + """Executor for multiprocessing - runs buddy test for a single station.""" return buddymethods.buddy_test_a_station(**kwargs) + +def _build_station_buddy_kwargs( + station: 'BuddyWrapSensor', + widedf: pd.DataFrame, + buddygroupname: str, + min_sample_size: int, + min_sample_spread: float, + outlier_threshold: float, + iteration: int, + check_type: str, + use_z_robust_method: bool, +) -> dict: + """ + Build kwargs dictionary for buddy_test_a_station with a minimal widedf subset. + + This function creates a dictionary of keyword arguments to pass to + buddymethods.buddy_test_a_station, including a view of the widedf that + contains only the target station and its buddy stations. This enables + efficient parallelization by station rather than by time chunks. + + Parameters + ---------- + station : BuddyWrapSensor + The wrapped station (center station) to build kwargs for. + widedf : pd.DataFrame + The full wide observation DataFrame with stations as columns. + buddygroupname : str + Name of the buddy group to use for extracting buddies. + min_sample_size : int + Minimum sample size for statistics. + min_sample_spread : float + Minimum sample spread (MAD or std). + outlier_threshold : float + Z-score threshold for flagging outliers. + iteration : int + Current iteration number. + check_type : str + Type of check being performed ('spatial_check', 'safetynet_check:groupname'). + use_z_robust_method : bool + Whether to use robust z-score method. + + Returns + ------- + dict + Dictionary of kwargs to pass to buddymethods.buddy_test_a_station. + The 'widedf' key contains only the columns for the center station + and its buddies. + """ + # Get the center station name and its buddies + center_name = station.name + buddies = station.get_buddies(groupname=buddygroupname) + + # Build list of required columns: center station + all its buddies + required_columns = [center_name] + buddies + + # Create a subset (view) of widedf with only required columns + subset_widedf = widedf[required_columns] + + return { + 'centerwrapstation': station, + 'buddygroupname': buddygroupname, + 'widedf': subset_widedf, + 'min_sample_size': min_sample_size, + 'min_sample_spread': min_sample_spread, + 'outlier_threshold': outlier_threshold, + 'iteration': iteration, + 'check_type': check_type, + 'use_z_robust_method': use_z_robust_method, + } + #TODO: Trough all modules related to the buddy check, there is often the reference to wrappedbuddystation or buddywrapstation. Replace these to buddywrapsensor @log_entry @@ -271,44 +339,34 @@ def toolkit_buddy_check( widedf.loc[outlier_time, outlier_station] = np.nan if use_mp: - num_cores = Settings.get('use_N_cores_for_MP') + num_workers = min(len(targets), num_cores) - logger.debug(f"Running spatial buddy check with multiprocessing on {num_cores} cores") - - # split dataframe along time/index dimension - chunks = np.array_split(widedf, num_cores) + logger.info(f"Running spatial buddy check with multiprocessing on {num_workers} cores") + logger.info( + f"Parallelizing by station: {len(targets)} stations to process" + ) - # build input arguments for each station and each chunk - inputargs = [ - { - 'centerwrapstation': sta, - 'buddygroupname': 'spatial', - 'widedf': chunk, - 'min_sample_size': spatial_min_sample_size, - 'min_sample_spread': min_sample_spread, - 'outlier_threshold': spatial_z_threshold, - 'iteration': i, - 'check_type': 'spatial_check', - 'use_z_robust_method': use_z_robust_method, - } + # Build kwargs for each station with subset widedf containing only + # the center station and its buddies + station_kwargs = [ + _build_station_buddy_kwargs( + station=sta, + widedf=widedf, + buddygroupname='spatial', + min_sample_size=spatial_min_sample_size, + min_sample_spread=min_sample_spread, + outlier_threshold=spatial_z_threshold, + iteration=i, + check_type='spatial_check', + use_z_robust_method=use_z_robust_method, + ) for sta in targets - for chunk in chunks ] - logger.debug( - f"Submitting {len(inputargs)} multiprocessing tasks " - f"({len(targets)} stations × {len(chunks)} chunks)" - ) - - # run in parallel - with ProcessPoolExecutor(max_workers=num_cores) as executor: - buddy_output = list( - executor.map( - _run_buddy_test, - inputargs - ) - ) + # Run in parallel - each task processes one center station + with ProcessPoolExecutor(max_workers=num_workers) as executor: + buddy_output = list(executor.map(_run_buddy_test, station_kwargs)) else: # create inputargs for each buddygroup, and for each chunk in time @@ -351,7 +409,7 @@ def toolkit_buddy_check( len(current_outliers_idx), ) for safety_net_config in safety_net_configs: - logger.debug(f"Applying safety net on category {safety_net_config['category']}") + logger.info(f"Applying safety net on category {safety_net_config['category']}") current_outliers_idx = buddymethods.apply_safety_net( outliers=current_outliers_idx, diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py index d3f81a41..156ebfdc 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/samplechecks.py @@ -69,8 +69,8 @@ def buddy_test_a_station( center_name = centerwrapstation.name # Subset to buddies only (for sample distribution) and center station - buddydf = widedf[buddies].copy() - center_series = widedf[center_name].copy() + buddydf = widedf[buddies] + center_series = widedf[center_name] # Count valid buddy samples per timestamp (center station NOT included) buddy_sample_sizes = buddydf.notna().sum(axis=1) From 1396f52113d99659503dcf44a16435503b10f63f Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Mon, 2 Feb 2026 18:10:44 +0100 Subject: [PATCH 42/57] minor version bump --- pyproject.toml | 2 +- src/metobs_toolkit/settings_collection/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a6da86fa..7f7d2bbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["poetry-core"] [project] name = "MetObs-toolkit" -version = "1.0.0a4" +version = "1.0.0a5" license = "LICENSE" authors = [{name = "Thomas Vergauwen", email = "thomas.vergauwen@ugent.be"}] description = "A Meteorological observations toolkit for scientists" diff --git a/src/metobs_toolkit/settings_collection/version.py b/src/metobs_toolkit/settings_collection/version.py index 6ba69d97..6d03f467 100644 --- a/src/metobs_toolkit/settings_collection/version.py +++ b/src/metobs_toolkit/settings_collection/version.py @@ -1 +1 @@ -__version__ = "1.0.0a4" +__version__ = "1.0.0a5" From fb756c7bc3a208c274c01bf0b4e685430883d91e Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Tue, 3 Feb 2026 11:22:35 +0100 Subject: [PATCH 43/57] add a todo --- src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py index c66f94a7..ef38f680 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py @@ -392,6 +392,7 @@ def toolkit_buddy_check( #buddy output is [(MultiIndex, BuddyCheckStation), ...], that needs to be unpacked outlier_indices, updated_stations = zip(*buddy_output) #overload the Buddycheckstation + #TODO: Now that the parallelization is done by station, this step is not needed anymore! targets = reconstruct_fractured_targets(list(updated_stations), iteration = i) # Concatenate all outlier MultiIndices From 5385cd4ad415ca8fdbf4956b2ba8f502c374fd01 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Tue, 3 Feb 2026 11:22:52 +0100 Subject: [PATCH 44/57] drop debug lines --- .../qc_collection/spatial_checks/buddy_check.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py index ef38f680..27cc30ad 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py @@ -473,18 +473,8 @@ def toolkit_buddy_check( flags=qcflags, detail='', ) - #DEBUG MODE - try: - qcres.add_details_by_series(detail_series = wrapsta.get_final_details()) - except Exception as e: - - logger.debug(f"Failed to add details for station {wrapsta.name}: {e}") - logger.warning(f" Here are the deatils: \n {wrapsta.__dict__}") - import sys - sys.exit(1) - - - + + qcres.add_details_by_series(detail_series = wrapsta.get_final_details()) return_results[wrapsta.name] = qcres return return_results From a870ffdc04e10446b710cd4d7d3a70d39091141b Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Tue, 3 Feb 2026 11:23:18 +0100 Subject: [PATCH 45/57] bugfix, return checksettings --- src/metobs_toolkit/qcresult.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/metobs_toolkit/qcresult.py b/src/metobs_toolkit/qcresult.py index 0a99f032..f4f30465 100644 --- a/src/metobs_toolkit/qcresult.py +++ b/src/metobs_toolkit/qcresult.py @@ -205,7 +205,7 @@ def create_outliersdf(self, # Helpers #=============================== -def fmt_checksettingsdict(checksettings: dict) -> str: +def fmt_checksettingsdict(checksettings: dict) -> dict: blackkeys = ['executor'] @@ -214,4 +214,6 @@ def fmt_checksettingsdict(checksettings: dict) -> str: for key in blackkeys: if key in checksettings: del checksettings[key] + + return checksettings \ No newline at end of file From 456a24880fe6c72d8dba4b216de84726140f38aa Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Tue, 3 Feb 2026 11:24:27 +0100 Subject: [PATCH 46/57] minor version bump --- pyproject.toml | 2 +- src/metobs_toolkit/settings_collection/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7f7d2bbd..985ac313 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["poetry-core"] [project] name = "MetObs-toolkit" -version = "1.0.0a5" +version = "1.0.0a6" license = "LICENSE" authors = [{name = "Thomas Vergauwen", email = "thomas.vergauwen@ugent.be"}] description = "A Meteorological observations toolkit for scientists" diff --git a/src/metobs_toolkit/settings_collection/version.py b/src/metobs_toolkit/settings_collection/version.py index 6d03f467..e82a0336 100644 --- a/src/metobs_toolkit/settings_collection/version.py +++ b/src/metobs_toolkit/settings_collection/version.py @@ -1 +1 @@ -__version__ = "1.0.0a5" +__version__ = "1.0.0a6" From 6e3b8d471450f1f969716b6c75264adf8349545c Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Wed, 4 Feb 2026 15:35:31 +0100 Subject: [PATCH 47/57] hardcode the checksettings, there was a memory leak and unwanted data with locals(). --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 985ac313..b2314ac4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["poetry-core"] [project] name = "MetObs-toolkit" -version = "1.0.0a6" +version = "1.0.0a7" license = "LICENSE" authors = [{name = "Thomas Vergauwen", email = "thomas.vergauwen@ugent.be"}] description = "A Meteorological observations toolkit for scientists" From 17c8a36263ae99759db300845860669018452f94 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Wed, 4 Feb 2026 15:36:18 +0100 Subject: [PATCH 48/57] fix memory leak issue and to mutch info in qcchecksettings with locals() --- .../qc_collection/grossvalue_check.py | 8 +++++++- .../qc_collection/persistence_check.py | 9 +++++++-- .../qc_collection/repetitions_check.py | 8 ++++++-- .../spatial_checks/buddy_check.py | 19 ++++++++++++++---- .../qc_collection/step_check.py | 9 +++++++-- .../qc_collection/window_variation_check.py | 12 +++++++++-- src/metobs_toolkit/qcresult.py | 20 ++----------------- .../settings_collection/version.py | 2 +- 8 files changed, 55 insertions(+), 32 deletions(-) diff --git a/src/metobs_toolkit/qc_collection/grossvalue_check.py b/src/metobs_toolkit/qc_collection/grossvalue_check.py index e2e7dcea..c6586d07 100644 --- a/src/metobs_toolkit/qc_collection/grossvalue_check.py +++ b/src/metobs_toolkit/qc_collection/grossvalue_check.py @@ -58,9 +58,15 @@ def gross_value_check( outliers_after_white_idx=outliers_after_white_idx, ) + checksettings = { + "lower_threshold": lower_threshold, + "upper_threshold": upper_threshold, + "sensorwhiteset": sensorwhiteset, + } + qcresult = QCresult( checkname="gross_value", - checksettings=locals().pop('records', None), + checksettings=checksettings, flags=flags, detail='no details' ) diff --git a/src/metobs_toolkit/qc_collection/persistence_check.py b/src/metobs_toolkit/qc_collection/persistence_check.py index 8424441b..b7917324 100644 --- a/src/metobs_toolkit/qc_collection/persistence_check.py +++ b/src/metobs_toolkit/qc_collection/persistence_check.py @@ -85,6 +85,11 @@ def persistence_check( If the minimum number of records per window is not met over the full time series, a warning is logged, and the function returns an empty DatetimeIndex. """ + checksettings = { + "timewindow": timewindow, + "min_records_per_window": min_records_per_window, + "sensorwhiteset": sensorwhiteset, + } to_check_records = records.dropna() # Exclude outliers and gaps # Test if the conditions for the moving window are met by the records frequency @@ -105,7 +110,7 @@ def persistence_check( ) qcresult = QCresult( checkname="persistence", - checksettings=locals().pop('records', None), + checksettings=checksettings, flags=flags, detail=f"Minimum number of records ({min_records_per_window}) per window ({timewindow}) not met.", ) @@ -144,7 +149,7 @@ def persistence_check( qcresult = QCresult( checkname="persistence", - checksettings=locals().pop('records', None), + checksettings=checksettings, flags=flags, detail='no details' ) diff --git a/src/metobs_toolkit/qc_collection/repetitions_check.py b/src/metobs_toolkit/qc_collection/repetitions_check.py index 81a1575f..031bf70e 100644 --- a/src/metobs_toolkit/qc_collection/repetitions_check.py +++ b/src/metobs_toolkit/qc_collection/repetitions_check.py @@ -52,7 +52,11 @@ def repetitions_check( The persistence check uses thresholds that are meteorologically based (e.g., the moving window is defined by a duration), in contrast to the repetitions check whose thresholds are instrumentally based (e.g., the "window" is defined by a number of records). """ - + + checksettings = { + "max_N_repetitions": max_N_repetitions, + "sensorwhiteset": sensorwhiteset, + } # Drop outliers from the series (these are NaNs) to_check_records = records.dropna() @@ -102,7 +106,7 @@ def repetitions_check( qcresult = QCresult( checkname="repetitions", - checksettings=locals().pop('records', None), + checksettings=checksettings, flags=flags, ) diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py index 27cc30ad..46c24bee 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py @@ -281,6 +281,20 @@ def toolkit_buddy_check( `Dataset.get_LCZ()` method. """ + checksettings = { + "obstype": obstype, + "spatial_buddy_radius": spatial_buddy_radius, + "spatial_min_sample_size": spatial_min_sample_size, + "max_alt_diff": max_alt_diff, + "min_sample_spread": min_sample_spread, + "spatial_z_threshold": spatial_z_threshold, + "N_iter": N_iter, + "instantaneous_tolerance": instantaneous_tolerance, + "whiteset": whiteset, + "safety_net_configs": safety_net_configs, + "lapserate": lapserate, + } + targets = [BuddyWrapSensor(sensor=sta.get_sensor(obstype), site=sta.site) for sta in target_stations] @@ -452,9 +466,6 @@ def toolkit_buddy_check( #Prepare for output return_results = {} - qcsettings = locals() - del qcsettings['target_stations'] - del qcsettings['metadf'] for wrapsta in targets: logger.debug(f"Preparing QCresult for station {wrapsta.name}") #1. Map timestamps back to original timestamps @@ -469,7 +480,7 @@ def toolkit_buddy_check( #4 Create QCresult object qcres = QCresult( checkname='buddy_check', - checksettings=qcsettings, + checksettings=checksettings, flags=qcflags, detail='', ) diff --git a/src/metobs_toolkit/qc_collection/step_check.py b/src/metobs_toolkit/qc_collection/step_check.py index 8a27b0ba..6a750f2d 100644 --- a/src/metobs_toolkit/qc_collection/step_check.py +++ b/src/metobs_toolkit/qc_collection/step_check.py @@ -54,7 +54,12 @@ def step_check( threshold. This is because a temperature drop is meteorologically more common than a sudden increase, which is often the result of a radiation error. """ - + checksettings = { + "max_increase_per_second": max_increase_per_second, + "max_decrease_per_second": max_decrease_per_second, + "sensorwhiteset": sensorwhiteset, + } + # Validate argument values if max_decrease_per_second > 0: raise ValueError("max_decrease_per_second must be negative!") @@ -97,7 +102,7 @@ def step_check( qcresult = QCresult( checkname="step", - checksettings=locals().pop('records', None), + checksettings=checksettings, flags=flags, detail='no details' ) diff --git a/src/metobs_toolkit/qc_collection/window_variation_check.py b/src/metobs_toolkit/qc_collection/window_variation_check.py index 92bf4b30..e1ba22af 100644 --- a/src/metobs_toolkit/qc_collection/window_variation_check.py +++ b/src/metobs_toolkit/qc_collection/window_variation_check.py @@ -76,6 +76,14 @@ def window_variation_check( if max_increase_per_second < 0: raise ValueError("max_increase_per_second must be positive!") + checksettings = { + "timewindow": timewindow, + "min_records_per_window": min_records_per_window, + "max_increase_per_second": max_increase_per_second, + "max_decrease_per_second": max_decrease_per_second, + "sensorwhiteset": sensorwhiteset, + } + # Drop outliers from the series (these are NaNs) to_check_records = records.dropna() @@ -98,7 +106,7 @@ def window_variation_check( qcresult = QCresult( checkname="window_variation", - checksettings=locals().pop('records', None), + checksettings=checksettings, flags=flags, detail=f"Minimum number of records ({min_records_per_window}) per window ({timewindow}) not met.", ) @@ -170,7 +178,7 @@ def variation_test(window: pd.Series) -> int: qcresult = QCresult( checkname="window_variation", - checksettings=locals().pop('records', None), + checksettings=checksettings, flags=flags, detail='no details' ) diff --git a/src/metobs_toolkit/qcresult.py b/src/metobs_toolkit/qcresult.py index f4f30465..8d63e609 100644 --- a/src/metobs_toolkit/qcresult.py +++ b/src/metobs_toolkit/qcresult.py @@ -67,7 +67,7 @@ def __init__( detail: str = "", ): self.checkname = checkname - self.checksettings = fmt_checksettingsdict(checksettings) + self.checksettings = checksettings if not isinstance(flags.index, pd.DatetimeIndex): raise TypeError("The index of 'flags' must be a pandas.DatetimeIndex.") @@ -200,20 +200,4 @@ def create_outliersdf(self, }) outliers_df.set_index('datetime', inplace=True) return outliers_df - -#=============================== -# Helpers -#=============================== - -def fmt_checksettingsdict(checksettings: dict) -> dict: - - blackkeys = ['executor'] - - #exector is added when using multiprocessing, it is not serializable and thus pkling fails. - - for key in blackkeys: - if key in checksettings: - del checksettings[key] - - return checksettings - \ No newline at end of file + \ No newline at end of file diff --git a/src/metobs_toolkit/settings_collection/version.py b/src/metobs_toolkit/settings_collection/version.py index e82a0336..162150b7 100644 --- a/src/metobs_toolkit/settings_collection/version.py +++ b/src/metobs_toolkit/settings_collection/version.py @@ -1 +1 @@ -__version__ = "1.0.0a6" +__version__ = "1.0.0a7" From fc828a22518ec308e1b476c9045d30dc171ff694 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Thu, 5 Feb 2026 20:49:11 +0100 Subject: [PATCH 49/57] remove complex joining functions since parallelization is per station --- .../spatial_checks/buddy_check.py | 5 +- .../spatial_checks/buddywrapsensor.py | 89 ------------------- 2 files changed, 2 insertions(+), 92 deletions(-) diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py index 46c24bee..8abf21ca 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py @@ -14,7 +14,7 @@ from metobs_toolkit.qc_collection.distancematrix_func import generate_distance_matrix from metobs_toolkit.qcresult import QCresult, flagged_cond -from .buddywrapsensor import BuddyWrapSensor, to_qc_labels_map, reconstruct_fractured_targets +from .buddywrapsensor import BuddyWrapSensor, to_qc_labels_map from metobs_toolkit.settings_collection import Settings from ..whitelist import WhiteSet # Import methods @@ -406,8 +406,7 @@ def toolkit_buddy_check( #buddy output is [(MultiIndex, BuddyCheckStation), ...], that needs to be unpacked outlier_indices, updated_stations = zip(*buddy_output) #overload the Buddycheckstation - #TODO: Now that the parallelization is done by station, this step is not needed anymore! - targets = reconstruct_fractured_targets(list(updated_stations), iteration = i) + targets = list(updated_stations) # Concatenate all outlier MultiIndices # Each element is a MultiIndex with (name, datetime) diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapsensor.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapsensor.py index 81bc9ac9..50ae76b7 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapsensor.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapsensor.py @@ -712,92 +712,3 @@ def _map_dt_index(pdobj: pd.Series | pd.DataFrame, return df -#=============================== -# Combine multiple BCS func -#=============================== -# combining multiple buddychecksensors is needed when buddy check is applied -# on fractured datasets (used with multiprocessing). - - -def combine_series_dicts(list_of_dicts, iteration: int): - # Always ensure we return a dict with at least the current iteration key - # This prevents KeyError when accessing details['spatial_check'][iteration] - combined = defaultdict(list) - - # Collect all series per key - for d in list_of_dicts: - for iter_key in d.keys(): - if iter_key == iteration: - combined[iteration].append(d[iter_key]) - else: - # Keep other iterations as is (overwrite, not concat else duplicates may occur) - combined[iter_key] = d[iter_key] - - # Ensure the current iteration key always exists - if combined[iteration] == []: - # Can happen when a safetynet is triggered in a previous iteration, but not in the current one - # Or when all input dicts are empty - we still need the iteration key to exist - combined[iteration] = pd.Series(dtype=str, index=pd.DatetimeIndex([], name='datetime')) - else: - # Filter out empty series before concatenating to avoid issues - non_empty_series = [s for s in combined[iteration] if not s.empty] - if non_empty_series: - combined[iteration] = pd.concat(non_empty_series).sort_index() - else: - combined[iteration] = pd.Series(dtype=str, index=pd.DatetimeIndex([], name='datetime')) - - return dict(combined) # Convert from defaultdict to regular dict - -def combine_buddychecksensors(sensors: List[BuddyWrapSensor], iteration: int) -> BuddyWrapSensor: - # Take the first element and attriute for time independent attributes - trg_buddygroups = sensors[0]._buddy_groups - trg_flag_lapsrate_corrections = sensors[0].flag_lapsrate_corrections - trg_cor_term = sensors[0].cor_term - - # Flags DataFrame with MultiIndex (datetime, iteration) - trg_flags = pd.concat([st.flags for st in sensors], axis=0).sort_index() - trg_flags = trg_flags.loc[~trg_flags.index.duplicated(keep='first')] #remove duplicates, keep first occurrence - - trg_spatial_details = combine_series_dicts([st.details['spatial_check'] for st in sensors], iteration=iteration) - trg_whitelist_check = combine_series_dicts([st.details['whitelist_check'] for st in sensors], iteration=iteration) - saftygroupnames = [list(st.details['safetynet_check'].keys()) for st in sensors] - saftygroupnames = set([item for sublist in saftygroupnames for item in sublist]) #flatten and unique - trg_safetynet_check = {} - for groupname in saftygroupnames: - group_series_list = [] - for st in sensors: - if groupname in st.details['safetynet_check']: - group_series_list.append(st.details['safetynet_check'][groupname]) - trg_safetynet_check[groupname] = combine_series_dicts(group_series_list, iteration=iteration) - - - #Construct the new BuddyWrapSensor - trg_buddysensor = BuddyWrapSensor(sensor=sensors[0].sensor, site=sensors[0].site) - trg_buddysensor._buddy_groups = trg_buddygroups - trg_buddysensor.flag_lapsrate_corrections = trg_flag_lapsrate_corrections - trg_buddysensor.cor_term = trg_cor_term - trg_buddysensor._flags = trg_flags - - - trg_buddysensor.details = { - 'spatial_check': trg_spatial_details, - 'safetynet_check': trg_safetynet_check, - 'whitelist_check': trg_whitelist_check, - } - - return trg_buddysensor - -def reconstruct_fractured_targets(fractured_targets: List[BuddyWrapSensor], iteration: int) -> List[BuddyWrapSensor]: - combined = defaultdict(list) - - #combine fractured sensors by name - for targ in fractured_targets: - combined[targ.name].append(targ) - - #combine each list of fractured sensors into a single BuddyChecksensor - for name, comblist in combined.items(): - combined[name] = combine_buddychecksensors(comblist, iteration=iteration) #combine into a single budychecksensor - - return list(combined.values()) - - \ No newline at end of file From 851452b44cbcdeb3e4b66e363faaa0e07d67f68b Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Thu, 5 Feb 2026 20:50:27 +0100 Subject: [PATCH 50/57] debug version bump --- pyproject.toml | 2 +- src/metobs_toolkit/settings_collection/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b2314ac4..e2a7140b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["poetry-core"] [project] name = "MetObs-toolkit" -version = "1.0.0a7" +version = "1.0.0a8" license = "LICENSE" authors = [{name = "Thomas Vergauwen", email = "thomas.vergauwen@ugent.be"}] description = "A Meteorological observations toolkit for scientists" diff --git a/src/metobs_toolkit/settings_collection/version.py b/src/metobs_toolkit/settings_collection/version.py index 162150b7..44b82629 100644 --- a/src/metobs_toolkit/settings_collection/version.py +++ b/src/metobs_toolkit/settings_collection/version.py @@ -1 +1 @@ -__version__ = "1.0.0a7" +__version__ = "1.0.0a8" From 0d26d6c7b45bcf652cc64340fba368bd247f9795 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Fri, 6 Feb 2026 11:56:17 +0100 Subject: [PATCH 51/57] add the max N buddies functionallity+ minor version bump --- pyproject.toml | 2 +- src/metobs_toolkit/dataset.py | 21 +++++++++ .../spatial_checks/buddy_check.py | 47 ++++++++++++++++--- .../spatial_checks/methods/__init__.py | 2 +- .../spatial_checks/methods/findbuddies.py | 32 +++++++++++++ .../spatial_checks/methods/safetynets.py | 25 +++++++++- .../settings_collection/version.py | 2 +- 7 files changed, 120 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e2a7140b..57976235 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["poetry-core"] [project] name = "MetObs-toolkit" -version = "1.0.0a8" +version = "1.0.0a9" license = "LICENSE" authors = [{name = "Thomas Vergauwen", email = "thomas.vergauwen@ugent.be"}] description = "A Meteorological observations toolkit for scientists" diff --git a/src/metobs_toolkit/dataset.py b/src/metobs_toolkit/dataset.py index a0e52f80..6a06b7cf 100644 --- a/src/metobs_toolkit/dataset.py +++ b/src/metobs_toolkit/dataset.py @@ -1988,6 +1988,7 @@ def buddy_check( obstype: str = "temp", spatial_buddy_radius: Union[int, float] = 10000, min_sample_size: int = 4, + max_sample_size: Union[int, None] = None, max_alt_diff: Union[int, float, None] = None, min_sample_spread: Union[int, float] = 1.0, spatial_z_threshold: Union[int, float] = 3.1, @@ -2055,6 +2056,12 @@ def buddy_check( The radius to define spatial neighbors in meters. Default is 10000. min_sample_size : int, optional The minimum sample size to calculate statistics on. Default is 4. + max_sample_size : int or None, optional + The maximum number of spatial buddies to use per station. If not + None, the spatial buddies for each station are sorted by distance + and only the nearest ``max_sample_size`` are kept. Must be larger + than ``min_sample_size`` when specified. The default is None + (no limit). max_alt_diff : int | float | None, optional The maximum altitude difference allowed for buddies. Default is None. min_std : int | float, optional @@ -2103,6 +2110,7 @@ def buddy_check( spatial_buddy_radius=spatial_buddy_radius, safety_net_configs=None, min_sample_size=min_sample_size, + max_sample_size=max_sample_size, max_alt_diff=max_alt_diff, min_sample_spread=min_sample_spread, spatial_z_threshold=spatial_z_threshold, @@ -2127,6 +2135,7 @@ def buddy_check_with_safetynets( spatial_buddy_radius: Union[int, float] = 10000, safety_net_configs: List[Dict] = None, min_sample_size: int = 4, + max_sample_size: Union[int, None] = None, max_alt_diff: Union[int, float, None] = None, min_sample_spread: Union[int, float] = 1.0, spatial_z_threshold: Union[int, float] = 3.1, @@ -2235,6 +2244,11 @@ def buddy_check_with_safetynets( * 'z_threshold': int or float, z-value threshold for saving outliers * 'min_sample_size': int, minimum number of buddies required for the safety net test + * 'max_sample_size': int or None (optional), maximum number of + category buddies to use per station. If not None, category + buddies are sorted by distance and only the nearest + ``max_sample_size`` are kept. Must be larger than + ``min_sample_size`` when specified. Defaults to None (no limit). Example:: @@ -2257,6 +2271,12 @@ def buddy_check_with_safetynets( min_sample_size : int, optional The minimum sample size to calculate statistics on. Used for spatial-buddy samples. Default is 4. + max_sample_size : int or None, optional + The maximum number of spatial buddies to use per station. If not + None, the spatial buddies for each station are sorted by distance + and only the nearest ``max_sample_size`` are kept. Must be larger + than ``min_sample_size`` when specified. The default is None + (no limit). max_alt_diff : int or float or None, optional The maximum altitude difference allowed for buddies. Default is None. min_std : int or float, optional @@ -2362,6 +2382,7 @@ def buddy_check_with_safetynets( obstype=obstype, spatial_buddy_radius=spatial_buddy_radius, spatial_min_sample_size=min_sample_size, + spatial_max_sample_size=max_sample_size, max_alt_diff=max_alt_diff, min_sample_spread=min_sample_spread, spatial_z_threshold=spatial_z_threshold, diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py index 8abf21ca..83ffc266 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py @@ -9,7 +9,6 @@ -from metobs_toolkit.backend_collection.datetime_collection import to_timedelta from metobs_toolkit.backend_collection.decorators import log_entry from metobs_toolkit.qc_collection.distancematrix_func import generate_distance_matrix @@ -111,13 +110,14 @@ def toolkit_buddy_check( obstype: str, spatial_buddy_radius: Union[int, float], spatial_min_sample_size: int, - max_alt_diff: Union[int, float, None], - min_sample_spread: Union[int, float], - spatial_z_threshold: Union[int, float], - N_iter: int, - instantaneous_tolerance: pd.Timedelta, + spatial_max_sample_size: Union[int, None] = None, + max_alt_diff: Union[int, float, None] = None, + min_sample_spread: Union[int, float] = 1.0, + spatial_z_threshold: Union[int, float] = 3.1, + N_iter: int = 2, + instantaneous_tolerance: pd.Timedelta = pd.Timedelta("4min"), # Whitelist arguments - whiteset: WhiteSet, + whiteset: WhiteSet = WhiteSet(), # Safety nets safety_net_configs: List[Dict] = None, #Statistical @@ -220,6 +220,12 @@ def toolkit_buddy_check( spatial_min_sample_size : int The minimum sample size to calculate statistics on used by spatial-buddy samples. + spatial_max_sample_size : int or None, optional + The maximum number of spatial buddies to use per station. If not + None, the spatial buddies for each station are sorted by distance + and only the nearest ``spatial_max_sample_size`` buddies are kept. + Must be larger than ``spatial_min_sample_size`` when specified. + The default is None (no limit). max_alt_diff : int, float, or None The maximum altitude difference allowed for buddies. If None, no altitude filter is applied. @@ -250,6 +256,11 @@ def toolkit_buddy_check( * 'z_threshold': int or float, z-value threshold for saving outliers * 'min_sample_size': int, minimum number of buddies required for the safety net test + * 'max_sample_size': int or None (optional), maximum number of category + buddies to use per station. If not None, category buddies are sorted + by distance and only the nearest ``max_sample_size`` are kept. Must + be larger than ``min_sample_size`` when specified. Defaults to None + (no limit). The default is None. lapserate : float or None, optional @@ -281,10 +292,19 @@ def toolkit_buddy_check( `Dataset.get_LCZ()` method. """ + # Validate spatial_max_sample_size + if spatial_max_sample_size is not None: + if spatial_max_sample_size <= spatial_min_sample_size: + raise ValueError( + f"spatial_max_sample_size ({spatial_max_sample_size}) must be " + f"larger than spatial_min_sample_size ({spatial_min_sample_size})." + ) + checksettings = { "obstype": obstype, "spatial_buddy_radius": spatial_buddy_radius, "spatial_min_sample_size": spatial_min_sample_size, + "spatial_max_sample_size": spatial_max_sample_size, "max_alt_diff": max_alt_diff, "min_sample_spread": min_sample_spread, "spatial_z_threshold": spatial_z_threshold, @@ -319,6 +339,18 @@ def toolkit_buddy_check( wrappedstations=targets, ) + # Subset spatial buddies to nearest N if spatial_max_sample_size is set + if spatial_max_sample_size is not None: + logger.debug( + "Subsetting spatial buddies to nearest %s stations", + spatial_max_sample_size, + ) + buddymethods.subset_buddies_to_nearest( + wrappedstations=targets, + distance_df=dist_matrix, + max_sample_size=spatial_max_sample_size, + groupname='spatial', + ) # ---- Part 2: Preparing the records ----- @@ -439,6 +471,7 @@ def toolkit_buddy_check( min_sample_spread=min_sample_spread, #make this configurable? use_z_robust_method=use_z_robust_method, iteration=i, + max_sample_size=safety_net_config.get('max_sample_size', None), ) # NOTE: Records saved by any safety net will be tested again in diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/__init__.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/__init__.py index 74c7c9f5..c1e892b9 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/methods/__init__.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/__init__.py @@ -1,4 +1,4 @@ -from .findbuddies import assign_spatial_buddies, filter_buddygroup_by_altitude +from .findbuddies import assign_spatial_buddies, filter_buddygroup_by_altitude, subset_buddies_to_nearest from .pdmethods import create_wide_obs_df, concat_multiindices from .lapsratecorrection import correct_lapse_rate from .samplechecks import buddy_test_a_station diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/findbuddies.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/findbuddies.py index 6d5aa163..7e2e2d45 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/methods/findbuddies.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/findbuddies.py @@ -62,6 +62,38 @@ def filter_buddygroup_by_altitude( alt_buddies.append(buddy_name) wrappedstation.filter_buddies(groupname=groupname, filteredbuddies=alt_buddies) +def subset_buddies_to_nearest( + wrappedstations: List, + distance_df: pd.DataFrame, + max_sample_size: int, + groupname: str, +) -> None: + """Subset buddy groups to the nearest N stations. + + For each wrapped station, the buddies in the specified group are sorted + by distance and only the closest ``max_sample_size`` buddies are kept. + + Parameters + ---------- + wrappedstations : list of BuddyWrapSensor + The wrapped stations whose buddy groups should be subsetted. + distance_df : pd.DataFrame + Symmetric distance matrix with station names as index and columns. + max_sample_size : int + Maximum number of buddies to keep per station. + groupname : str + The name of the buddy group to subset (e.g., 'spatial'). + """ + for wrapsta in wrappedstations: + buddies = wrapsta.get_buddies(groupname=groupname) + if len(buddies) <= max_sample_size: + continue + # Sort buddies by distance and keep only the nearest N + buddy_distances = distance_df.loc[wrapsta.name, buddies] + nearest = buddy_distances.nsmallest(max_sample_size).index.to_list() + wrapsta.filter_buddies(groupname=groupname, filteredbuddies=nearest) + + # ------------------------------------------ # Help functions to find buddies # ------------------------------------------ diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py index 939b38e6..952c3d70 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py @@ -8,7 +8,7 @@ logger = logging.getLogger("") -from .findbuddies import filter_buddygroup_by_altitude +from .findbuddies import filter_buddygroup_by_altitude, subset_buddies_to_nearest from .samplechecks import buddy_test_a_station from ..buddywrapsensor import BC_PASSED @@ -36,6 +36,7 @@ def validate_safety_net_configs(safety_net_configs: List[Dict]) -> None: return None required_keys = {"category", "buddy_radius", "z_threshold", "min_sample_size"} + optional_keys = {"max_sample_size"} if not isinstance(safety_net_configs, list): raise ValueError( @@ -57,6 +58,18 @@ def validate_safety_net_configs(safety_net_configs: List[Dict]) -> None: f"Required keys are: {', '.join(sorted(required_keys))}" ) + # Validate optional max_sample_size + if "max_sample_size" in config: + max_ss = config["max_sample_size"] + if max_ss is not None: + min_ss = config["min_sample_size"] + if max_ss <= min_ss: + raise ValueError( + f"Safety net config at index {i}: 'max_sample_size' " + f"({max_ss}) must be larger than 'min_sample_size' " + f"({min_ss})." + ) + return None @@ -76,6 +89,7 @@ def apply_safety_net( min_sample_spread: Union[int, float], use_z_robust_method: bool, iteration: int, + max_sample_size: Union[int, None] = None, ) -> pd.MultiIndex: # Track records that were saved (passed the safety net test) @@ -123,6 +137,15 @@ def apply_safety_net( altitudes=metadf['altitude'], max_altitude_diff=max_alt_diff ) + + # Subset category buddies to nearest N if max_sample_size is set + if max_sample_size is not None: + subset_buddies_to_nearest( + wrappedstations=[wrapsta], + distance_df=distance_df, + max_sample_size=max_sample_size, + groupname=buddygroupname, + ) #find outliers in the new categorical group # The buddy_test_a_station function updates flags/details directly diff --git a/src/metobs_toolkit/settings_collection/version.py b/src/metobs_toolkit/settings_collection/version.py index 44b82629..1dd153b0 100644 --- a/src/metobs_toolkit/settings_collection/version.py +++ b/src/metobs_toolkit/settings_collection/version.py @@ -1 +1 @@ -__version__ = "1.0.0a8" +__version__ = "1.0.0a9" From bea4623a49c563099f38f2a3b00a439f02f63836 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Mon, 9 Feb 2026 12:04:19 +0100 Subject: [PATCH 52/57] details bugfix --- .../spatial_checks/buddywrapsensor.py | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapsensor.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapsensor.py index 50ae76b7..7ac049e3 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapsensor.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddywrapsensor.py @@ -382,10 +382,18 @@ def get_final_details(self) -> pd.Series: if self.flags.empty: return pd.Series(dtype=str, name='final_details') - detailstr = pd.Series('', index=self.flags.index) - - def reindex_details(detail_series: pd.Series) -> pd.Series: - return detail_series.reindex(detailstr.index).fillna('NA').astype(str) + # The flags have a MultiIndex (datetime, iteration), but the detail + # Series stored in self.details use a plain DatetimeIndex. We must + # build the detail strings per-iteration using the DatetimeIndex so + # that reindexing can match properly, then combine the results back + # into a Series indexed by the original MultiIndex. + + # Get all datetimes (unique, from flags) + all_datetimes = self.flags.index.get_level_values('datetime').unique() + + def reindex_details(detail_series: pd.Series, dt_index: pd.DatetimeIndex) -> pd.Series: + """Reindex a detail Series to match the given DatetimeIndex.""" + return detail_series.reindex(dt_index).fillna('NA').astype(str) # Get all iterations that have been processed (using the helper method) iterations = self._get_iterations() @@ -397,6 +405,9 @@ def reindex_details(detail_series: pd.Series) -> pd.Series: iterations = sorted(self.flags.index.get_level_values('iteration').unique()) else: iterations = [0] # Default to iteration 0 + + # Build detail strings per datetime using all_datetimes as the index + detailstr = pd.Series('', index=all_datetimes) for iter_num in iterations: # Check if this iteration exists in spatial_check details @@ -408,7 +419,7 @@ def reindex_details(detail_series: pd.Series) -> pd.Series: #add spatial check details if iter_num in self.details['spatial_check']: - spatial_details = reindex_details(self.details['spatial_check'][iter_num]) + spatial_details = reindex_details(self.details['spatial_check'][iter_num], all_datetimes) detailstr = detailstr.str.cat(spatial_details, sep='') # else: placeholder already added above @@ -420,7 +431,7 @@ def reindex_details(detail_series: pd.Series) -> pd.Series: for safetynetkey in self.details['safetynet_check'].keys(): if iter_num in self.details['safetynet_check'][safetynetkey].keys(): - savedetails = reindex_details(self.details['safetynet_check'][safetynetkey][iter_num]) + savedetails = reindex_details(self.details['safetynet_check'][safetynetkey][iter_num], all_datetimes) detailstr = detailstr.str.cat(savedetails, sep=f'{safetynetkey}:') else: @@ -432,7 +443,7 @@ def reindex_details(detail_series: pd.Series) -> pd.Series: #add whitelist details if iter_num in self.details['whitelist_check'].keys(): - savedetails = reindex_details(self.details['whitelist_check'][iter_num]) + savedetails = reindex_details(self.details['whitelist_check'][iter_num], all_datetimes) detailstr = detailstr.str.cat(savedetails, sep='') else: detailstr = detailstr + 'NA' From c6b1e73a46f6490508778b62f2e5e8dfdda2db9f Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Mon, 9 Feb 2026 12:05:05 +0100 Subject: [PATCH 53/57] DEBUG, return wrapsensors for analysis --- src/metobs_toolkit/dataset.py | 3 ++- src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/metobs_toolkit/dataset.py b/src/metobs_toolkit/dataset.py index 6a06b7cf..ef8e932a 100644 --- a/src/metobs_toolkit/dataset.py +++ b/src/metobs_toolkit/dataset.py @@ -2404,13 +2404,14 @@ def buddy_check_with_safetynets( ) metadf = self.metadf.loc[[sta.name for sta in target_stations]] - qcresuldict = toolkit_buddy_check( + qcresuldict, detailsensors = toolkit_buddy_check( target_stations=target_stations, metadf=metadf, **qc_kwargs ) for staname, qcres in qcresuldict.items(): sensordata = self.get_station(staname).get_sensor(obstype) sensordata._update_outliers(qcresult=qcres, overwrite=False) + return detailsensors @copy_doc(dataset_qc_overview_df) @log_entry diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py index 83ffc266..31092d5f 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py @@ -520,5 +520,5 @@ def toolkit_buddy_check( qcres.add_details_by_series(detail_series = wrapsta.get_final_details()) return_results[wrapsta.name] = qcres - return return_results + return return_results, targets From c6a6db51531b9424df2920c9fc85f95eafcdb4fd Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Mon, 9 Feb 2026 12:05:52 +0100 Subject: [PATCH 54/57] debug version bump --- pyproject.toml | 2 +- src/metobs_toolkit/settings_collection/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 57976235..4cc931de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["poetry-core"] [project] name = "MetObs-toolkit" -version = "1.0.0a9" +version = "1.0.0a10_debug" license = "LICENSE" authors = [{name = "Thomas Vergauwen", email = "thomas.vergauwen@ugent.be"}] description = "A Meteorological observations toolkit for scientists" diff --git a/src/metobs_toolkit/settings_collection/version.py b/src/metobs_toolkit/settings_collection/version.py index 1dd153b0..2af715ac 100644 --- a/src/metobs_toolkit/settings_collection/version.py +++ b/src/metobs_toolkit/settings_collection/version.py @@ -1 +1 @@ -__version__ = "1.0.0a9" +__version__ = "1.0.0a10_debug" From a708338daa7f1b63232505bc68543b87b9dbd468 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Mon, 9 Feb 2026 12:10:38 +0100 Subject: [PATCH 55/57] bump because of invalid version format --- pyproject.toml | 2 +- src/metobs_toolkit/settings_collection/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4cc931de..fc224493 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["poetry-core"] [project] name = "MetObs-toolkit" -version = "1.0.0a10_debug" +version = "1.0.0a11" license = "LICENSE" authors = [{name = "Thomas Vergauwen", email = "thomas.vergauwen@ugent.be"}] description = "A Meteorological observations toolkit for scientists" diff --git a/src/metobs_toolkit/settings_collection/version.py b/src/metobs_toolkit/settings_collection/version.py index 2af715ac..576e5e14 100644 --- a/src/metobs_toolkit/settings_collection/version.py +++ b/src/metobs_toolkit/settings_collection/version.py @@ -1 +1 @@ -__version__ = "1.0.0a10_debug" +__version__ = "1.0.0a11" From 517ed84b9349ad4d661b66e9da4dae64754cc2c6 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Tue, 10 Feb 2026 15:32:11 +0100 Subject: [PATCH 56/57] add only_if_previous_had_no_buddies functionallity for buddy saftynets --- pyproject.toml | 2 +- src/metobs_toolkit/dataset.py | 26 +++++- .../spatial_checks/buddy_check.py | 17 ++++ .../spatial_checks/methods/safetynets.py | 89 ++++++++++++++++++- .../settings_collection/version.py | 2 +- 5 files changed, 131 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fc224493..4d719b1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["poetry-core"] [project] name = "MetObs-toolkit" -version = "1.0.0a11" +version = "1.0.0a12" license = "LICENSE" authors = [{name = "Thomas Vergauwen", email = "thomas.vergauwen@ugent.be"}] description = "A Meteorological observations toolkit for scientists" diff --git a/src/metobs_toolkit/dataset.py b/src/metobs_toolkit/dataset.py index ef8e932a..241a57a3 100644 --- a/src/metobs_toolkit/dataset.py +++ b/src/metobs_toolkit/dataset.py @@ -2203,6 +2203,11 @@ def buddy_check_with_safetynets( #. For each safety net in `safety_net_configs` (in order): + * If `only_if_previous_had_no_buddies` is True for this + safety net, only outlier records where the previous safety + net had insufficient buddies are passed to this safety net. + All other records retain their status from the previous + safety net. * Category buddies (stations sharing the same category value within the specified radius) are identified. * The category-buddy sample is tested in size (sample size must @@ -2249,6 +2254,13 @@ def buddy_check_with_safetynets( buddies are sorted by distance and only the nearest ``max_sample_size`` are kept. Must be larger than ``min_sample_size`` when specified. Defaults to None (no limit). + * 'only_if_previous_had_no_buddies': bool (optional), if True + this safety net is only applied to outlier records for which + the **previous** safety net could not be executed due to + insufficient buddies. Records that were successfully tested + by the previous safety net (passed or failed) are not + re-tested. This enables a cascading fallback strategy. + Cannot be True for the first safety net. Defaults to False. Example:: @@ -2263,7 +2275,8 @@ def buddy_check_with_safetynets( "category": "network", "buddy_radius": 50000, "z_threshold": 2.5, - "min_sample_size": 3 + "min_sample_size": 3, + "only_if_previous_had_no_buddies": True } ] @@ -2345,6 +2358,17 @@ def buddy_check_with_safetynets( ... ] ... ) + Apply cascading safety nets where the second safety net only tests + records that had insufficient buddies in the first: + + >>> dataset.buddy_check_with_safetynets( + ... obstype="temp", + ... safety_net_configs=[ + ... {"category": "LCZ", "buddy_radius": 40000, "z_threshold": 2.1, "min_sample_size": 4}, + ... {"category": "network", "buddy_radius": 50000, "z_threshold": 2.5, "min_sample_size": 3, "only_if_previous_had_no_buddies": True} + ... ] + ... ) + """ if min_std is not None: raise DeprecationWarning( diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py index 31092d5f..e0248a45 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py @@ -184,6 +184,10 @@ def toolkit_buddy_check( on the outliers flagged by the current iteration, for each safety net in order: + * If `only_if_previous_had_no_buddies` is True for this safety net, + only outlier records where the previous safety net had insufficient + buddies (``BC_NO_BUDDIES`` flag) are passed to this safety net. + All other records retain their status from the previous safety net. * Category buddies (stations sharing the same category value within the specified radius) are identified. * The safety net sample is tested in size (sample size must be at @@ -261,6 +265,15 @@ def toolkit_buddy_check( by distance and only the nearest ``max_sample_size`` are kept. Must be larger than ``min_sample_size`` when specified. Defaults to None (no limit). + * 'only_if_previous_had_no_buddies': bool (optional), if True this + safety net is only applied to outlier records for which the + **previous** safety net could not be executed due to insufficient + buddies (``BC_NO_BUDDIES`` flag). Records that were successfully + tested by the previous safety net (whether they passed or failed) + are not re-tested by this one. This enables a cascading fallback + strategy, e.g. first try LCZ buddies, then fall back to network + buddies only for records that had no LCZ buddies. Cannot be True + for the first safety net. Defaults to False. The default is None. lapserate : float or None, optional @@ -454,6 +467,7 @@ def toolkit_buddy_check( len(safety_net_configs), len(current_outliers_idx), ) + previous_safetynet_category = None for safety_net_config in safety_net_configs: logger.info(f"Applying safety net on category {safety_net_config['category']}") @@ -472,7 +486,10 @@ def toolkit_buddy_check( use_z_robust_method=use_z_robust_method, iteration=i, max_sample_size=safety_net_config.get('max_sample_size', None), + only_if_previous_had_no_buddies=safety_net_config.get('only_if_previous_had_no_buddies', False), + previous_safetynet_category=previous_safetynet_category, ) + previous_safetynet_category = safety_net_config['category'] # NOTE: Records saved by any safety net will be tested again in # the following iteration. A different result can occur if the diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py index 952c3d70..61126d54 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py @@ -10,7 +10,7 @@ from .findbuddies import filter_buddygroup_by_altitude, subset_buddies_to_nearest from .samplechecks import buddy_test_a_station -from ..buddywrapsensor import BC_PASSED +from ..buddywrapsensor import BC_PASSED, BC_NO_BUDDIES if TYPE_CHECKING: from ..buddywrapsensor import BuddyWrapSensor @@ -36,7 +36,7 @@ def validate_safety_net_configs(safety_net_configs: List[Dict]) -> None: return None required_keys = {"category", "buddy_radius", "z_threshold", "min_sample_size"} - optional_keys = {"max_sample_size"} + optional_keys = {"max_sample_size", "only_if_previous_had_no_buddies"} if not isinstance(safety_net_configs, list): raise ValueError( @@ -70,6 +70,23 @@ def validate_safety_net_configs(safety_net_configs: List[Dict]) -> None: f"({min_ss})." ) + # Validate optional only_if_previous_had_no_buddies + if "only_if_previous_had_no_buddies" in config: + val = config["only_if_previous_had_no_buddies"] + if not isinstance(val, bool): + raise ValueError( + f"Safety net config at index {i}: " + f"'only_if_previous_had_no_buddies' must be a bool, " + f"got {type(val).__name__}." + ) + if val and i == 0: + raise ValueError( + f"Safety net config at index {i}: " + f"'only_if_previous_had_no_buddies' cannot be True for " + f"the first safety net because there is no previous " + f"safety net to fall back from." + ) + return None @@ -90,6 +107,8 @@ def apply_safety_net( use_z_robust_method: bool, iteration: int, max_sample_size: Union[int, None] = None, + only_if_previous_had_no_buddies: bool = False, + previous_safetynet_category: Union[str, None] = None, ) -> pd.MultiIndex: # Track records that were saved (passed the safety net test) @@ -98,6 +117,72 @@ def apply_safety_net( #create a name map of the wrappedstations name_map = {wrapsta.name: wrapsta for wrapsta in buddycheckstations} + # If only_if_previous_had_no_buddies is True, restrict outliers to only + # those records where the previous safety net had insufficient buddies + # (BC_NO_BUDDIES flag). This is determined by inspecting the flags + # already stored on each BuddyWrapSensor for the current iteration. + if only_if_previous_had_no_buddies: + if previous_safetynet_category is None: + raise ValueError( + "only_if_previous_had_no_buddies is True but " + "previous_safetynet_category is None. This should not " + "happen -- the first safety net cannot use this option." + ) + + prev_check_col = f'safetynet_check:{previous_safetynet_category}' + previous_no_buddies = pd.MultiIndex.from_tuples( + [], names=['name', 'datetime'] + ) + + for station_name in outliers.get_level_values('name').unique(): + wrapsta = name_map[station_name] + if ( + not wrapsta.flags.empty + and prev_check_col in wrapsta.flags.columns + ): + iter_mask = ( + wrapsta.flags.index.get_level_values('iteration') + == iteration + ) + iter_flags = wrapsta.flags.loc[iter_mask, prev_check_col] + nb_mask = iter_flags == BC_NO_BUDDIES + if nb_mask.any(): + nb_timestamps = ( + iter_flags[nb_mask] + .index.get_level_values('datetime') + ) + station_nb = pd.MultiIndex.from_arrays( + [ + [station_name] * len(nb_timestamps), + nb_timestamps, + ], + names=['name', 'datetime'], + ) + previous_no_buddies = previous_no_buddies.union( + station_nb + ) + + if previous_no_buddies.empty: + logger.info( + "only_if_previous_had_no_buddies is True but no records " + "from the previous safety net ('%s') had insufficient " + "buddies. Skipping safety net '%s' entirely.", + previous_safetynet_category, + buddygroupname, + ) + return outliers + + outliers = outliers.intersection(previous_no_buddies) + logger.info( + "Filtering to %s outlier records that had insufficient " + "buddies in the previous safety net ('%s').", + len(outliers), + previous_safetynet_category, + ) + + if outliers.empty: + return outliers + #find the categorical buddies (only for the outlier stations) for outlstation in outliers.get_level_values('name').unique(): diff --git a/src/metobs_toolkit/settings_collection/version.py b/src/metobs_toolkit/settings_collection/version.py index 576e5e14..cf701cdd 100644 --- a/src/metobs_toolkit/settings_collection/version.py +++ b/src/metobs_toolkit/settings_collection/version.py @@ -1 +1 @@ -__version__ = "1.0.0a11" +__version__ = "1.0.0a12" From 55596de68158754f26adb6fe98185c9f7a176343 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Wed, 11 Feb 2026 21:13:46 +0100 Subject: [PATCH 57/57] implement the minimum buddy radius functionallity --- pyproject.toml | 2 +- src/metobs_toolkit/dataset.py | 57 ++++-- .../spatial_checks/buddy_check.py | 26 ++- .../spatial_checks/methods/findbuddies.py | 11 +- .../spatial_checks/methods/safetynets.py | 169 +++++++++++++----- .../settings_collection/version.py | 2 +- 6 files changed, 198 insertions(+), 69 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4d719b1a..41a39a3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["poetry-core"] [project] name = "MetObs-toolkit" -version = "1.0.0a12" +version = "1.0.0a13" license = "LICENSE" authors = [{name = "Thomas Vergauwen", email = "thomas.vergauwen@ugent.be"}] description = "A Meteorological observations toolkit for scientists" diff --git a/src/metobs_toolkit/dataset.py b/src/metobs_toolkit/dataset.py index 241a57a3..f269ff03 100644 --- a/src/metobs_toolkit/dataset.py +++ b/src/metobs_toolkit/dataset.py @@ -1991,6 +1991,7 @@ def buddy_check( max_sample_size: Union[int, None] = None, max_alt_diff: Union[int, float, None] = None, min_sample_spread: Union[int, float] = 1.0, + min_buddy_distance: Union[int, float] = 0.0, spatial_z_threshold: Union[int, float] = 3.1, N_iter: int = 2, instantaneous_tolerance: Union[str, pd.Timedelta] = pd.Timedelta("4min"), @@ -2018,16 +2019,18 @@ def buddy_check( #. A distance matrix is constructed for all interdistances between the stations. This is done using the haversine approximation. #. Groups of spatial buddies (neighbours) are created by using the - `spatial_buddy_radius.` These groups are further filtered by: + `spatial_buddy_radius` and `min_buddy_distance`. Only stations within + the distance range [min_buddy_distance, spatial_buddy_radius] are + considered as buddies. These groups are further filtered by: - * removing stations from the groups that differ to much in altitude + * removing stations from the groups that differ too much in altitude (based on the `max_alt_diff`) * removing groups of buddies that are too small (based on the `min_sample_size`) #. Observations per group are synchronized in time (using the - `instantaneous_tolerance` for allignment). - #. If a `lapsrate` is specified, the observations are corrected for + `instantaneous_tolerance` for alignment). + #. If a `lapserate` is specified, the observations are corrected for altitude differences. #. The following steps are repeated for `N-iter` iterations: @@ -2064,8 +2067,15 @@ def buddy_check( (no limit). max_alt_diff : int | float | None, optional The maximum altitude difference allowed for buddies. Default is None. - min_std : int | float, optional - The minimum standard deviation for sample statistics. Default is 1.0. + min_sample_spread : int | float, optional + The minimum sample spread for sample statistics. When use_z_robust_method is True, + this is equal to the minimum MAD to use (avoids division by near-zero). When + use_z_robust_method is False, this is the standard deviation. This parameter helps + to represent the accuracy of the observations. Default is 1.0. + min_buddy_distance : int | float, optional + The minimum distance (in meters) required between a station and its buddies. + Stations closer than this distance will be excluded from the buddy group. + Default is 0.0 (no minimum distance). spatial_z_threshold : int | float, optional The threshold (std units) for flagging observations as outliers. Default is 3.1. N_iter : int, optional @@ -2113,6 +2123,7 @@ def buddy_check( max_sample_size=max_sample_size, max_alt_diff=max_alt_diff, min_sample_spread=min_sample_spread, + min_buddy_distance=min_buddy_distance, spatial_z_threshold=spatial_z_threshold, N_iter=N_iter, instantaneous_tolerance=instantaneous_tolerance, @@ -2138,6 +2149,7 @@ def buddy_check_with_safetynets( max_sample_size: Union[int, None] = None, max_alt_diff: Union[int, float, None] = None, min_sample_spread: Union[int, float] = 1.0, + min_buddy_distance: Union[int, float] = 0.0, spatial_z_threshold: Union[int, float] = 3.1, N_iter: int = 2, instantaneous_tolerance: Union[str, pd.Timedelta] = pd.Timedelta("4min"), @@ -2174,16 +2186,18 @@ def buddy_check_with_safetynets( #. A distance matrix is constructed for all interdistances between the stations. This is done using the haversine approximation. #. Groups of spatial buddies (neighbours) are created by using the - `spatial_buddy_radius.` These groups are further filtered by: + `spatial_buddy_radius` and `min_buddy_distance`. Only stations within + the distance range [min_buddy_distance, spatial_buddy_radius] are + considered as buddies. These groups are further filtered by: - * removing stations from the groups that differ to much in altitude + * removing stations from the groups that differ too much in altitude (based on the `max_alt_diff`) * removing groups of buddies that are too small (based on the `min_sample_size`) #. Observations per group are synchronized in time (using the - `instantaneous_tolerance` for allignment). - #. If a `lapsrate` is specified, the observations are corrected for + `instantaneous_tolerance` for alignment). + #. If a `lapserate` is specified, the observations are corrected for altitude differences. #. The following steps are repeated for `N-iter` iterations: @@ -2209,7 +2223,9 @@ def buddy_check_with_safetynets( All other records retain their status from the previous safety net. * Category buddies (stations sharing the same category value - within the specified radius) are identified. + within the specified distance range) are identified. Like spatial + buddies, category buddies are filtered by distance range + [min_buddy_distance, buddy_radius]. * The category-buddy sample is tested in size (sample size must be at least `min_sample_size`). If the condition is not met, the safety net test is not applied. @@ -2249,6 +2265,10 @@ def buddy_check_with_safetynets( * 'z_threshold': int or float, z-value threshold for saving outliers * 'min_sample_size': int, minimum number of buddies required for the safety net test + * 'min_buddy_distance': int or float (optional), minimum distance + (in meters) required between a station and its category buddies. + Stations closer than this distance will be excluded from the + buddy group. Defaults to 0 (no minimum distance). * 'max_sample_size': int or None (optional), maximum number of category buddies to use per station. If not None, category buddies are sorted by distance and only the nearest @@ -2292,9 +2312,17 @@ def buddy_check_with_safetynets( (no limit). max_alt_diff : int or float or None, optional The maximum altitude difference allowed for buddies. Default is None. - min_std : int or float, optional - The minimum standard deviation for sample statistics. This is used - in spatial and safety net samples. Default is 1.0. + min_sample_spread : int or float, optional + The minimum sample spread for sample statistics. When use_z_robust_method is True, + this is equal to the minimum MAD to use (avoids division by near-zero). When + use_z_robust_method is False, this is the standard deviation. This parameter helps + to represent the accuracy of the observations. This is used in spatial and + safety net samples. Default is 1.0. + min_buddy_distance : int or float, optional + The minimum distance (in meters) required between a station and its spatial buddies. + Stations closer than this distance will be excluded from the buddy group. This also + affects safety net buddy selection unless overridden in the safety_net_configs. + Default is 0.0 (no minimum distance). spatial_z_threshold : int or float, optional The threshold, tested with z-scores, for flagging observations as outliers. Default is 3.1. @@ -2412,6 +2440,7 @@ def buddy_check_with_safetynets( spatial_z_threshold=spatial_z_threshold, N_iter=N_iter, instantaneous_tolerance=instantaneous_tolerance, + min_buddy_distance = min_buddy_distance, lapserate=lapserate, whiteset=whiteset, # Generalized safety net configuration diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py index e0248a45..e57520e9 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/buddy_check.py @@ -113,6 +113,7 @@ def toolkit_buddy_check( spatial_max_sample_size: Union[int, None] = None, max_alt_diff: Union[int, float, None] = None, min_sample_spread: Union[int, float] = 1.0, + min_buddy_distance: Union[int, float]=0, spatial_z_threshold: Union[int, float] = 3.1, N_iter: int = 2, instantaneous_tolerance: pd.Timedelta = pd.Timedelta("4min"), @@ -154,16 +155,18 @@ def toolkit_buddy_check( #. A distance matrix is constructed for all interdistances between the stations. This is done using the haversine approximation. #. Groups of spatial buddies (neighbours) are created by using the - `spatial_buddy_radius.` These groups are further filtered by: + `spatial_buddy_radius` and `min_buddy_distance`. Only stations within + the distance range [min_buddy_distance, spatial_buddy_radius] are + considered as buddies. These groups are further filtered by: - * removing stations from the groups that differ to much in altitude + * removing stations from the groups that differ too much in altitude (based on the `max_alt_diff`) * removing groups of buddies that are too small (based on the `min_sample_size`) #. Observations per group are synchronized in time (using the - `instantaneous_tolerance` for allignment). - #. If a `lapsrate` is specified, the observations are corrected for + `instantaneous_tolerance` for alignment). + #. If a `lapserate` is specified, the observations are corrected for altitude differences. #. The following steps are repeated for `N-iter` iterations: @@ -189,7 +192,9 @@ def toolkit_buddy_check( buddies (``BC_NO_BUDDIES`` flag) are passed to this safety net. All other records retain their status from the previous safety net. * Category buddies (stations sharing the same category value within - the specified radius) are identified. + the specified distance range) are identified. Like spatial buddies, + category buddies are filtered by distance range [min_buddy_distance, + buddy_radius]. * The safety net sample is tested in size (sample size must be at least `min_sample_size`). If the condition is not met, the safety net test is not applied. @@ -238,6 +243,11 @@ def toolkit_buddy_check( this is the equal to the minimum MAD to use (avoids division by near-zero). When use_z_robust_method is False, this is the standard deviation. This parameter helps to represent the accuracy of the observations. + min_buddy_distance : int or float, optional + The minimum distance (in meters) required between a station and its spatial buddies. + Stations closer than this distance will be excluded from the buddy group. This also + affects safety net buddy selection unless overridden in the safety_net_configs. + Default is 0.0 (no minimum distance). spatial_z_threshold : int or float The threshold, tested with z-scores, for flagging observations as outliers. N_iter : int @@ -260,6 +270,10 @@ def toolkit_buddy_check( * 'z_threshold': int or float, z-value threshold for saving outliers * 'min_sample_size': int, minimum number of buddies required for the safety net test + * 'min_buddy_distance': int or float (optional), minimum distance + (in meters) required between a station and its category buddies. + Stations closer than this distance will be excluded from the + buddy group. Defaults to 0 (no minimum distance). * 'max_sample_size': int or None (optional), maximum number of category buddies to use per station. If not None, category buddies are sorted by distance and only the nearest ``max_sample_size`` are kept. Must @@ -349,6 +363,7 @@ def toolkit_buddy_check( metadf = metadf, max_alt_diff=max_alt_diff, buddy_radius=spatial_buddy_radius, + min_buddy_distance = min_buddy_distance, wrappedstations=targets, ) @@ -478,6 +493,7 @@ def toolkit_buddy_check( metadf = metadf, distance_df = dist_matrix, max_distance=safety_net_config['buddy_radius'], + min_distance=safety_net_config.get('min_buddy_distance', 0), max_alt_diff=max_alt_diff, #make this configurable? wideobsds=widedf, safety_z_threshold=safety_net_config['z_threshold'], diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/findbuddies.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/findbuddies.py index 7e2e2d45..9c189f3f 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/methods/findbuddies.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/findbuddies.py @@ -20,13 +20,15 @@ def assign_spatial_buddies( distance_df: pd.DataFrame, metadf: pd.DataFrame, buddy_radius: Union[int, float], + min_buddy_distance: Union[int, float], wrappedstations: List[BuddyCheckStation], max_alt_diff: Union[int, float, None]=None, ) -> None: spatial_buddies = _find_buddies_by_distance(distance_df=distance_df, - buddy_radius=buddy_radius) + buddy_radius=buddy_radius, + min_buddy_distance=min_buddy_distance) #update the wrapstations for wrapsta in wrappedstations: @@ -98,13 +100,14 @@ def subset_buddies_to_nearest( # Help functions to find buddies # ------------------------------------------ def _find_buddies_by_distance( - distance_df: pd.DataFrame, buddy_radius: Union[int, float] + distance_df: pd.DataFrame, buddy_radius: Union[int, float], min_buddy_distance: Union[int, float]=0 ) -> Dict: buddies = {} for refstation, distances in distance_df.iterrows(): - bud_stations = distances[distances <= buddy_radius].index.to_list() - bud_stations.remove(refstation) + bud_stations = distances[(distances <= buddy_radius) & (distances >= min_buddy_distance)].index.to_list() + if refstation in bud_stations: + bud_stations.remove(refstation) buddies[refstation] = bud_stations return buddies diff --git a/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py b/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py index 61126d54..42080e01 100644 --- a/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py +++ b/src/metobs_toolkit/qc_collection/spatial_checks/methods/safetynets.py @@ -90,6 +90,120 @@ def validate_safety_net_configs(safety_net_configs: List[Dict]) -> None: return None +def assign_safety_net_buddies(wrapsta: BuddyWrapSensor, + metadf: pd.DataFrame, + distance_df: pd.DataFrame, + buddygroupname: str, + max_distance: Union[int, float], + min_distance: Union[int, float], + max_alt_diff: Union[int, float, None], + max_sample_size: Union[int, None]) -> None: + """ + Assign category buddies to a wrapped station for safety net buddy checks. + + This function identifies buddies that share the same categorical attribute + (e.g., LCZ, network) with the reference station, within specified distance + constraints and altitude difference limits. + + The assigned buddy group is stored on the wrapped station and can be accessed + using ``wrapsta.get_buddies(groupname=buddygroupname)``. + + Parameters + ---------- + wrapsta : BuddyWrapSensor + The wrapped station for which buddies should be assigned. + metadf : pd.DataFrame + DataFrame containing station metadata. Must have the category column + specified by ``buddygroupname`` (e.g., 'LCZ', 'network') and an + 'altitude' column if ``max_alt_diff`` is not None. + distance_df : pd.DataFrame + Symmetric distance matrix with station names as index and columns. + Distances should be in meters. + buddygroupname : str + The name of the metadata column to group by (e.g., 'LCZ', 'network'). + This is also used as the buddy group identifier on the wrapped station. + max_distance : int or float + Maximum distance (in meters) for category buddies. Stations farther + than this distance will be excluded. + min_distance : int or float + Minimum distance (in meters) required between the station and its + category buddies. Stations closer than this distance will be excluded. + max_alt_diff : int, float, or None + Maximum altitude difference (in meters) allowed for buddies. If None, + no altitude filtering is applied. + max_sample_size : int or None + Maximum number of category buddies to keep per station. If not None, + buddies are sorted by distance and only the nearest ``max_sample_size`` + are retained. If None, no limit is applied. + + Returns + ------- + None + The function modifies ``wrapsta`` in place by assigning the buddy group. + + Notes + ----- + The function applies filters in the following order: + + 1. Category matching: Only stations with the same category value as the + reference station are considered. + 2. Distance filtering: Only stations within [min_distance, max_distance] + are kept. + 3. Altitude filtering (if max_alt_diff is not None): Only stations with + altitude difference <= max_alt_diff are kept. + 4. Sample size limiting (if max_sample_size is not None): Only the nearest + max_sample_size buddies are kept. + + If the reference station has a NaN value for the category, an empty buddy + list is assigned and a warning is logged. + + """ + + + ref_category = metadf.loc[wrapsta.name, buddygroupname] + # Handle NaN values - they should not match with anything + if pd.isna(ref_category): + logger.warning( + "Station %s has NaN value for category '%s' - no category buddies assigned", + wrapsta.name, + buddygroupname, + ) + # Assign empty buddy list + wrapsta.set_buddies([], groupname=buddygroupname) + else: + #find potential candidates + buddy_candidates = metadf.loc[ + metadf[buddygroupname] == ref_category + ].index.to_list() + + #remove self from buddy candidates + buddy_candidates.remove(wrapsta.name) + + target_distances = distance_df.loc[wrapsta.name, buddy_candidates] + #filter by distance + ref_buddies = target_distances[(target_distances <= max_distance) & (target_distances >= min_distance)].index.to_list() + + # Assign the found buddies + wrapsta.set_buddies(ref_buddies, groupname=buddygroupname) + + #filter by altitude difference if needed + if max_alt_diff is not None: + filter_buddygroup_by_altitude( + wrappedstation=wrapsta, + groupname=buddygroupname, + altitudes=metadf['altitude'], + max_altitude_diff=max_alt_diff + ) + + # Subset category buddies to nearest N if max_sample_size is set + if max_sample_size is not None: + subset_buddies_to_nearest( + wrappedstations=[wrapsta], + distance_df=distance_df, + max_sample_size=max_sample_size, + groupname=buddygroupname, + ) + def apply_safety_net( @@ -99,6 +213,7 @@ def apply_safety_net( metadf: pd.DataFrame, distance_df: pd.DataFrame, max_distance: Union[int, float], + min_distance: Union[int, float], max_alt_diff: Union[int, float, None], wideobsds: pd.DataFrame, safety_z_threshold: Union[int, float], @@ -187,51 +302,17 @@ def apply_safety_net( #find the categorical buddies (only for the outlier stations) for outlstation in outliers.get_level_values('name').unique(): wrapsta = name_map[outlstation] + assign_safety_net_buddies( + wrapsta=wrapsta, + metadf = metadf, + distance_df = distance_df, + buddygroupname = buddygroupname, + max_distance = max_distance, + min_distance = min_distance, + max_alt_diff = max_alt_diff, + max_sample_size = max_sample_size) + - ref_category = metadf.loc[wrapsta.name, buddygroupname] - # Handle NaN values - they should not match with anything - if pd.isna(ref_category): - logger.warning( - "Station %s has NaN value for category '%s' - no category buddies assigned", - wrapsta.name, - buddygroupname, - ) - # Assign empty buddy list - wrapsta.set_buddies([], groupname=buddygroupname) - else: - #find potential candidates - buddy_candidates = metadf.loc[ - metadf[buddygroupname] == ref_category - ].index.to_list() - - #remove self from buddy candidates - buddy_candidates.remove(wrapsta.name) - - target_distances = distance_df.loc[wrapsta.name, buddy_candidates] - #filter by distance - ref_buddies = target_distances[target_distances <= max_distance].index.to_list() - - # Assign the found buddies - wrapsta.set_buddies(ref_buddies, groupname=buddygroupname) - - #filter by altitude difference if needed - if max_alt_diff is not None: - filter_buddygroup_by_altitude( - wrappedstation=wrapsta, - groupname=buddygroupname, - altitudes=metadf['altitude'], - max_altitude_diff=max_alt_diff - ) - - # Subset category buddies to nearest N if max_sample_size is set - if max_sample_size is not None: - subset_buddies_to_nearest( - wrappedstations=[wrapsta], - distance_df=distance_df, - max_sample_size=max_sample_size, - groupname=buddygroupname, - ) - #find outliers in the new categorical group # The buddy_test_a_station function updates flags/details directly # and returns only the outlier MultiIndex (BC_FLAGGED records) diff --git a/src/metobs_toolkit/settings_collection/version.py b/src/metobs_toolkit/settings_collection/version.py index cf701cdd..2978f753 100644 --- a/src/metobs_toolkit/settings_collection/version.py +++ b/src/metobs_toolkit/settings_collection/version.py @@ -1 +1 @@ -__version__ = "1.0.0a12" +__version__ = "1.0.0a13"

w`SIL;Fcmt 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 HH}3xdbUHgA diff --git a/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_one_iteration/solution_gapsdf.parquet b/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_one_iteration/solution_gapsdf.parquet index b802b21c2dcb47ae3aaff147e76a35200d4f6209..5ec3fd78d9e25432e7d1c5b457133ef97824b8da 100644 GIT binary patch delta 410 zcmew$xkzfmOGd_{n_n>|GxH~wloVwqm6oIyD_JR2*G~3gHDolNT*GR_xqwd}1PmP~ zZ)de*3MilaomGV=)Un*r(a8}Ad_y;@v$eAbyBVZ-cojtEg&9Oe_-6WNI+>?UUdiRe zST*?#m%b%XVI&Ycl}Cn~W=BT&LisKRK!#%}i0}j&=;)LTVuEBH9Ropx!DJmCc`;WI zGbz`tBnm9(m<@7J#N*y_fW@>0HBOX2#~OtT+Jgboeq?C0$Lc9 zlj5EO)$U{h(q0VGE(X>Pl5{Ib^6+)c2Whv=n%vGUpB4(zS{xbfm6HTyIr?US3=MR2 z3UqV^TIuMR?C1g#2L=Mzc_4P6E7*M=AU4GPU=~n|qoYSuMv_NZ4oCo^1>%*-Yj~7^ S0ezlFl4B1K14DpgkRbq50Czk9 delta 413 zcmZ1^^+9sOOGd`cn_n>|Gf$3Z)nhcA+{bFf*}$g{0#)ggPqNxE`KM3jWK-dBbS!st zbaDg&AK%TEZ0#(QZ*bW%R!kP-)~|O52`9U|l}5R{RRNh`z9mS|Cml#Q`+}GeAi@Pi zIJ$xezW|^eKr%#PC1UQAcfklKtdlR;TQxYfo=p5U{8P;j*gxn0_bRisED8fm_?}| zi_BdnNAk#vflbW@%4Q^nS5!HAyMPRgoLt8(Z{P;fo8<1685Iulg^MRh3g{@HYttQp z?(zVK7|?rA3n#zjmKFnf#mOi#yuvUF>{GC$>*RYpO2DvZ+a~obSJ! zbG{eUU)87H42_OH{pM$Mp2jZ+nc%1R>)`pAbz>k9C^f4at(w!B)HiL%9|29J>en`g z(-(y^W5kY^Let^!NMHm%5Gnx%ek*h`_$>Y=^zpez^B2mKe$AUqU%9g3je>W^_F8_; z_9l^U)x6rKdwArLiOIC>HofZ2!6VU{amVC6n_lqA5&T_W9ME=;gntX}Wi~nR6gZRnNNrL+R(88g|1|lE!$9_H%d*hkmF`^tB zUON2k4Uz8$V^565{=5l)($5n0XL}2_}bf8)F%xWw8{BHEn zJAFs0zQ1AXn|`fz+w$Z?SMlc~Y%snXy78y}Te;h=UJXQVK*eIdvfMJPnp=%N%+8UI zt>e(qyC+ihdk{hy7zov2rN|hrW1C9RF*@RcxZ?A8ZYmj+@W#~K4nj#1VkK$mZqYITElCii_6kC& z0$D4_Kq!qsMj0ZPV9|?^tj|J35R%CvRe~c3bV(cEFduSzCz&=Sv&}Sy++VQ&qf=Q^t1}^Yf7jg*=V~M&EMOvI)o14oX$Vec? z);hU{;YoJhPA=~LaC9Mvr%olp+j2w0zt8Ytxhu8tiBpT9SbsS8QCV%MB@>PiMMvhu}Gzrd#fS@0#XrTjom9(Ltw?TaXqL`P6(zo>?_I zQbS_phRhRSwIVl6C1C40Ut+W5ZP<2wzhBYSMmi{rNpx7dXA7_H2AYl_Th z6^6D{^ki1`iU~uwL=m*gcB`r+yE^BrtCcoQUsbFoy^L9ZQFIfQUg`N3x$>4Kl$mNp zs_R0QsY1~j;E>Gl?3uBspB-S*n}sXo}WUmH*II1%6YsNehcj!!0*V!P(Mnis-0N zE51B_%MNQ2P$;*d$CD1}zPN5dTbu=*OE6GsUFCS+04S}JU_^m;#k8CeSLB2dhm$DH z@3jI7**jr9o@;5TJ-wUTs_oqZxDl)=SZmj}ByZTr&#xlVjkpFmDctt;wL$gFc0Nt* zn*hvIvwVxxxK}$|f%P`&t5(T&O|U`I-hRK~VWh6ettl)IFN(ZGR4C_4 ztE{o0DHfc1qFb%-n|YUYOd-1t5iH_E9&DPDRvpn-Nol1=VxaGF7MJZ9E+4<(nHy^F z(bEV2e|&jA0KFz;EAoMM=GX>IOCV*klxHZ9?Xf2HBDp5JDrar!9I%Vr1^-!uPtBe< zPBU1|$W=-?d))dUL2cuyv$yQfjD>6q&YdwUMTg?$7WV@pj=F-jAW`BW{7j$@KEh@K zoJTz^z$VI(g!sTuq#W2r67hw^_ctZcHr-7)lsO;W8bB+(+QojnnZ R%?|!$B*>^$-8#E>&M2# z?}*Cfp@PJrz@Q!ws*pfEfP{psBGevu9FUMWMil}9MZg6K&RnW8Gj>rV812r?_s{o# z|35ST{2^VJ-uMKN=7l%!e1ryYgY=MJgYU?-#o!5oAX1IOezR2T+vT=aYmwfEvV~S@ z-x{=po|+*Sd|=hEXe8q%uxm(x+lB@58u;1p&Bb7RD`Rh!bbD}bZ(n~H>Imd>zLKWf zxt3beOYQm;Vf}s19@Od$z3?b`a=N5A?VD}#RgxmXZ$|HMmim*#`JA0NgyWEU%SgWq zJ~PjQAIxqB0VDIFiTS`dXQS4rn`<^I{_WBlO+0x7LFyAAZ+V3JX@dCzG%UW!TNM2Y z#ay0b-T@z57QvxqY3j-Z{gsJ1Jjq<20whI)XROPUcaG8Do0-39<}TQ?vfzEIZ}N(n z{>#W*JI&kzzgSO!d)71b+YWk}vC!|$5wjO;6ODpiEHrJe5827vMsWYQlk^OoCw?_O z=6U?mCBk-$OD5ylm8PPW>IK_fw+l5l2b|NZ20j7w=~*&5d~N!snGv+zoSVa~3;R&9 zCU-e8u8y3f1i<@++* zcwS)x?JYf4DTFdDDdopl)VgI+Px_TmT8nF(mV`;>y%ALjsSU{;OQri_Q(TX-%Wtr$-CIbUoYFTiU^dgM%?ChM`9oQ*W|DRBvW?qKcwQ4`W=!MwG6ZnT-K7kuep zt(kB{g`SNfxa~SHa7UbH;I>f(wa69CW7tYMF_m__VG52|AiQ*D z%mw3M7nTOTH(?`{t_E`Pdd61_W!ef`>&TTxE1yfM`Ot=2!X@s6-&DCVU;P!7?NW!BP1kg;6DrywDDLRSqW` z#c-yY$0x8}LIKeN7efL^{V@Scjr`H0;hQq+<9Z!P#R;P532q%9y21sIBPKk*kr(*! u?5Q(2*-%d{Vi9S~#peJm0=V(?8HdN|cDiR@m?Mb)e#A=-g4pJgo__#$@W&(o diff --git a/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_one_iteration/solution_modeldatadf.parquet b/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_one_iteration/solution_modeldatadf.parquet index 5b68f8b54715fd0a3791a47c986ae5deb0188fec..57dae89016fefcb2a27cac6e6fab1d908dabc309 100644 GIT binary patch delta 619 zcmah_%TB^T6fGDrQACZ2(ZmNaZlx8Xpb3jR*cM1oXrfS7Qb4H`sBHy&1>L*r#)ThY z!dJL+=fV&0H=H}5A-b^X+?+de&Yd&oedS^GCFS+J_S4?av6+(NSh{xURPFV>;LV*I z@@;t{?ujov`83Pp*Ey5jXJ6c#0{6qW;a3R&-~zGUpPGn{&r3ojnKsqtu}~~z^h2FQ zPu;kmc&^=hKi{K76%`#dzg<@g8H&qvfekr;7$V$)1giyv`&d9^HH!u)%88T>AhS&d z8Dc)9t-8B}G}HwiWYx zf`T_ju16+NQUaMnM+^$I5=RRwhL$YW$_=BYHo68LRZze4OHc3#qxw2+d&2=g_7i7R z%7_1ZGR~3}qasVDmdwBv^1A>M0DX{3gB}D*^ZU+;fP(iAa%YiYr!ft6+>q?bAU~!V z5aMxeF=&>oi_5CBSvJkhl4Z3z!FJWM^;R<&j)a7euyni3aesgAF~D(IP`17R2_LH| delta 523 zcmZpayDq!IlZkQrW-q4a%#)qj^cW2%7qJ;}H3;Z~LAu-Ib!@gw?&Xu;vZ?U6IypKz zg0Q1Y@@83fbJoc@JhqG#lUMQR+qi>-lil4)qukx9fJ_e%-^bC_(J=tTasd&JSs=o# z97KW%uvv~SlYM#R>jQy;PNk9IhB+W<#{d^?eSM33kSjnEjzM72NDv9p1?GcoNiy=P zO7X}kj|y~*1gQ@V01_bc16@IeLY(3VW`Z?2gGl$Fj1>1UgG88p^{yd6U6vLg1Ax*n z3)4Yrku3BB@sq+Wv!lW*j3Ohv3L?Y3ih&jbZ2{@mE=JRz3{sDz-wVWt>W6zaALw8v zeap!{ysD|$Q4v8IiQyGhj=otyvn&D~odO+!E(V5gG9(m%rU6400&w^cs0HYtsEj0! fFmPx>v>-xY@;*K}U`*fTli^6>V_*ny3^D`&_X&|F diff --git a/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_one_iteration/solution_outliersdf.parquet b/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_one_iteration/solution_outliersdf.parquet index b28f51d14a1649b412d2df1c60b37574976ce6d4..f7d6f4b359e299dcb4eadcf822d888e82156ad03 100644 GIT binary patch literal 12164 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 Fe*$5l;G6&e literal 8207 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 diff --git a/tests/test_qc.py b/tests/test_qc.py index 977f4235..deed7822 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -247,6 +247,22 @@ def test_buddy_check_raise_errors(self, import_dataset): from metobs_toolkit.backend_collection.errorclasses import ( MetObsMetadataNotFound, ) + + with pytest.raises(DeprecationWarning): + # Should raise error because no altitude info is available and lapsrate is not none + dataset.buddy_check( + obstype="temp", + spatial_buddy_radius=17000, + min_sample_size=3, + max_alt_diff=150, + min_std=1.0, #cause a deprecation warning + spatial_z_threshold=2.4, + N_iter=1, + instantaneous_tolerance=pd.Timedelta("4min"), + lapserate=-0.0065, # -0.0065 + use_mp=False, + ) + with pytest.raises(MetObsMetadataNotFound): # Should raise error because no altitude info is available and lapsrate is not none @@ -255,7 +271,7 @@ def test_buddy_check_raise_errors(self, import_dataset): spatial_buddy_radius=17000, min_sample_size=3, max_alt_diff=150, - min_std=1.0, + min_sample_spread=1.0, spatial_z_threshold=2.4, N_iter=1, instantaneous_tolerance=pd.Timedelta("4min"), @@ -274,7 +290,7 @@ def test_buddy_check_raise_errors(self, import_dataset): spatial_buddy_radius=17000, min_sample_size=3, max_alt_diff=150, - min_std=1.0, + min_sample_spread=1.0, spatial_z_threshold=2.4, N_iter=1, instantaneous_tolerance=pd.Timedelta("4min"), @@ -293,7 +309,7 @@ def test_buddy_check_one_iteration(self, import_dataset, overwrite_solution=Fals spatial_buddy_radius=25000, min_sample_size=3, max_alt_diff=None, - min_std=1.0, + min_sample_spread=1.0, spatial_z_threshold=2.1, N_iter=1, # one iteration test instantaneous_tolerance=pd.Timedelta("4min"), @@ -331,7 +347,7 @@ def test_buddy_check_more_iterations( spatial_buddy_radius=25000, min_sample_size=3, max_alt_diff=None, - min_std=1.0, + min_sample_spread=1.0, spatial_z_threshold=2.1, N_iter=2, # one iteration test instantaneous_tolerance=pd.Timedelta("4min"), @@ -366,8 +382,9 @@ def test_buddy_check_no_outliers(self, import_dataset): spatial_buddy_radius=25000, min_sample_size=3, max_alt_diff=None, - min_std=1.0, - spatial_z_threshold=5.9, # this does noet create outliers + min_sample_spread=1.0, + spatial_z_threshold=7.4, # this does noet create outliers + use_z_robust_method=True, N_iter=1, instantaneous_tolerance=pd.Timedelta("4min"), lapserate=None, # -0.0065 @@ -375,6 +392,24 @@ def test_buddy_check_no_outliers(self, import_dataset): ) assert dataset.outliersdf.empty + + #Extra test, same settings with non-robust z-method will make outliers + dataset = copy.deepcopy(import_dataset()) + dataset.buddy_check( + obstype="temp", + spatial_buddy_radius=25000, + min_sample_size=3, + max_alt_diff=None, + min_sample_spread=1.0, + spatial_z_threshold=7.4, # this does noet create outliers + N_iter=1, + instantaneous_tolerance=pd.Timedelta("4min"), + use_z_robust_method=False, + lapserate=None, # -0.0065 + use_mp=False, + ) + + assert not dataset.outliersdf.empty def test_buddy_check_with_big_radius( self, import_dataset, overwrite_solution=False @@ -435,7 +470,7 @@ def test_buddy_check_with_safety_nets( ], min_sample_size=3, max_alt_diff=None, - min_std=1.0, + min_sample_spread=1.0, spatial_z_threshold=2.1, N_iter=1, instantaneous_tolerance=pd.Timedelta("4min"), @@ -1259,13 +1294,13 @@ def test_all_qc_methods_with_whiteset(self, import_dataset): if __name__ == "__main__": # When running outside pytest OVERWRITE = False - test_breaking_dataset = TestBreakingDataset() + # test_breaking_dataset = TestBreakingDataset() # Manually call fixtures and pass results to tests # Access the original unwrapped function via __wrapped__ - imported_dataset = test_breaking_dataset.import_dataset.__wrapped__(test_breaking_dataset) - qc_dataset = test_breaking_dataset.regular_qc_on_dataset.__wrapped__( - test_breaking_dataset, imported_dataset - ) + # imported_dataset = test_breaking_dataset.import_dataset.__wrapped__(test_breaking_dataset) + # qc_dataset = test_breaking_dataset.regular_qc_on_dataset.__wrapped__( + # test_breaking_dataset, imported_dataset + # ) # test_breaking_dataset.test_qc_labels(qc_dataset) # test_breaking_dataset.test_qc_with_solution(qc_dataset, overwrite_solution=False) @@ -1273,16 +1308,16 @@ def test_all_qc_methods_with_whiteset(self, import_dataset): # test_breaking_dataset.test_make_plot_by_label_with_outliers(qc_dataset) # test_breaking_dataset.test_get_info(qc_dataset) - # test_demo_dataset = TestDemoDataset() - # imported_demo_dataset = test_demo_dataset.import_dataset.__wrapped__( - # test_demo_dataset - # ) + test_demo_dataset = TestDemoDataset() + imported_demo_dataset = test_demo_dataset.import_dataset.__wrapped__( + test_demo_dataset + ) # test_demo_dataset.test_import_data(imported_demo_dataset, overwrite_solution=OVERWRITE) # test_demo_dataset.test_qc_when_some_stations_missing_obs(imported_demo_dataset) # test_demo_dataset.test_buddy_check_raise_errors(imported_demo_dataset) # test_demo_dataset.test_buddy_check_one_iteration(imported_demo_dataset, overwrite_solution=OVERWRITE) - # test_demo_dataset.test_buddy_check_more_iterations(imported_demo_dataset, overwrite_solution=OVERWRITE) + test_demo_dataset.test_buddy_check_more_iterations(imported_demo_dataset, overwrite_solution=OVERWRITE) # test_demo_dataset.test_buddy_check_no_outliers(imported_demo_dataset) # test_demo_dataset.test_buddy_check_with_big_radius( # imported_demo_dataset, overwrite_solution=OVERWRITE From 2a297b1f75b4924880c3f075cb1720f83ebaa522 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Wed, 28 Jan 2026 09:08:34 +0100 Subject: [PATCH 24/57] fix and add a buddy test --- tests/test_qc.py | 40 +++++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/tests/test_qc.py b/tests/test_qc.py index deed7822..166746d4 100644 --- a/tests/test_qc.py +++ b/tests/test_qc.py @@ -339,26 +339,52 @@ def test_buddy_check_more_iterations( ): # 0. Get info of the current check _method_name = sys._getframe().f_code.co_name - dataset = copy.deepcopy(import_dataset) + # test one iteration - dataset.buddy_check( + dataset_1iter = copy.deepcopy(import_dataset) + dataset_1iter.buddy_check( obstype="temp", spatial_buddy_radius=25000, min_sample_size=3, max_alt_diff=None, min_sample_spread=1.0, spatial_z_threshold=2.1, - N_iter=2, # one iteration test + N_iter=1, # one iteration test instantaneous_tolerance=pd.Timedelta("4min"), lapserate=None, # -0.0065 - use_mp=False, + use_mp=True, ) + + #Test 2 iterations + dataset_2iter = copy.deepcopy(import_dataset) + dataset_2iter.buddy_check( + obstype="temp", + spatial_buddy_radius=25000, + min_sample_size=3, + max_alt_diff=None, + min_sample_spread=1.0, + spatial_z_threshold=2.1, + N_iter=2, # two iteration test + instantaneous_tolerance=pd.Timedelta("4min"), + lapserate=None, # -0.0065 + use_mp=True, + ) + + #apply relative tests + outl2 = dataset_2iter.outliersdf + outl1 = dataset_1iter.outliersdf + + #test if all oult indexes are in outl2 + assert outl1.index.isin(outl2.index).all() + assert outl2.shape[0] > outl1.shape[0] + + #absolute testings # overwrite solution? if overwrite_solution: TestDemoDataset.solutionfixer.create_solution( - solution=dataset, + solution=dataset_2iter, **TestDemoDataset.solkwargs, methodname=_method_name, ) @@ -369,7 +395,7 @@ def test_buddy_check_more_iterations( # validate expression assert_equality( - dataset, solutionobj, exclude_columns=["details"] + dataset_2iter, solutionobj, exclude_columns=["details"] ) # dataset comparison def test_buddy_check_no_outliers(self, import_dataset): @@ -394,7 +420,7 @@ def test_buddy_check_no_outliers(self, import_dataset): assert dataset.outliersdf.empty #Extra test, same settings with non-robust z-method will make outliers - dataset = copy.deepcopy(import_dataset()) + dataset = copy.deepcopy(import_dataset) dataset.buddy_check( obstype="temp", spatial_buddy_radius=25000, From 072fc8c4332b1b6d8f42b6dd5cf64f317df09ed0 Mon Sep 17 00:00:00 2001 From: Thomas Vergauwen Date: Wed, 28 Jan 2026 09:46:06 +0100 Subject: [PATCH 25/57] buddy check test solutions --- .../solution_df.parquet | Bin 62704 -> 60025 bytes .../solution_gapsdf.parquet | Bin 3312 -> 3362 bytes .../solution_metadf.parquet | Bin 11422 -> 11498 bytes .../solution_modeldatadf.parquet | Bin 3799 -> 3841 bytes .../solution_outliersdf.parquet | Bin 17691 -> 15077 bytes .../solution_df.parquet | Bin 62375 -> 59594 bytes .../solution_gapsdf.parquet | Bin 3312 -> 3362 bytes .../solution_metadf.parquet | Bin 11422 -> 11498 bytes .../solution_modeldatadf.parquet | Bin 3799 -> 3841 bytes .../solution_outliersdf.parquet | Bin 8646 -> 11174 bytes tests/test_qc.py | 46 +++++++++++++----- 11 files changed, 34 insertions(+), 12 deletions(-) diff --git a/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_with_big_radius/solution_df.parquet b/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_with_big_radius/solution_df.parquet index 8a8f0f13feec954e98f8e120ffff638e33849666..56ecb0569e61c03f8ec25faede318feac950e9f2 100644 GIT binary patch delta 39996 zcmc$`Xh@ALDv$1!!EsG?H31jdY@FdywT-XgW+JLJ*YGhIoStM}be^YXk;{kPyKY zf@DY(+T1lAg5)S9h3U%Wg747==HXhkEZdO45LR>R8K9wPF;E`?>8m8Ta%1|C2sA{HNCt;Xi3ASKzN21P#w1lZ zJy?|H8yK0^dIXT@*+fHDm=sNHP++KZvA4Z!Hot(v5>-n>kx;1)|Bm1%Dp~HjLwtc3 zZ%_#@<0PY+<5S_RSFA6MfRR8oQ2c%{f3<9CZ@@mRk&4@VEv{6XLcDLo-^1r6~eKuO#ss*c&n z5ad%>p`DnSr+8~eSNUM+2|aX)Da_fnP)%kIm(ccpJ&uVpyjo%v39)(-A{aP*&2PI2gyEkJ%Ta5-@c7vS>=0#P+LL@qogwt_iRFPb@=H}y522g>kIPX2Sh<|5slk{g4^pIg(|aC`}HyXMnZzO5n-=I!xLlR z=!C=v3~8mQeyxKuSDS2fS`>w%JuB-A{PwfNibdXQnr|-40(gPkKfrut^sqWI(-!9t z#t0?a^9Cmj33Rb$o;r+z$Wo}9%}XfGrwGLF7_S1yik4qUpX))04OGiKrcnbhT{wxe z=4autoTAA>3nWCNNg*DDL_w%g4g^W{eNE9cHMbDbkHkTcq)28EZ)Lu>Ctd8TzY?dS zwo7onrI?`t7I~wfCU3h`*+fGM1!ym@_J@!x0fyYItcDO$;VBcTcr9WuWrSk?`P1RV zJ2z@7OgYoIT@!H1mLpcT)js8GUO%hj&KwwBXZ!S$HLt&bN zj3lkKUArbbl76_-dTdqv)b|JV%x*=l%9v=0vRZ$p&NEdot{}8O&Cd5^h&9N)g+UlH z4XTDD4T4${4N||03BF&k>gWCSR?GmyVr#EV&BjJJt=h<^{MOKXdr7#l_1%;0yUesd zUOK=M^~4*#jOvB%HdV&#jDfn426J5}ypBYF_T}V@nd=93AOHTVzb-62KTv%9FYudZ zi1)v}jMg5e3oFp=20*nfYPHORK=@#NT4-ExD@htQCE}$U8-nmhUl+HQM>&;kCeFmwJBABFP2UC#mlx(MevojGv+2{YxaTl%po^|TXQAl9td%PS`HYlG37v5dy_F$;7kW2Wh#@C~olk5KC<)TA zwISspHuc$7@VyM%{CHQ3`BQt}3_p*DmpRZ(a;%w|bGS0h6Hgp*rQlkbkr@XU5*j<8 zz?)TV>lyl{SMk!6Q?c_Eom*hMg1*YXwj2>8g zGE1yM%%Qlz^##?0df-Y724B7S_BfEoMYd$xd>ahCq3*5A=gqZ z@V;y(i?gxU%CM;pxcbo@1GzZr!-VjLsDWdzQf5=KI1Ph!#v6Z`I+*#h-OclUd2faGxnyqp)0t#q%LQ(GEY%nsJ5=pl8uREKTh9nbi=Ua1aYS?VAAkI@TE zgOsnBqT~KSzm3zQ$5(g-m&vdTed&_F?=-9FfhP_$6^}bmr)|F0`(Ox*n1aWP)Via$UGb6Dcj@sU7 zz@|QQ3JCM_&GAaD>WttX?bM0TunBG@?a6q1v(OwzB{^Fnzr#{l`J|ygD7ymcSQ)c}Ni_OEuFToh&fML+Y zbrRk_vl70Sa$ePNI*;PRnM-NSZhK&CzwHuC8kyZhu#Ul7J#>1|mJmY4>hNJh{bgV| zn5*Y(2NZPWY;VnN_FoDem)gZiFZI?*$ss!~D?{SAU;eG=>mEZ1LzGV+)9pNzI*cx6 zE_;Rw#dtE5)jd?X62p-4n}o40!Bu7hb#I4x;`K+&J*Uyd+3~?9W?x`_fw2s-u*eKv zW#v_LUlJYAuq2x8jOd&T${C-m9K4{8LLb0Wt11kCJN}opxT^(!BY1r~@YNpw-;R$k&$cIB)33BZvLeyMNVtD4m zDuofG2vILY`!}Fi%)Dw^& z@GS&ce?FFMs|07R4!=T&X9iZ$c=G9VBJ+BYlb0OT*KQ+JnMM;0FrhGEJveh8Eu#_m zftQZePXAb8HVc&=({s9J6rz@zI_uA}7CJOm9qx(0GqclA9rHZ*#7nbjYeK7M>I`!+ zV64v^#iFmPKogM)3{c+^o(+s|0s-T1)EWN+esGB94e&k0gxpGtj5w5irwvGWQu6gN z948p0n+G0Rpb2q-n5>qEU3-2{tF=7JhK7bddEb4&c|Hf{HQ3$N`z z){gf3?;!i2A74>exc4Eu{b71o1z76AJWoiX5q(H^f%TO>Vh9@hVnD+peaqRp_e zPp=e($!r3y2k%1R=1C3r2zt8+IGQA;kA*biSa`RoLBfS0p+?vIQq=617+)hQ`+YbU zCtN%9uq~m2XcR9_MLv0gsJTq%0^j?HucpaBw58BN#?iK;7^we*H{;1s55OKMq0zHZ zy!#BW_J@58Fbx^&t7tHs)|GOH((haf9Wo;jTw@9yQIXMjwtBjR6&Q(PYR7+9es@0c zuYcm*9VjdOW50jDY>%cY*T`f7`P-VOcaLjXQ03p{qZMNYfDbYrT8EDRLH^)K3Gh8s zK9Msy+f?Q~nKV1@oK%?x;)ANQK1x6^^ z@F1qhE+p~aM@C%3O-`+|5D@+4Ctb@Jhp>N*cr!h0H^55y2c9eM2Y3QQhxZ4IF9q`b z8i9L%#0!gP&*UhJIfhVWemVhIpKz;$Rz-nRE|B@9wor99I$WhL+v;orK1ZL z<>Wn;D5wcaT<^(${rmXwbxo@+{4-Bh=wY+G?r~0_ih=s^ig5Br>$gbxNJT-I4$p`K z>S+M)X$N8(z+cnFJz5wrLmb%oj!NMAWj$cyS6+02CrL;$5mD2PHs?zVqn*(XbYXhl zJL16qs8W*VF3-CVq03YfatfC3J6C+@?UmJwjW=a?(ar>4b zB40qlHf|qli!aG3D`$t;JZ$Hm<~-yTnZj*(NkWe_L0oDSd*xZp6YW4v1iWj!f`CvK z>-ZWu>6!xTxoZM!J@f~DwU+woirSjldLxTC=vT7r)*R}=FD?EmtD8o66gjYbTc5*f{6DNUBM-q;)M^QyY}YU+AERq8_k=U(Zno;>RSL*t5=^=cTk_Q1mIg+2Grd~*r; z>vVhk`kUPRB>m~WV^u3xOy(!QZl@HkT*1vpteDkv^rHRcOJlvKr-i2Rf+FcylSGeQ zh9M`3HTJv)1)5Bo0JTQ{W(Pvj#N?l}oC0c;Xy3gH2(iDr11I^tT->5|cZj;{S-9j| zR%!oYij|TDMZ^7y#W_~5g`D1-WJLXb*Ux7ZYxc9uZ4-`io{S8?7k96n-?)9SkX`@h z-0!McLA#k`2|e=rx^s;q6@-m(gy5Agh%c`E{hO8}O^dy(YTR;+FJb6`@y~bR z?~DRT+rulK^&b-ynmPv*|9JD*%Yv%jgEil6xNdS}N7bo==Qe*E>9gXq2c+b%3ew6y zHN$0gp~q>AN(hB6uw+AG2nz@i7JPAs+|_B2~L@Kd4J z-YJ#E)_sJ`URk;^AsX*XU^yH~+<6toBoAv}=cj!94yqJ=7yQyj8?+Y~>cLq0^|GuO_-|3hbmQI3= z-#?Kb#yqZx_2v6HfKsc?qMAWE7M8+)hec78XDpeGhDy zBc3d@#G8RiXR&$bU9WuDbm))qJ^VU=|LRcQrUBBc;}3f+E;Qa6S?74#v~`oicvWc7 ziq9UF?VKAnT{Gt~zj4=i9o%hD1%VX+<+ZrU!p-$Vo1rZt9+^`cM&sAT_4Lyk)Nt1T$C zTo0l!qz;LSR)8XpmZd9YS%;j5+zA_`(?|qNN#)VZ$!2~>v0;a(w)v#Ub$|8t{;j#1 z$RUa6o7IXJij06O*4L(GCLQZaY0VOwk9a9Wye7bUf({!*z$j|N9K_QcB1r9>F6x@g zO!N`jN;!%rW`S&*nSBFYHUwJR1t`1GGD#nlT(B*~ikn(EjX=1<_M;5%jgI10Y+L98 z_ePojy0_l0nF6FChlqXIyht<6&9GW-&AX>C;joeqHM8k|$GZ_!`5rWY=f>fWeZ*^q z@IFBOFbQR>qIrTsGgFuDUX-IS{YwB}O(8-m6_thV#IQM z&!H!c*NC?3-e%c%=Vcpmmak=9fS#VojkhYPo5`Ll%Efi7o6tHu^}Kiz$?F#sgaPAA zN_PZl!kWbjA(aWPQ!Z`;rk)!rraAA&H7tNy!;C9`hRzg%QU|V~un4L)BSaqwy_$;BzZAoxwb_)aX2rf1sQQI2M_RU z1p8hs`8?=t6EQ<%Pbs%rg6Q8|8iD6{paJs3w}amicHkO@lrvI7 zJG3=ujrK_y5|MYoTqx0Mm!a+=QzEpm-)=|oF6_3Eup3B&b2xA7HB||GH)FpN&o=F} zxk3C$LByg~i!`v=r7mjIXXDYr^Wvg5>U?&-M-o)SE-fg|2lzUn{vQ7-`^?P2Ng^t* zlI9)R6aG!$7S%F7<_Q}47?fJAqzhkOW$|QCwNqvF=D1oZ>cwO#Z?qAnI=UR4PKxw3 z{&iIz?%#ei+RBXRN>8T0xB0#Gi&eY+HoVE*;8xuEgzl8DblG-3rBtlxcsr=b4{HYa zSjS3`1M#Os@p|D^{LHk$B%<9_H7&D66?Qc&QN?pTI45_1>uw z;q7HI#LKiagsb8@ylk^zHno}Q^229U6Og68_e;w`rHRd>OWh`4bi;2?z1`@T_Q*2> zP8#FJ4V)}?CO3w6f(LO-5qzm zV%@<<NN*Bkdr}=M{QeN8zxCePFgW?=3b zV}yVa(B!hwxZ6#F;}Jop5|TV(g7fW8M88kJ^WEfO`=N(>hIcaprOj#0;pGkzOHvQW zwnG?WTyi&~W-&)Ds0}NX)G0eFb8!y)vc|P^b_w2bW8@-jI4qIih(3U+3eoVd=p=SV3cj={r!m8;v2E6W18ADLv?6@^7;tu<=$(zuV?+GE>H=IZ< zt0lON1~9iJgTTjRWX6aoG!+IOTD&On|>6NoP=y`mbASLK{0W@7qpa+Ic%s@RcO(EyHcJ3bS<= zaD87C#3VB%3ieW8K8AqY@-B|7keKp!*F8xGO>UKSXf!GVG(1(y!VaME9H*=5)io$< zHX)`MLI{vJnk)C^G8ANx@UY{lt2{6ghwFjMQ@{P}>p#kVz4ZG{Ha8DZy#0q&Rngg* z4^~U@*Eh6;z>>PAc298D+3fg|o8%}2 zXz{CPY4QjRpAC9m1us!(R}Iw>anuyF_h@$;^=p+qQR-*uEWaoMA>9)`Gxb^9Pp(bN zeo!|IwMV#l^xRkXckG^YN*{3Dzwo^=3-FWN0iUu9+^V^K;zOlxP*}qEAn@$55t{Dg zCF@#T01M9i2utw6?3X#vUR@93Zb1abyy03Z1KI_eO*Gm@2BWp7=f{R;n@$f@bvmX0 z0CSx$rzIEfHFvB({&{MQqgcIV5W&fqE3jB&+P&g414*66(i`B3Iy^!ybkNC%=1!aG z@EQq_uc8rn5F8;#)$1-5Xq;wn0uTDB%osgRO@n@=j{0f&Y>AY1w|32$Nc)Q?o>l%Z zkWWbo8LS^zm4Nq#W8O^&ro6i1S7!a;6??;N*IZHJ;^@sVCt~+k?z=yFo(AaI&cN7llmpd{FJct+0O}A|gYDF2nV7#kr z7&xO0U%jAjfx_b731Qd5k(P+epBQR~?-0eMX>)@TE(cNf(5AFbW8tkrsXqv(gYf~6 zBuO=aP5}5Gu^{w=a6oM*=}vo~9wc2t)iPIrCo!)S(pue;l80D#Yem%#o5uG3-1bOx zh>Ef8@NaL}vdig0|K5{ALl(Y@<_2FGHt*g}xU$xD%?<^zbJ<$uQop+2+x0);b<+!B z{{gQF`8yu8s6it!RF0Z4*BKu)m@ypC-KVvhoXfn}M{&QSOC0sf=Fnr&U8L6L&7CGQK&nTq*) zu|sE3j-gXO7air`Mb5n3Jh;D|u}rJrPvljVVkqwxd>AquM+@))?y8USkxR0V@(GAW zJQi&F|APlh9ryu1?*>#a)9r&Zex3b;Pbz`n`zC57)u{7v-@tSagv8BfLSLH~&i1}7XyABF#4WCd`$&GH4QE&%!H zmV&Qy|FeGRTZ4Y>j4fy1VE1ni5ZLyer98j9H+(5}sDNruA|~HHz7jtsze5<2Hp>1kTsJ^Llq*;2>`vo1+~fk`=&--L-Lb~ZEwwDY1o?U(;8jKxcxWK~g9czqPX#~t8x z7l|}*p^16*3PgdkTQ7Yz2|#`UtRK=)OlF@qsHGvn)h6IW5;Ac1f+#&O^u>$+cJ{2H+R{d&v(dpFuNJ>3nDI1 zWi9fY47_VXc8u6UftgvI@?1T!71ryTDWJTTw?yjWlP7Tr?t~F%yHfFF99a%}s-?&p z2bqoV1_Kq38zX2`X9?EWvbCN~#=MlCDWoIxqqQI@6?T~`o}>8$F=+02qSyLRm;N( zV?Bs-!G)_aI5b*21a*;?L}JHuZeYGX3Mt<8T@Dzb+KQ_nq#s%Fc|Lxz`Mq@Ix3?>Q zTX=BSY|+f*lO4XfFQ+fBfzC=VA(lkLMsB~} zk|ya-T{~e)_-UXJf4IBX`i>?zpS`i8^XeQG;=t9 z) zW~F_97L<)koiDZu-=DO=`|||u!HY>^*R}2RTMgL2Q|xBs&VgfiYHoOzp?1yw@$_8f zfu;KGt@zC39~U-038|=U{ADf1^L0SMdnA1PaN-fWag+09xl#_nuNL^8T1Z9akjb+2 zdNyKyvl`S_ow2Wr%n8N3Tcs@sN_eF`b06{8SE?lX-e8mC1N8;_zj^rT(M;Iz_Sk?r zY~aH%;hQ9MF5bd6p(zPmE-i>Sd-F)Fu@m^L&Hf|qI|IL|_50%FWCv&D8>(F}JZKkk z`l<4KS{L0-5~Crf@NrCJQxMqeNTNLgBeL9j9yw!~xTb(|gqPr6`6~N_`%Zc7eBw5J zCC==$_wn*E?=ixG^^1m{FiZf-ualKi9}CH@wL{dQH|#O4Z@@O9{7+zfdt^I_LsyI-$*8{Sa|n}@u=|p6~N^+^NNKz^fqIsPk zN2iN}nTX zr72dKaT11ez~mO*%mr6Zu)9RSQ*FZ%^C2+6CaOxg&)FGx9PhEa`|z8lkqV!TW18x7 zuZD&nET1I@!b=@Hs#4Qhl^&z_wZc^KWG0d}Rv>FmLzu_Lp(vhgF_%Zbfwfli4ymt? zF|L+axMvOfc*KvCm{rGyVmc_wLJNaA&xEVnBQFv3`MtaDFP@YgSbF$l*t6t72suvi z(o}I~{iOH~|M7^B@9rFSvb*JOzr@s5O#Bkto4XtSPIUM@vXST31`3>1F?0xt9Kwuhxa|3gc0YddfqM(mk&>BdC>ab9>F8n{};aIoupw9 z>zVX-JO!i{;JgDrK^YjplRv|P52y7F95UKFSv4wPc+yW#EPQX2Xzj&2hj;41k_zsV$uw>h94yKHyjH1$Zz`q5J^_xn#^_ekVAM7>9bS;_Qz%ZZ@2r z-}ii^3_=*Xx^+y%re?1rzW;3WWU+OGp^bLA{iZW*+>M0$j;MiGN#q1rC(NwPFM&zg=U z^*$|wob@Km=Osm(^=za2kB6Ra?d$sN_cmfO_%Bt}sWGi$tB?3X7~sGE2R=+m z_=FGnq<%lhY;1(zd|ZD)$)*yX+zAh6Nsc-jO=V`BPLf6=3<(u6L;74tzoqF5PZ;mY zOr4=OuWGc2biKdCj>;_!`}kg;eR1ZShn=k}^3X9?)XrYPo2@`vhf9fP1i@BhyW?ut zkLtr(5FhY1Y5MgajSui0Hp+7}-k3fHX?Oi%6i;rA9b*U?`di;*-r1E8@Q6-vUgJgNO(=#oP8mNp^n(xSD3Sod}CzfQqeY2LeX;Z zFgI55(e>jUZ-O=&%vXU%R?r80N2LyLpN0Vr26o;``CJc=C9I@rR~ul;Sh@FNLGEYF zu~EwDEu0LWx~A*PP?0YZFQ8(0C$~TbDfH0%d#0OxDb(1}^=W!P9;)@OPL~yxN`|h~ z%oif26mf~e+p@T^Rjr_u#HHx5gJ@)$2-Y~n2I}x4P3UVLz%$bV4t+F!hqg5HvELCI zyw=(f!?;@DzlWE(VEl?Pj6?wb!OA8hBEsl}laLv6eP#;=#~$6gz8o23G!-n_|I3E# z3*UJa_0eQUP%OWqeTNjXshs%bD%Y3<%@Ipa9p8ol_$f+Q>WX0N_|PPy-XHj0vP3BV zP-d*zkLm;C7)NLsUjyGu$o044&2c|0KB_W0Bjkn%OsfF-vD;&YXC_eCsesO*(4#I! z6BMge{YTnYTByXrE80Nf$~kRcxk5AsLjzk+@zG?M*!?!)@ zKi&BH+~i9u3MFX_N0H551HQ+tPA`M6C~CR3q9z$B0DnwqHc$! z+WA+MMnn%jbAN7F0k3;o|E6X`<6m}1{O!jz`5T<9D5>hU*z%_sFgG(+&6UB|6_#9E zrAivd#~;&dP#0;+SR?qo9tNIY+6?$rT0Lu{RZt#Z5%R>@3#XR&;rS%I+47zXZ8Jex z_}#X>_GbkHiiJ6ZB!-6Q%2_lP2%5VNXm2)@{xM-QA07;A%QTHpZDXiv!$GV^XBKyhz zvx~2GjMpE0WbESk%eo2^y>+-poLTM{wxJj?xp|8fxf%M33ud=qFge11BccZ2E4)~f z>OG~|354p4nNYcVV?nWf9%*;N9fkoej=tN&Cdp57X+JCQN__a(Nq#QY$|0dgvOKr} zYtg~r{>sg7!2A^9kDYQC%nan48UEW}yI;=sIU26|+B#{d&|&3qL5byo0uTG~PFepI zGwe12x5>P^6A!^8Td0Lr>L0}9rWzod-GpU@J~^7X%#agTC{dnkUGQGMqSd^8;4-h` z#K@Y1eyJDFMTF`5=6IWtHVvo8-f8fn+FDM(kgjbo`XSZ7_OSQ(%*au*mA9<-?U`$a zzw>|be*a%8j_&hc=5q17a&mBJ>xF&KF%Vh6MHQex-Asg&p%!7ee~?DFD~)*lj&GvT z-ix#96PW`B1R9(%8gQ(`#_0LcP4XK=l;*+BV}7Y4juD+r{W;zivrg9}I(|lU?b8y= zwRQO2NRz)=|G9H+G|%clT4dLrxl#D^{^71Fn};?T>T!`dpAN6zmxg<&%ua{dd32zkVqf zYs!`$kU|4FOH-RpC8&+M6t8garVd`p9LG#w`DX2*pq9ThA#A>W;p5iOoj`UzzHsgJ z3>Io0pF%D*k02ejM&Oe8v{&iG#c7wZ~G+ycot%pqTkU7#ud4?Y63i;(?3BbOHuwS z=bWmk4sHf`Y}pfCN13ThfQMao@}?KRvWb6wTC9icxtWkZ+!!b@g}CV$nGC6iV@5u9 zDBqf#<`z(`zG<0@How6L+*~~637bBe-c7p=jGP|85IV;$ z`Jdg|Ctn#6syT2|rZ2ddexdW}B1&wz>tF{QISir0u0x7lF&W`+oHhyCJjHv#&LEZ;qH6eR39iAHs7-u zm1E%vleVUVH4Wkxm#(oNL~TEK9$J|Z(vpaWptK7u7Y6n3);b?D`BE*NnN%>Y zs*;-0XZShH<+e<{yq5m@}wV!UcHrp2U)>LqD4r507hubg`f%tc+ir( zL6DX5n}Oxav5R{z<1m8z$4v9G5Okl%2G3*rGbKmcZ8QwOqb^q!~kjYDw=mt2?0N*Mqjc%`qfmdgl+Lfw$y zLDu(N917y!%qQDImBPwXD40f0MLOqTimYU$h7bAGo5Ep_C)L=i*duIyRM^f# zz9rFpX+L)N&uuPc__a=KBh}xpWCiBwU)pyMyV0>vPNiOI>+4hCh0LrTtu$MoV_oLf zMCenRV@PG;>KK6>?I{&QmXih|G&z~u7NW<#S5CDRNg-5|0CpfVxWkWFjfC9}EB9N! zr&FSa%}2*B2OPFB+~pJ+J^u9{{7~EThu(IN%$b%)pO^m_O`=y9M)y@uJfk{!mpfig zqaGX`9qdCHMtd{uLzw}ku5p&M83K=Ah7R&_U8MCH5j~qHA(6fkg(mAE@F?{T1$Ue6 z1elgQiK4+|`;R0>%ro+m3+MIKve-fy5S~|cUaJ+no+O>KCsP8+Z zzbDCg^4`oqT9kd}>EGKN#cA%PGt)V@6|M;kwHqIiZ?MkFR<$%MLy}fnz1zlR#NMzZ z(fN=_4=u%zdmyzdxZN%HvQ5dSH2^#rwjGmPYdB%}a=FXgjZayB^dNdH&fwfKu3TjR zs-HM<0i|no%a#z^zUryWq+j>nJiVnL(mr$!437Avxi5P_Ft~|g?NmK0l@1mf`0p%O z%4nkpkPL%v2`5#8YnBAM0aE140GA0mJi;#r1fIkBP{k?G;7o$Y!gclKON-DJR8qSQ zsB_(;Z)n?33|%mLCSR-qh2|`Xv^xBq*z*N%E@l=7EIJMW{Md7^@@O%54@ckQe;eg$ z(mPGOtH2rh!|RKzW9J&L8F)m$W6~dQ&quR${PGdNPnkIEieLm;dfDcp7d8eD32{|_4oTW4YXMl+X zka0wm>(n=op}s2&3q*L&^H0TT36)Pz8%)#i;r?*Jrt|?w#Zu6S7M)GX+eG~m!5(Uov>{NqpG1i zw?5m-S`V=Ez|IHw6=UKgm^i2Z(j44mG>MNZxxs+h0b>L;gK)vNksM2_`Z@6i}$QLekGOBTC-CcF%9bxWw4 zj`d}3qmK(j<+l11@D8__FvAQ2>P4E^fs-f-IaN|^V*v{cjx2MF)Y1lhZ&iR&EB9U! zac*d05SLcP@ux6H}u`GjnnT1FSDdpt)4H1dWq1$u4}Me(v#qAU~pH*17? zh7Z@7!vb@vx$B-m$`xOc`|{P9&&S$*NA~Rrj1SkBXVZ>~@|A6I`*sd53ygX}n{KcV zG1ITg-PSQUQpwU6ka(m1E-eqge%rs}w84I>$<&{^@11U%_;wX~wm;(e@2OT;D)Me; z-@T^*-_7_cG5b0SL$M9*Od=0&wj%c!*dBkyfD0rwdk$p@e(txp=n%GAgxxBSmSkqz7YXx@=#Ebh$uqXMH{ISi9%i+1$XxKk%fOcJ<==O@P+o^ zpWq#?6_hLJvZ=kzDaS zDRh^Bs%Uzd4RUQ(!(sD-UyM}RZs8Xd-KqDJvVZ^4J%IQVXs{XlDY_^hakC+W!KpJ#nR^wwLhhWCTO|0t`^TYU(O_ z*kKpgDlBDmrTD~cKmM&vpnmJyxyeeOjeizhys&+x9fQxLHtELmBoA9K6?><#GFIqX z9+6#KPD)Lqk5jK+siVO@8uY#yh7fPHG2uqQfdTCHB>J9V|MfXsRR0Yrcj$e4V5M#A?u#IEFJ`5X6IWq%~WKv{)_jQ4AYN)-}a92^}taupH_HZ zEIORElJyXNU2!MgqU~EPfflCugb!|t_=s040RASHtiv;Y{xmcKgEOYijJE@HeNeYf z=}uu?!!^cvYpzqeXM)rbX@o+J?o9%3A(1ETMSGKqPeOVw#a2e$UtCCgT_hjKIfGROcl~d(yNzaT6ShWV)B!yI6aM?n+)wz2Z%B!M$Cv3wA~T~=2i;(s;bJqMZfaE! zTp0kKsDYJ1I|fBv#3gmKlu`{Xq-4?v&odYBS@hd@m`~sNFt0fF)l}?*e#~lxX{Ks$akPK8BonIMwfW z#_%zkbF{9t4F?{f@ud1mz#o34@Dw6Q+j?rL2?MQcPtmpB5%`S(0o8QYfJzr~pjrN^Z@{37z})8T6&^IPn0k zSY_DtGn({x=nQ+~{tDBIM$xXJT_}@~anZ_?;2*s3KiFPmtRz-E@V$@m5yhksj)m{1(4B{YYkJ>2BrC2Hy6W;fnkfLdnDkV)bj zg0SS&2_bg2-@wDcJ#Im{#3-qReZnS+txZwJ#cZB@7#+Y8?jokWHSU`;y*nd5`{tL_ zZ{`X-^$i2q2}%_clZRi=R$t@V zB2TaqN)$=6IiR@<`J;Qg;))A2->0|F>-*(6d)Ypm6m4%Pwj@i(hE=>q{Z6OM4pb5t znJEPCU%kP#Qrr{2O7m56#du|7!Sjg%3f^a7^UIk5Q1pYv#7$Xec^zAmU6;} zJTji+9?P)i&F0+Bbx&ldEgowJ^jPt~70o<`KyD2I>o4(u^_e2UFZ%pqz-KfD=rscQ zrIQPpV0(G^WP|ZIlAW-awh~SRZ$b6fFZ0NMTJHa< z%lccBz6sFbQRlyYdkWz5{1T?`8jj~|4D4s~I8TM;iS-dCuLH_}t zEK-1HoI3SLSsS$p5Aw}PU27ZcJb-_2hoP)n=DArP z){oi$m3g@msPEkT{!~{Sz_WS!S6=3@OnTY$Y!T+$4Kc%S5RY?w*`GC46dxzF1YFdjeR5-oYLVFbd}ZzysooSxQwUJ$AI{YUTK;ke~$)X_y6Fxeq3TIC%0eez^`mN-ZfAhA^pN8UYgj_C_{J+Xw`@5( ztFwN_Hy9m%TbIuIahGc%*O;GMA5e{tV-_vV_Q~?xg1i^$=Xe99pT(D?Ot#53Xk<)A zJ4O(vk0p15Y}>Ff026lgY`B4GX5vL75uMUe6aTLztHz|t_T0?T>pdT!~dO~2mdp6 z{>P2~-{SxC@E-8LD)4-YJMh0K?tQ{j;Q#Hg`}e@}DenER^Zy=rKE)mQUvldF-TLwW zUvcjO*3_9Te(yIok`QnwBq2b65JC(WH6U7SZJUH}M+C%(iZmP1(SjCGtF`pxLLd-h z%T0_OX9^Up&Y%LKVh4Kw0l`ZLDJq?zJsZ4~cI+Hq{gD4(Mtsx%DB}2EbUaV}-v6xq|3%01 z)bE)7)dAOkk-p2@^e>M0{-OC`)4wd-{0GPL)bIW4+W!Z~^VIK{{-x3OKT6-wZu(aS z`#&`At-N3r1&XIGn{K>qDC)_*+?{y+*K77hhL8VxwnKj5#G{9t9rw~DF+}fNX|2s^ zk0#z$ecaGb_j{pSvPp8=ys~X+-FV0O17D;ld{neIG}DUu2B_hJ3tX8Du1`;9yMOq* z&7!Dlc~N2EckdpKsO$~$|Csj4%fAo3z0*GJ^!u*)8>aue=0pF#YCrS;uKoY6c@Bn& zFwv(~bWz*a|9Dg5Qy02X*Hq^+>tw;rq+XFRRiNW!Y7mpHa+y83zji9( zwFcvLr)OH7Vkm7#PFD3AyK{o}?5{swnl1gLpX4oe&4#(;cFs*lWKnk)%eSept4e;V zw{=aJWA?}h;h4SQ$S=2*4*L-Pxtb53Kyb=~|F z|Kh3o%Rb*av48&iHz)S!-#zs0EgD~Yct@q!RMXRC7PC6a?6i}-mV?Tsk`R}?2JbW* zucRBDF~qS=`=8;-=$2>xW6wtC622r}OP@|x5BnvWVmnqu*L5R}qPQ6!;S?Y481@P7 zh6^@n(*;mIrWK_4&v%*AB6Pjvz%qlR(fiqFY&r3P{zKY?jar4$E@J;p7LWT~KuPls zc8^!m?47MqA8-rD9G$c_aGGU8B1=*s=e237JX#8 z#wed_(b9d?GM1lE@-FhAX|1$?V!dG0XPc{;6%g#IO_XJrjaCj_3nIyMsRuN=wVm+` z4Lu<|@5+(d1ZjWl?ZkA$ge-}-Aaip9p2c30EIh$Z91OI7QhJHzU~f(dJD^g!*1On8 zlf$fMW-dn_%qgqM4^mv$FiphH=JdE@icVh4mBSX6AV6Qspo>eIwGv@0+0Q!8Q{Vx` zyoq6MrmHzIBBJ~HFt?ZiHgUWSBy-%Qrg0aHL<7tneAPVdU}?PAz^F^WwG6%F#B%V z({J_|xj&6Dk2yOnOlh2XkiQ~VHXL;BQNcb=aXZEg$Z%oR3nID+Pobg>U{spAM39U* zEnv2=Zphm((GicwfIRSLfJ3Y;1en37p?F>mlSshjs(VfA?WLV8P|nOvlYgG-=)ruj z(CWw&?VzXaoST=4RH=m}A)>Na5ut^({rqzCbd9ews+19&TxcF2Aj<^PTes=$A<2b;9m^gg|q5Dt`{5he7mQ11hY$DxsP9*S60|x;TffG z0YaczY?7;PI`ZvA>l&p!@Mv}Z$?m2axg-2*M}84>q=G~3b)%8*EFZfRPP8!{;qOVK zX+B2VfKBjDRF)&iPyMhje2dxY5WK9X=>m!wR_mhUa1EM&aCaRmpso!pxVQsEqhNF; z`j6kQWvUIy*LC9P$&z?+`lgVJ5l|}6AN2m5U7yAaTyi>|=cs8uCmd>Pz%jHXA(yGT4PQOwmRI$uyH`ie-;*J#OeDDorBoWNiYzV}x%@(z1O9 zz1#cAL~;Jw>oiYCcol~d$}Pga=^FSRd8^Ly`6i+%nZ+dK{A>C zpeCAPtE(e1B5X=$g1g02dfG~fCUY_-Q!zNmw}}irCGqpLb8|X;;UI1+r23Qi^QfUZ z(RpTweX%1PTDMoB!UP zE{^2%)$Clq_YN1NuG?E4tqaOz#NZ}SRDB|MD_BiLD1(-uaCaz02jyzoW;_GxNBDX# zLo)Q~GFxMUIHp^}EPM`VJjbPq3D5rk6BeE7t$#`bFsV=V^aG6a-c1nA%q=B>-SZ9l z`5vo>YSW&snxM+-0CUtdo0Yh3@73|7(BkfCJt&H%<8Fl;6h?$FbTrNr(bR$wWloJJ zDn1+G56TP^>Udl`2EAL;-Enc5wimaFnA*^4=_jLgLjnI&7}I~D`Pajk{(I5{g~#TTKG1a_78ulaHI`|w+X4PkgEJPmHsf5 z{^++kdq2P1lyBz@GkuHL_nIOPf;U}%vZu1f==l6hl^ndh!&vU@XPZKa!=)_0V-R>m zloxh*1aE9^(X+NVwTZ0q=IJ2`eOUf!{cn!0{JLESuee&8bGMg%R8Lyq=i>A@4iamQdFvi%=myL$ym2@V`n`ZA` z_IDXu*F-u@oO_P-MXItoO=*dz(641RE zDbPg(qwH|48^;vd2Rc1Di->m@1NKcx?jP3 z_Ksh65Pt7-+SS*rrPD9~uFd8|9?YvZu_N^*{Y+o`h4ECmqo>tZd9tUK;~TWa%nc@l ztMvSoJsQYW)Q^O@dWM*hd&9t|=HdO9!xDrQHa?*?JDHbqjs4LhvK?2MuPZ_fNPi_RGEKNplG19PeygT}cZF<3g=Kd%nDap|N=d^iU z30drz|txOA|!@nc^ESmJNw7y?QXWl@n=Stz+1S*jx)%(9J#iLY#0MILE!>|~S+UK~m}nB=x~o~qf$y{BDM|f#Yv`gmiwE3YObUiexkF=xVxlH~@&TOm|(;VJB3mx*L>RY2p>!*0xpfDl&~aAbduN0C8xM<@POUw3?d%Jh(mWzzn!3eT^eUzLFG`n+H$VtaUNbv`AT^>Bqk zeNBFzYQT|y(pX=uEXtc{V($%~5i1M#Jm{knwU$&#L3Mu;Yega1$9mm7oggp?Wg%69 ze5pmxIv0l^@Q>?HR9I|gD;s0T$22|c!U2zMQ{Zao+UYBrKfv9|vv{5qj?Nz_6wm!hIi=t0c`ITuo+xrGtRp z-3){Cw&qM-KyNSKmT7QKREP#A@Ig;QtPkUrGDclIKJz6l9(SHmC#O4lDgoxi^~?ST zdmcLECkmnUQ>vS(^1bJe9Axj^)Kz85Pg;(1CB*&C!JwZxAB@gF277*cXDyc{`EYYJ zzt_vVl2ocVeu|=as_crt%7vvUsOXm4(`?kSw=t}`2vx0lK=WB{-H_Dayu*_Yj;ZO= zRed|-W39D{Aun~^oh;sG4?5_JAjoDb?VPh!#v=BsE;$un=btjEj;@IW4JH>se(I}C zRnw}12({ocKULbP51x1bdJ^kB=iDe?dcQ^Qm-MJ~a6Wz61BS2|t_|^^QesXB&z2^$ zG=rzcELN#=Td@G)#I1=zifOk$`_PB ztO-w5gBIyw2Ur2cQ#u`$w2ygmLSabYW^@xe4h#v&5%d;P9}tw1Rk6%4m^0+1xDb8O z^OZRbGi=;>N5}gta6o6mt71PI$bq;K6csLa$@hkXqO5^OVO&-n9r52w(Lmsz0yO=M zCp0bUE`9Jj>VnJsq5}^>G>=+rZW_V1?(3ey;wcFH&%sUI10auPP%u_*4^If9;oTY3 ze9`*=21{&-U9@Yiq(ZvfGzZzCN565%>jh`whu*tx7>5!a7py5tI|1FsVFZ}%7p$X% z8|F}9eM-@(!unAn&78&ZAyjC1zES;v<~tEoXvw2j6QflfdSGfHf?kK~WPxAdron}VT;^5p(8`Gc@6~hIB}Ep92LJO`Mm*kj zrj{8Cfj_`deRwa?DpBsOF^@(*kxbyLxkaa>O^_1JmU4Xvbr&NvP4yr}hU!(iJ! z&%tK8O|)b}n^63UCNu$m?6vr+_uU62bgf)P1^$$p97YP}3`G0(@TwZ)b_&Kpf`Ccc`V>epfuIKG1!A6`)BGL7x?4{64j+zg;$XLQ5( zw2dJc(VV?NfTua;lg_&~nHevQ%^~RW-#BA0^8TW&$Q1udA6-!_uZHZ<4Pyd5aeqv6 zp$uqRSIW$_Z0LH#-9?DdHSt^#SYo_4HMSZ~7Xg><43X z+=^#2O`5+6E_uJy5A#yNhd$D!pQtIB1eh5EbSJq^>3Raw1d*`n>r_*bT>7f3VACu= zHP|g0=W;Aj^$wVc;EhKQIQ{`7O7sgg^BqCw;!1S@v(poT8ob&+j4`7Rpp1s|0W~~r zW7JP9FkG(%Ptz0P^i)?elB6~)KEZVD8)K#L+d!Hguijdi0BWLO0*{VF?j^?8DP1Ot z=2W#AOc*9TD45AeqfG!K0@a_k+(!Td;-SA`8(>FBZC#hbjZqJ31U*oot)U{U0F%n4 z0i$8eqy=Px(Xc;YL%i3c$$IqXf@jbUEJRq_^PYO5!f_J_#J*QDj?JkYXp9WI|l_$wKbJx zjDnHe->Dtu6RJ8C(erp9F`$?|!IIGr$?$-XsqtZrTQnJuA;h9-wX}t|V7^psC}!}a z(HA=D-b*Jl*&D|un<`A6e_c_Zi(~)RSdrxD+2xi^b-~H@>lDn{DiBTch`%*#rm4sw zJzKS2TGBT{Cz{K^x9I2k_* zxLKGA8i_oRM=7djxU#@e(~&5*t{tc>Dk{vbi5ybxUAcq>AqkGcykM z!9txaEU@`5Q$W=ACrJVo6~lfTn=M+&Jg3DHGbCJPp^I(k!1BT!USt@HwR?`Y{mJSX!jDPP25g$R7(l5IMt5u6A8Xut9=X0UfY+ZDveWlcrKYhtF&Eyh4Fz*f5}JQtADX7dCkcZh^{D_$PGC&{z_MO;viuUZ|$QM z<-jc2_RlwQd?l)lgV98sI#F_2bumfeU7V@)2}s7gmq`>KnQZjLJ!6*Wl)u!232Vly z7MugC2?%Z;t6BuXr&R;}$&-7b7BeqmPve*78Sc>1=dmk=3~Ba6h_EPNnKB{yEA)7u zsQbw6tGi&<&`#;;R%A{0IKW76rh@a^l@hj{UsuCE#6EgZo?NYc^BF=vHpuc7Hs}Q= z>CQfFv@o|s%M>-=O^~dR-nK|CXtKf8JeK1DA1v?GDKNctA1C8+0ZoyixB)P2V8ZO7 zxvBtzOwSg*KZe^pcg$rn!!$R96~d4GR7tqU`_Yw2r&O(dVmp=X7xf%r`_|-iRT)pR zhapM80mh{OhnS!;y=z!Xz-@c@!abQoFo+HOTa`?MN30Yj;@qwVJM~Ma744 zcEAU1{1lmry>lPugGU7e9vO|&jn(~F47pQ{1iWLL(qn2LwY zmFrD4d1a%4?Hyf-VW(6u1wgqP_TH^Cm;HTma8OkGSU>a1PIEe4vU8&?#<{1I7ZaGC z$?!{%EJiW&F|69B`0+y&aIT{cQUl1OIJ9Lja)0osenNAhtu--we5w|Ln)|JuqU94I z9-_5(G?huS&YTM2r<|-y-V@U%(weya{0%1GJ#-)JnYkQzpxrVWcB-)Tpz%aE+ycO%qb?z2?fp5cDYh)VvqJh!Kc>h3zz9iFyNubR zVK7?WB#PL|+^`yBeM+R`LVeBp6SHBtMPM~ea_OEvi!Wi*Ci*1nWhiE7uJcm#dr{zo zNA~bx-9+E4Wu$yEz)}=HdZ<-wYkx54a~zU{MF6AYMn0mAYA%>Hw1vAEd`Oy1y|wYY z=lWn?Q_FxkZ>>;%$;)|06OZ=-49^}oJml`&1T~!%mIp(%(Q%>71u%CFMfNk0GX&IX zhvyS2%dljlETlg=QOf5sjpb#-EHS(gw8A-Up+AA4EO;9>o|Y}+*{*Oh;vg(v8CPcl zJ()GSMeY?F0|VAa))scc{3?TQ^Md(!hV_gDWlu7q3!@fj$tDfbA`736M6RVQ!QCS>xUNbBVc&oem=Vwe&qEU$4 zG*?VMfCI7t9G^&b4msgWbkl2Ec za}}0N`dcA-c(QKd&YxT=7%*DQoeU!}J3UGdg=HIwH`&h+G&}o7B);YcHQ_{5^zgb2 z(|0sNkj)he<_gyh6Aq@MNwJJ2ekU4e_0Mqy=a8n zCfzVQK{8b54yPnTvw&pW2nMQ6_p%l|9rT2yia~FfveM)2ObX`AQOI*4D=4sEkXz+@ zYvxjw^6F-ZvPjyL@+3((x~IUAN3=7!5>w>@NTU3(vo|F_Z=?}q3Z}=b&io!`&>Lpq-QuxHwG1CUzJcNxr-)A0n5nRwihnQ2X-Y@; z*O!T>FjU48Xo7gJNHlEQK(w!R1+~tWrmB+q9wx9l%3JJ$A?(%5zG;oE`UOA0m>_}r zjt0i3MQ)mmw&)*de$bO%q{RhKX+Ct*oN*^}RLI%ura5YwB;?&nowBHx$`Ythcz(fcr!f^ikRc1L?9pTH75Rqr1;Aoh7<_N+v5=1!I)TrigHQf=)oaD@Id%#{Tc z%%YVP4DHB2smTPLib(LJ>==IS6^!WGPrw`k7&gER5YV##M)kFW{o0t^vx2~ssq1!c zn>jfD=r`y}_-mh>5&IZNx;VzlYiGpsX>KPBo<{hcnmpZodr=())1$FKcQR4UJ?T%> zf}a*vuNP9H5xcEd`v}%1Dak~FWKxPur>Y^|gCxPlK5_o$?}$UHqq#*V!eR6C>W4Rj z30K`E-&YoN-CVcl8{`>uxUWkbls1?xncw~RK|;_!(Le^1!8R4@p5V~6A(`23rTnmh z4o_`rjasC~VX$~=)~7B&k8#sDYDhrnRa)IJaID!?1db@T=%|Z!Wj$f8zq#)89ZQnm zrZF1Vr@K14D!c`<@u;E&hSYUWU?As)A60N+L;kY%5PTtxEKe&r~z!zyu+qh{=FxOsC^P0@)9H~p0djn+{L27cx-x38K`Ebn87 zrbTsHdJDxb=1gj!_#rvE!u#Mw_4a79W ziSu7O*0)DB*A&&vF44<3?+Uf>O@wVaLE^m!&+zbyHQJcR?oTK9%xnqG5KQhNz1D5D znW=drL9z_|9!8TXmusyQ4Q-+Fa0#WS=uE|j5IuMsrpy<;HM#v2!da-tP*?B-dM|c= z7SQDK2c`Preh1B7Bb)SZhxLQ74IQw`aY!|OIqYo{yThEn0Y`m-zi-@;zLb!aqL>@G z?~0fGUa_%h>5AA=r^nri+wb4bex6Hd%EFt#4Yk1}+Ls+}yR^!I{7m6A)b(~_oT*tMzuw+FhuQ5@v?HI;n zhqhsC{>wI~Ac~qKb+{q!ql>)X%<2-npDzr#xbboE9m%h_c0%J2-JJw6E~vc@gROd0=}Wxf+0IceGP4(bi#}>V>C~;MuYOA zIx5$5vD!ZTcZKx+T%vqc*7IBTul8o#Y6OlO8smy(WMP9>+*I-6l?HtqL4*2>Hu(V1 z^s!_sDPuO9Ylk@zlQbIka$6^jv%H&HS{WPzA2$V?I2b3hwVBZ2KUo07!e_}4u($7w z*8AF5Tg|9ORtvvBf|p*R9Z<INs1*pj(o*`?}t4Ka6Bt9Z|h zL@j=E$rV%ck@VhCQq zy~-w<v@_6$=L%^j+r!(gd!(?s~E-KQiPJCm62$ttvrz(*F3b*;igtw%vg-=!dNY`69;K7wsI1t z*$OzyU(U4*ZXoXPQ~9S$NaQElGqc9y7g(Z&>22d*7F>diH|j?)3MLzj<~x{|;iiyJ z_2hRAbWGI-B>RPa1FdFX5 zZJ?&Dl+kG1Fz$wXKv!!S*Dt2IVU|-dpUJ%oAQC9`Qo6$Ir7#iFUfoukMZW@0j=P5SU(I(Ca1BdFg!*zH$<_?6D+va zZ8H=Mjrjz|-P#Z7lYwEnPSd#$mVPf9s#+CbplHNoKQWyOH0DvDIa?LxnmqzjYUj@k zN4Azv-?9?lXs-lqmiBu{0%8ji1$&kaBuiSNO1Y|XVO46y6VbS79@TSY5kJrzLrm^%dwyxj{`J49;r!%^e+Fq{xqts5rM->oOp%Ri?1 zF$~2K=!U8M0S0EHVRrFo_4-q@P)c*Kx&-DUAoqn}qP3dsQnel|=y(H3@(CyH3i}sk zSiYQxv3^dsEjxB8DE;t&J~Xv&{F3#17;cEm1T+|kM(CzFODXC*(fk+&O3rK|H_ZD~ zIPA_ZPu4tz$%mP~>h&kPUv=d}OxzS{C#*2PXN0MNU+#G*&L`r!a>L*MMa;lDW7K#G z%fb|24k~)hniTX#)aJ`3>(pmIp3p!&ocCgIHR;qb z@w0beu>8$;G;TcsjTIJ`yQ%uXGe-|b!s02=6av!bsE39M;v3VAly&d6-AxI7Z%TA! zKK|^;;QakyPDbNYrHAbY8aE8pp`PmLr(h_51A1cc+<@u?kjhmz%o(WEgfy1}m0nO( z3NSDq(Q`C85|&qLOl&CRtYN=${h=YB_$KVux_3bqFTFRr`Bu2+w`Xn&D*tl#*7~DS z_fmce=BcYWR`*mg&lP^Jr>8K3FdqTOq!f&Ma>Nd8LXyx_lP`yoYUN2&4I2C_?^$%WPzGqm(o)DS7&bzk5iWx%lDd9tL` zgZufCgcOc;ts8jTkMiq7eu%;R;ZlJ*0B|}eJm`~+(8Ry{1|6=8*--y zJeN(VN4$33C!N`q-Ns6($=*A7-8=PsM@7JaE%j`u@QJq3OuIW;hj30#SEVvfdXz)k zy#?k(^;4adVUpOs{un}S^jhX6?DvYbxQ zl|O~@2CF#RsL|j!)VcD35a-%$k7((JxX_E9l_nBr;5m;nlb?NM0#d1ecQP)@3`6{i z*xgahm6&}izYoK<5PcYvSJx-@+}Ww2#j84){CEhqd|!So{>?bTwtD4@Q<}`IWw)x| zy|(mb$>NM9#I|?EOE+)Y;l)r*l|fC-RBM$_rmGezTzL#D#{xGK<$#`2A{2~qQ=%fL zZYN!ANY*9r-p8Pfe_?X}M97Y-MyB+lwCopNzsst-xXcrSA9}Vk)hjsE)HBV4Z8T#X zo<@T)Wad5^o1wmj|9$NC3IkDlWS&N}dgsdA!oNTu{`a`XYfHuZcdb}P>>v8=i^WhF z2i5as`#AJN#xz)Vhm6H^yzPNSqtIw|GF7)FO|d5W1}(%eITkNge}y%8?TEX0U9IUa zWJ*6T>7J0)qiDL8EvYpl`-+QiE@uky-(n9K6w%<0zF4{fha2TdGObwQCp)gC5_dNK zHlYA9#$NPZ)-A~WbF#wl;-BZNzq^a2`Y z;T3{Ibg45H(OF>$YC{fxoR@wcw|lPUWe;Vazn@*tlJ;1 z@EE-KVeB8Jnic5Fo{Qc~KEjO}8l#c)Mg@0IuY7?hpTiR#C)?giNc5|GEB@=Y!g*`I zt{$3a$`uhd!HZj`>V@p$X7d%UX=@`k;#1pP>)==ztpjXX2cN@+^5jgJ`r5QkWFRvW z<}G?KRw1?VbQOuZe*BUwwqJk4%dpJHreG?0d5ItMMrvj=l+qL* zE4{*%Ljt7;X3Xs4P(`2EafYB~K^+OThN&0$6$-x4oA$c_kR;(cLf>>wCn+7>g$kw~c4K z@R!23^u7t3SH9qrC9HiO`}lPI3tuOfQ^y3;`LMGBMSB48ElFXF9kYXv3PY(G&5|8v~kE=bAfS@ck|)jxzWz!K8WK>42RSe;`5{m@vNZ29a%+B`#H9bFXDTg!w6 z>&^;og&wBzJy2MM=(FbAIyI|f@qT1SE_@r@ByRtN&@5lP zdoUiOh4DIuXk0MgE??L}U?${uBPe zWM(!ru|rIJJ{&znf8>IjyJ47_iMUyN&+ob*re)KmJjomrUY%ZRspbPQ29F zoPlRh`CU;DTuQ;iVj!s|O@Q>#&+5xw$bmW#zRli7mOEc{zORfoYE9 z4Rz;e`1=bCiNQ`6;?Cc5wd`^fRS^I%VVMAfC9}hz(lYx(T+4t*WE)6kzDtQ&7cHRH zzO+-=CGWL`e1>%4AYU)k-iP$}KbialJQjf&HN108&3()kodw0g^?VHkoP4}2U+~vb!j2W&K!^>~O%i)9xt&V*Z4l$ECs1;!zh+*!42Ooy53m!Oh9oihn|AK+hO=vCvURIb- zA}?nTldd0tk45sydCRbn>x1oIYhg@voVGQd+ zTQD-@LaYbSBA}(m!wwS(7=C9_G`fx*4M{*8HAn-#3mr~*#KsmTt1)2*JmK6#YN3-l z`YXgl(dsrf?A9}3(-BSewnuEyBgQ`{?S00v@0Hd^o!_K#zaPFO4WIL(-b;O;TbE-f zhKn&^dT$<&$=6JwV4!7UcMwzY4g8ib62SkH$a^7tAq!u?!htr#p#-PxKpf^_9wkDiw@AkH#Xdy?La+xWVk0^BE2KtQ*jeNw zk+s1wjt1daGxB4DzG65SgBikHf~44dBxPnxDNErq%%7rY?3aMojW)sseN1fWTNsiv z_#dLB*h4t{CQ5sbA;19-A|`*F};!UFDb zFtje#a`t;+e1TIUjT0?*yD#Vj%iQiuTKv>^QRYFX#P(!E38w@TF+}DCBIkm1(HK*N z381rxA3e`*P>&gSxGzR>+xMP>M{`5nSRMm*u+tC6CJ>JgFa7zYxOd^v+;Dw7j{!Sa zL-a@P9rNk>kKi1EFJ^S+Y0knU)=+vQj{!U8)8jYbB_DX{HT>Hac*GhyZ{;yy$K1f5 ze2L~mJ6$#jX9%GIFE7>^cr-U;{VtY)G;?lQzEEfcuIu)lVFC%cYg^D1`Q%rLjqbkqD89Mj|?i7$jnnhy}&- zB!ZVXB;u0DlSDicd6CGQMDs`!!b!v@k$^-#BodNHL?SVXd`TpMc`Oq7lL$%)r6dX@ zQ4ooONhBjt2#Mq*3MG+(L}4USl4w4OR3r)~Q3Q!1Nfbq*1teNXqD3T9lPH=*F(g_{ zqF53wA<80V)|ultiLr z5~YwRl|*SIN+;0@5@nERC5bXgltrR!60IUp4vAKi$UvesB+4bxS`w`z(RvcW1q2&N zlux3UNMs}tL81Z@Z6r~liA0-7w3$RdBheNTZ6(pmBq}0NF^RU3Xgi5sArZ{zm6E88 zL_0{dlSI2n^eTyVlV}f#UL(<761`5MauU5kqJM)gL?SbZEF`j$sDeZ`5>=9@ibQr2 zRgL5|4 z8FfLoI_W9u@%*v1y#PigYpBdsh$wS=YItsQ`=_IBbGyZSbn|<2;}}m@e|njgA=`xD zvsI!fs>}Xh#zw|xcOu-M`}DKipZiZo-JdH=f7-n{eGRLov$-FLMLp<=OFcCmJkNd3 zJD!@ShTib|>2#lkaH$1OgNFa>x&BYj)qmG1XewG9Jr0XQaSacqb@Kw*3>oENG$Os* z!@$SScfUne{uF^Gp-%qpO+R{UD4yx`bPOVqmHPxub^9q=WkJW&=1#A^y_pvL%jF1L z^DR<8`CEt7(BMD3QQ&U$hc_aosbRzIAf~KZ>sWUB_1hYn^rt?hl`blCb3@xNxIf}L zHIiUdSjWs#Urzb;g}6U>StV_*iu!f!=RMv%-2J?+yfdtb5O zbP213*j`BNko@&@^A{5OJYwh0vaJQXc5d1se^&PT8x3c^j7EzaKKn9;-R`x>{jVY9 zk1wM12I7wjulN-0Zmm|Qg@4JfoUZ?4D8naFU6`C&vUa;cy)Jjf)|Fdz&unh!c&I~r z8~*fA9j*Hrb*gSl!M5xz8>zQu!Sj{WCcX$Z+FWX*r#9LIYLnIA{FBT??IkVQQm|$< zwWlq}1df=OVG{?>5)$2KxEY{%ADw##A`FH|p7`{hYt RJpIuh_E#Q;Woomd|6c@ll==Vw delta 42893 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 DEvkJE diff --git a/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_with_big_radius/solution_gapsdf.parquet b/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_with_big_radius/solution_gapsdf.parquet index b802b21c2dcb47ae3aaff147e76a35200d4f6209..5ec3fd78d9e25432e7d1c5b457133ef97824b8da 100644 GIT binary patch delta 410 zcmew$xkzfmOGd_{n_n>|GxH~wloVwqm6oIyD_JR2*G~3gHDolNT*GR_xqwd}1PmP~ zZ)de*3MilaomGV=)Un*r(a8}Ad_y;@v$eAbyBVZ-cojtEg&9Oe_-6WNI+>?UUdiRe zST*?#m%b%XVI&Ycl}Cn~W=BT&LisKRK!#%}i0}j&=;)LTVuEBH9Ropx!DJmCc`;WI zGbz`tBnm9(m<@7J#N*y_fW@>0HBOX2#~OtT+Jgboeq?C0$Lc9 zlj5EO)$U{h(q0VGE(X>Pl5{Ib^6+)c2Whv=n%vGUpB4(zS{xbfm6HTyIr?US3=MR2 z3UqV^TIuMR?C1g#2L=Mzc_4P6E7*M=AU4GPU=~n|qoYSuMv_NZ4oCo^1>%*-Yj~7^ S0ezlFl4B1K14DpgkRbq50Czk9 delta 413 zcmZ1^^+9sOOGd`cn_n>|Gf$3Z)nhcA+{bFf*}$g{0#)ggPqNxE`KM3jWK-dBbS!st zbaDg&AK%TEZ0#(QZ*bW%R!kP-)~|O52`9U|l}5R{RRNh`z9mS|Cml#Q`+}GeAi@Pi zIJ$xezW|^eKr%#PC1UQAcfklKtdlR;TQxYfo=p5U{8P;j*gxn0_bRisED8fm_?}| zi_BdnNAk#vflbW@%4Q^nS5!HAyMPRgoLt8(Z{P;fo8<1685Iulg^MRh3g{@HYttQp z?(zVK7|?rA3n#zjmKFnf#mOi#yuvUF>{GC$>*RYpO2DvZ+a~obSJ! zbG{eUU)87H42_OH{pM$Mp2jZ+nc%1R>)`pAbz>k9C^f4at(w!B)HiL%9|29J>en`g z(-(y^W5kY^Let^!NMHm%5Gnx%ek*h`_$>Y=^zpez^B2mKe$AUqU%9g3je>W^_F8_; z_9l^U)x6rKdwArLiOIC>HofZ2!6VU{amVC6n_lqA5&T_W9ME=;gntX}Wi~nR6gZRnNNrL+R(88g|1|lE!$9_H%d*hkmF`^tB zUON2k4Uz8$V^565{=5l)($5n0XL}2_}bf8)F%xWw8{BHEn zJAFs0zQ1AXn|`fz+w$Z?SMlc~Y%snXy78y}Te;h=UJXQVK*eIdvfMJPnp=%N%+8UI zt>e(qyC+ihdk{hy7zov2rN|hrW1C9RF*@RcxZ?A8ZYmj+@W#~K4nj#1VkK$mZqYITElCii_6kC& z0$D4_Kq!qsMj0ZPV9|?^tj|J35R%CvRe~c3bV(cEFduSzCz&=Sv&}Sy++VQ&qf=Q^t1}^Yf7jg*=V~M&EMOvI)o14oX$Vec? z);hU{;YoJhPA=~LaC9Mvr%olp+j2w0zt8Ytxhu8tiBpT9SbsS8QCV%MB@>PiMMvhu}Gzrd#fS@0#XrTjom9(Ltw?TaXqL`P6(zo>?_I zQbS_phRhRSwIVl6C1C40Ut+W5ZP<2wzhBYSMmi{rNpx7dXA7_H2AYl_Th z6^6D{^ki1`iU~uwL=m*gcB`r+yE^BrtCcoQUsbFoy^L9ZQFIfQUg`N3x$>4Kl$mNp zs_R0QsY1~j;E>Gl?3uBspB-S*n}sXo}WUmH*II1%6YsNehcj!!0*V!P(Mnis-0N zE51B_%MNQ2P$;*d$CD1}zPN5dTbu=*OE6GsUFCS+04S}JU_^m;#k8CeSLB2dhm$DH z@3jI7**jr9o@;5TJ-wUTs_oqZxDl)=SZmj}ByZTr&#xlVjkpFmDctt;wL$gFc0Nt* zn*hvIvwVxxxK}$|f%P`&t5(T&O|U`I-hRK~VWh6ettl)IFN(ZGR4C_4 ztE{o0DHfc1qFb%-n|YUYOd-1t5iH_E9&DPDRvpn-Nol1=VxaGF7MJZ9E+4<(nHy^F z(bEV2e|&jA0KFz;EAoMM=GX>IOCV*klxHZ9?Xf2HBDp5JDrar!9I%Vr1^-!uPtBe< zPBU1|$W=-?d))dUL2cuyv$yQfjD>6q&YdwUMTg?$7WV@pj=F-jAW`BW{7j$@KEh@K zoJTz^z$VI(g!sTuq#W2r67hw^_ctZcHr-7)lsO;W8bB+(+QojnnZ R%?|!$B*>^$-8#E>&M2# z?}*Cfp@PJrz@Q!ws*pfEfP{psBGevu9FUMWMil}9MZg6K&RnW8Gj>rV812r?_s{o# z|35ST{2^VJ-uMKN=7l%!e1ryYgY=MJgYU?-#o!5oAX1IOezR2T+vT=aYmwfEvV~S@ z-x{=po|+*Sd|=hEXe8q%uxm(x+lB@58u;1p&Bb7RD`Rh!bbD}bZ(n~H>Imd>zLKWf zxt3beOYQm;Vf}s19@Od$z3?b`a=N5A?VD}#RgxmXZ$|HMmim*#`JA0NgyWEU%SgWq zJ~PjQAIxqB0VDIFiTS`dXQS4rn`<^I{_WBlO+0x7LFyAAZ+V3JX@dCzG%UW!TNM2Y z#ay0b-T@z57QvxqY3j-Z{gsJ1Jjq<20whI)XROPUcaG8Do0-39<}TQ?vfzEIZ}N(n z{>#W*JI&kzzgSO!d)71b+YWk}vC!|$5wjO;6ODpiEHrJe5827vMsWYQlk^OoCw?_O z=6U?mCBk-$OD5ylm8PPW>IK_fw+l5l2b|NZ20j7w=~*&5d~N!snGv+zoSVa~3;R&9 zCU-e8u8y3f1i<@++* zcwS)x?JYf4DTFdDDdopl)VgI+Px_TmT8nF(mV`;>y%ALjsSU{;OQri_Q(TX-%Wtr$-CIbUoYFTiU^dgM%?ChM`9oQ*W|DRBvW?qKcwQ4`W=!MwG6ZnT-K7kuep zt(kB{g`SNfxa~SHa7UbH;I>f(wa69CW7tYMF_m__VG52|AiQ*D z%mw3M7nTOTH(?`{t_E`Pdd61_W!ef`>&TTxE1yfM`Ot=2!X@s6-&DCVU;P!7?NW!BP1kg;6DrywDDLRSqW` z#c-yY$0x8}LIKeN7efL^{V@Scjr`H0;hQq+<9Z!P#R;P532q%9y21sIBPKk*kr(*! u?5Q(2*-%d{Vi9S~#peJm0=V(?8HdN|cDiR@m?Mb)e#A=-g4pJgo__#$@W&(o diff --git a/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_with_big_radius/solution_modeldatadf.parquet b/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_with_big_radius/solution_modeldatadf.parquet index 5b68f8b54715fd0a3791a47c986ae5deb0188fec..57dae89016fefcb2a27cac6e6fab1d908dabc309 100644 GIT binary patch delta 619 zcmah_%TB^T6fGDrQACZ2(ZmNaZlx8Xpb3jR*cM1oXrfS7Qb4H`sBHy&1>L*r#)ThY z!dJL+=fV&0H=H}5A-b^X+?+de&Yd&oedS^GCFS+J_S4?av6+(NSh{xURPFV>;LV*I z@@;t{?ujov`83Pp*Ey5jXJ6c#0{6qW;a3R&-~zGUpPGn{&r3ojnKsqtu}~~z^h2FQ zPu;kmc&^=hKi{K76%`#dzg<@g8H&qvfekr;7$V$)1giyv`&d9^HH!u)%88T>AhS&d z8Dc)9t-8B}G}HwiWYx zf`T_ju16+NQUaMnM+^$I5=RRwhL$YW$_=BYHo68LRZze4OHc3#qxw2+d&2=g_7i7R z%7_1ZGR~3}qasVDmdwBv^1A>M0DX{3gB}D*^ZU+;fP(iAa%YiYr!ft6+>q?bAU~!V z5aMxeF=&>oi_5CBSvJkhl4Z3z!FJWM^;R<&j)a7euyni3aesgAF~D(IP`17R2_LH| delta 523 zcmZpayDq!IlZkQrW-q4a%#)qj^cW2%7qJ;}H3;Z~LAu-Ib!@gw?&Xu;vZ?U6IypKz zg0Q1Y@@83fbJoc@JhqG#lUMQR+qi>-lil4)qukx9fJ_e%-^bC_(J=tTasd&JSs=o# z97KW%uvv~SlYM#R>jQy;PNk9IhB+W<#{d^?eSM33kSjnEjzM72NDv9p1?GcoNiy=P zO7X}kj|y~*1gQ@V01_bc16@IeLY(3VW`Z?2gGl$Fj1>1UgG88p^{yd6U6vLg1Ax*n z3)4Yrku3BB@sq+Wv!lW*j3Ohv3L?Y3ih&jbZ2{@mE=JRz3{sDz-wVWt>W6zaALw8v zeap!{ysD|$Q4v8IiQyGhj=otyvn&D~odO+!E(V5gG9(m%rU6400&w^cs0HYtsEj0! fFmPx>v>-xY@;*K}U`*fTli^6>V_*ny3^D`&_X&|F diff --git a/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_with_big_radius/solution_outliersdf.parquet b/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_with_big_radius/solution_outliersdf.parquet index f468f3589fbad648b2c7b09156c2fbea6b734024..193bb52a5567704ed619d88102ab19a8aa070a06 100644 GIT binary patch literal 15077 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 literal 17691 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 YV3s+L?C>%;LLMS&LLm7sH&W^U1LtVs9RL6T diff --git a/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_with_safety_nets/solution_df.parquet b/tests/pkled_solutions/test_qc_solutions/testdemodata/test_buddy_check_with_safety_nets/solution_df.parquet index 53e6317a50ed94d94fca8c1e6315294f5afe5c05..c78c7e2c1e9f4e92a584a2468bebc4fdc3a8ad95 100644 GIT binary patch delta 39859 zcmc$`Xd}bi6yRM%NRKYCM6))03`60sqCo^2*EI$q12hyZ2I?aqeU$`PZcHB%frbbY$>4A)k-(wZchn2Zn4}7) z`wP;110&N~j{p)qn`p=ilcI?Y3JjGl_O_SJ<`+;{qH1X<5-Qc<-x2&oB}-kmiO=)m z4JzR!oMcpU9Ba6cwyEtpn!yYtSVW?5_c<|vNIeXRK#Pe1KSR*NM5|(k3C?IV>gg=& zs&Y9&t021j<7(W3N-8kxFBh3({BF`L9_bh7;pkzS-wFI6r6(k=pdlUyD2Y2n)iLWC zf_w@qv=cM)1aIx=DjzI8riU&ug*jX1tI5pa658&s$1!n+S4ylRA$E^@3>wlyAt8t` zU1I9IV}jsHi|JG8N1}0)q{~J}R}P0OlnSmPQD zk$(eC2I>)%VL`qLz|%tFZj=M{6=?d?%W9shd{W74$}6UQ2Jgi=P?$o3S+4O_jGTR~ z&Ou4Eg;i#oO$p5&+A%rG7lpH6Ooy83S96jAxSGO7Q7sDHb7$>U+hD@V{ePf- z?mHn_`%3wTM*+oV&DxO)j{XzMNe+Fo1;{T3E+=l`0(?F}Agbnu$b}~t!p9{ZM%)FJTYMxwpRQV;_li*q;*hAD~2byVF&>{T80e8 zkRefZ&jut{ho26m>)kZAJ})1>PZR_f(YPHbxV_#{s4_dbS0B@FBqVqn5q4WNJT?Z7 zPDs4ZkXD-N*E%?JwaG@OL{TW()3Uz6Z$C?{Sm3>;`R1}LfEUR91I(94533_HZE+4^ zj8LLIZ*anpKo@JCQHN0wSqfFNc?re&6oL31<5l2T(bDtjb3F*Lfoh4zG-?2*3ny{b z{46|{Q#4sFv^m*Z5_ zb_ve66f;!7BCi+JvF@)eu4|JZT~ouSN`}j8N=9e=?kS z`+7}zs^q>rOD+4-Ifu?D%jFbG4Y zLDi6?K~PJgLF#ug!S^aw{Is{;iWy*7Z0(h)S>Fh!RU7$~-yC|@UJ`C>edl=l4m0f! z7x%G5J@JMwqk5q`O_ed*W1udi!CcoduS3zFeL4AJ=GwlUN5A{(&-umY`-+eL4Sw?! z@&4DB(b|J_VFkL~0I0S_t(18X2p_CZ3ymvoB}v1kM7(rkJpZS!)#VKQH)pMPHVHRx zSb#U-Xa*+6@?FNUM%!OJxEeU@QqRv>Ai3cBB=x-{I>`%{@e|a$cah|ka@a&YkoPB& zU-XVQJ18}=pMA8cbQ!mB7K$FsTE0w>&q%41&>45pn;GJFp?7kH7;-Y$`NRf+k{}IR z9a0`*Q=e@G-_5Yik9W0rc5?Td;pg%25(k<|jx{rL4p)YG?1>{T7hEkfGUMPvLSqLM zc(bZ4JwxC0Dqfm$Dz?9(a|?`@@mJ?_9ve#SH$>M}?|kf=_mP0>4KpZm^JJ1^k=h$~_F2EMq_GkX&_odmchPrn% zbdl8j^M&`Wi*f6|g$1EG5X8P~NKO`OIEWnZLv{#Iq2&GvO9oA;aa|-IuTb?ic$CjR z2|8`Y-T59HAbC^)FXhE!E8Q#R)RsmTvx7A%dPp5E)nQ9`$5Vc;SE@r;mimYOBlH5( zAmuBj=%|0tuj4hO*}O`#L;kDxFY2xN(m&!=r(G!{v;Y#d*wf=~D7_rgOCf0-ib#gI z)iGqG!~sg?Go9;q-?Z92I<};OVQ*G%y=T^mb-7bzv>OgGL0p}!7{y!T%t)*Uqqesj zu&EE60>b=!bG%ZkIwQD8`w94OPojCHkNuj+>z-VHw*DvIgHFdD6uw(vt~vHwaQC2D zNMqPHjEzmS4*$WtFvf#6go3d_8P=UrLm)B~1q7D(oygr&Ly0;c9`=k!EpfNMkcsj= z?6?UZumNr*?aFw2qtF~jB{^FnPhhF6eA3YGlpO(etc=;gBpQ7n*JwjiOf{!LP~{(T za;oOlobN%0W6_#-3!0E)5fMkP|2EUGv5GNXfktR2lwmAJYZga>hhze7bvT(Xz%Xdy zItg!&Sqa}u`ApStDv#pBnM-NSZo6-6zvUuK8kyZdu#Ul7J#f0;mJmY4>hNJh{bgV| zm@DUO2NZPWY;Vmi_TLH}7u&^2FZI?)$syY=DMRA8U;e%5>mEZ1LzGV+)9pNzI*cx6 zE_sRy#dtE5)jd?X62p-4n}o40!Bu7hb#I4x;Urq&gL9wh)Dw^& z@GS&ce?FFMs|07R4!=x?X9iZ$c=G9VBJ*02lb0OT*KQ+JnMM;0FrhGEEjV)@E};?l zfftX|PXAD0HVc&=(Q~?L6rz@zI_uA}<~uZ29qx(0F|*T89`QW;*h{lvb3&_U>I`!s zV64v^#iFmNKogM)3{c+^o(+s|0s-T%*BSoouUAulutF_$+xt#xUZ83z@M>JhM8z!>Ib4&bli?(~Oh1b>} zYDfG1w~>9&k1i`L-20H-{xChP0xb1lo+l*Hh(4sd!1_`o1o#eOvFHD%xo*noDR1l#^PHha53A;WZ#5 zDIxLOxkkhw!{e1r%zFcR>k=0exD(2Ob~Miade=g}3n$)GI~^NFzv>o2_pgu;<;7Sp z;Cr#;L>(TgrXgd%;_u+UyFg{GRXOoG5=zNTWzmwAa@1{4wopae^92gSN%2>~v{rOt z2!T;5O*)LGxN=hwhaq21kRu`n?YL17)Ee>oe3wNg-f(S5Ur}VZ+;XC%VAOD@8;gxfd|-mGEyRN{T(3VY0m{a9~^@hsB?a*CBXP-u;Elo<-XcR9_MLvFvsJTSv0^j?HucpaBw58BN#?iK|7^we*H{;1s_rV@0q0zHZ zyn76=_6L0oFbx@Nt7tHs)|GOb((haf9WWyhTw@9yQIXMjwtBjR6&Q(PYR7+5o;a8I z=O1`?2g)-4*zevi*`=wDv|`Kv@Il5y>(KE($R8Xj0ltUI zCo+eNY4m-l`>|l_S=@Sp!XVFzR5Uq!F69}Czx))cAQu|tPnX0X&?r0e^t|Clff0%} zJcuc>3rYOERs23RToz;osO08e1(@cv-&r9i%4 zBXIAJcwrIksT^f7#}KN_PbC2B6KhpW_Oo1M`lI*qHSbacU@ zoV=$J1vNp5Yd!g|e;Yr#rfH>xf9CNDJ#3cOUCuF7F;G8V5l;SS{T3-7sVE53;Tds2 zJq_SJ?Lce;_^Z0OM+*aHhyy#{Q3+hXtOsoT$_q~LBne3-B5JzP=6q>kv@_a)E=EYi%*pvwM@r2)6_ z~@ z9bY3ST~%N`cT9k-hyK8?)>2(Yj0rGxabrlyvFd!^CoEvPB6K8oL>iNc2S){?;3p{935yJcI$nb_8 ze3vQx%rO9&E8hfLCURC$KF*sz!iJPl- z$gi%m`233}%G*6_3mx>36mKr9g)Wjnn5i~BBfuc<91n`$B-MH#^=5-?(t|d4f0_Je z{x62VKIqE4r`<-q^2?F#mV19ZBir5l@myQVqy`D_`5*Gl3alB8EEQBkJVlT$;W{Yo ze_mY36XoP+7(t=KmKj@OGOi;njm~i3rektY61eSa;7GSO?3?6j-TCz-W5{H z@IV(@U6gv1JCQlo)=T2Y65VPz)=S`*4=bJSm(;^Sb#y)(SFd_{W3P9xy zgi#=AYk0-e{v(1yQ|Ex+(<4qZo=7E_XLH8rjOZN6sx_TVP--xf9K+Z_|b(n+xK z`zP|lm`63SzI;CiP-?YVR5M6N!czF}uqcZ1v?a3<@r)Qspj)rrGS9%oS6gpCxCw#b zh$jmz@n)dXS!mvV$15K;9r}HI7rzeRzdDe&VSx1N=!0I1^Nlx0);OLrZQbB7UKJX& z?6U`D+vkQ&SIv1mTfbwx4(>Lng1`!Z@><+v;pY0G&CnJRkIbnJqw(uvd_5B?9WKOB zkYfyVfE71w%DW<%qjFN+sy8CJhZDB{Px=2myea(OkpEA_55fO+>;E(Hzr+9K#s44T z;s3Ds!1w;w`TrR|4*yqu|9>d||A*fN{};F3KN-)5|7$z{pN+4B|5pP~0m@3Kl4uco z(;VAZjizx=CBZ0^o_U+W9m`ADFFBVGy-uL`i9pu+|cFpAnR2k|tA2vU2e3%ceq z6McxbT#n+2Ss>eHW?x5_4T08n0m^Q)OwtD>7iADrT}TkA!1)PFVIYLGpv?c^X@84IIQFY&20K#csGJ7--8D5+&KJ^k9f@x z-Up~3CZUW~G*3`yX6n-23vv{ue-YrTDMU!6Ld%kBrh<*qW0a0aXv!9jd;Du#j98BE zIq9!zEShH9mq%y&E%7rb!)N@0{H0QmzhIvqHm~rJ#(V0R}>cBM=7D3f!1nK2%rqZ}U zg)s=oi3MNK{MPpUzyW;A{>QAVdh3NHS?8sq>{7g?wy1d_DV1n!e7WeVDxt_Qtb4Kv zjbMM`>lQx@$d|-AZm0!(jgD{a$V<`gCkg;99jLYi>p?36ZI=#Rwp}nqf~F^0ZJqO? zEQAtB>WIK}PCbJe?*Kjx-+AD%47n_f=)b_5ZLCHyNnXrYu5D3H9L`H=K}KB7!2>)S z!MAZr?YoL0<&2cj z4r~rurF~q6MC6?}7fQ6+C8#^dln5>Cx6@I)1G{A;>^jomEY90{RaFAt&DgKRvrRi~ zZV*3G5V4@uA`NVIsEgY4*?6?@oVcis`Yb!&BMGWumlhP~1ALuOf0uuieP-t1BoUQY zN%M~E3I8T=lWGYc^B9eM1WK(|()lm1uy``4+NrX7V_dBi^RB&a;D2g`T z@YWI;;$>PI!c}n%Ub0azo7&8D`Tn!23CL34`=#Ze(!}QB#cq=?y5YAc->!E|d+3<~ zCyjCA296gylbgj&A;%1cFl2$CnvDUCyJdvg*!E(Bh2zr`%i@v653K`vt?vA@w zv1b26@{X6;pSS(_olo5glWEa`q=PvUvXGW9Oc6t&dd)!&RhDlPIr3^}x z(bBNm4bMV;x>3U8CJ*{}khZC;pGlvpY&5yn3M8rbVt|&LPtt=rS4{^$?9_XiW?=3b zV}yVa(BzWQxLZwvqY*(T6Oue)g7fW;MZZtKePZ&U{m_G5!#f#)(&n`0@Nx%Xe<8xj2VCS>swdy9DpJK5~IJ9F|CMMDN2?@@tP-C!vtN z<(t%8%wGND=Z{Uih{7b#riNZqyH8xo zx7Ay-#lD>-`ZB5PYhmN%!x*n~Vo2oKfTxgp!m3?M9jI2^QsFc<>{xKg%Jta0Sp$o? zMl{c9!q1a&I|yb@D0&LdomuyV9!{ZJA+5c*_l_|zzDabx9`vv>eXXEShCsm6Lwc&R zty`OZrTtoDy_gVOAlVV6ST+61fYHr3{u}I(!rwdEI}g7c7|Lf~`(<-YXk$m_J=^Jv+ixWbzLKQ9Ww?!2VK(mo zuJ4P2m}I6z!Cvgk#}JU4-o=p>5>x)py2t6D$*r;ujYegFhNo&t*giC#<8(#6vIb?% zCd3p&2mumDbLGBVf`SYZ9&|i$l?O)Ra6NE&>bIYL{d?Ij7k|6K=H?-aw|>8(Dmpv! z{z@tS+PanySW?&2?g`F1yB)yzvN)igDXC`9JO}ELL2u&7y1VP0dlythrt&mzgB*nb zEq)a(O&)>avq8_R;3W#}s-Zd}4x57Z9_>z}eyy@6O8qpQx8N=f5*%0e+G@;8S*hTQ#>&e5mvd3QPD71fE?sLeuTM zWL=93V8NLmVF^B%{X7TSt7}2rEr`IFH(X6+Ks!LQiAGz`V6^u1{Lt`p!>NI)PN($m zVXpI~wB+L5=8pA8KTnNu6stE4A~+dy1s1DJyO(`tAgR+>dILOBheybT4m$bJ+$l31 zULyhWRWt$*f+OUpdflZ0jnfQH;6Xo?8KcLkY0$6KQ9mu8Es@gh)UG-mX@B9^)5`A$ z@+m1HgY^R|67b$|%)1G}lvh{$@~q#zVz0aHm@7(L7`;*J#rZ&16@MZt0l8F1Mv<_D z;7?==UY&joQ3G-d(B#9&b;oTU_%Id+8oRCfj>19xaz{pi2l4#b>9);5ttg`xjCYj{ z1E-bYE9dnsP+0ssA?#W>(h`yRBSY;UxWZ|o5Zt&${^X{#L%d1^iZBr0Cm#kJU_N)87UH=naH@y({ zAMl!xzwn?%4H}7|a@35u&iJ6gjNyRpKCRW{Z00pqc}!ppDXK#-SIG#h;heuax6$qR zrUDnt#-hsu?Q8IF7Lq{!%h%8-@i(>QZ%M7C3Ff^_`msJG=M!G>$@o&C0|nqA#i_#> zpk5PVqhsi7PSRW{p{^Q3>bm=_CU&8NkK4W%)VQ1%X#(1#UGhwxZ_Nn+ z_?pP`Uo~C{RII^kt!vaj!TcN}=eeqcr)q&|-d4+Ag^x`=CU^Nq`3ejfxE(SUxE)(3 zpV$oCu1DPV5Ba(=3e^Wbm>~go8sHa&f|Qqbn!2kG@E-=&Z0QmWiWH1Zd0${oS3J8L zJ9Gx+7&`S+(P0i=gZtYVOSB69L|#=XhVpLShatmpv;ZIAuKFk+xhVT6pMYq@ zW5K5XKX|azfgkYC+<@vOx_xlkud{#faU~FZuVp4Q0Cn8+Of)8VcX)>F(3vR*QV2H4 zK!V3+`xrRnql`_fr0{Ktzp1`~RYhGS7_TK!Pp^&Yi#KCx$?3IyjNwtYn&Ji zZi(Lpgu(`17MR59gO+YjWag3oz=Mu*JGi|)vsorl(8~M=xnS9+{;}zR*GCb2+yP#9 zkw^m$X(*`avsp@mU9 zUovy4)|EeI2vaOfwo7Dgez_)P7ylz3p;J$h0rklK2_1e~hyQ455-`3K@cN_-fH$M- z>tpzdAmp6cY_#UOpW@-hGn6H!y-&I}i}R?@FKNODPfai;Vluy>>($`#!;%V$%9GEj zva{C`?7e~N``IAG+uYX2FxbP!_NU#VE(|{2&?wrnB_nBTbC>P8e20wtvl{`wAmTh# z)*{czz`G`7$A~Qyn3#MLT8gZJ zpng*;!a2x?PPZwk=5==*>q{^X1$$r|OLzUCfxwvNx zX`nQ4Gl6ZczWR&hxvK8Ou|Hq_cy2%b>3zLbFPDLn=HGp;d)=z{g4^$1Zr0y)`l#2= zl+fZfPgPK@9UG+BWaK7*PX~BbC1x{IGA$aC`xkoiPF~Nde-uP;T)nrAFtR&bwKSYC z)`K`3T(}a0L!-4rP#0-YBz8>a2IlLdkm4ODa=-}HW?Tg!{m`<{^YIJK@1@JXy;b?^ z{QWy-3%-6*7}2%ijp0VdcP*uoHW|m2R6&-BCkw`}RdLBk!&)|vQR(c-6ulD-h|8>b zyzt%(SzCn4DMzDyPsjKZ>@6GhpSPDZ7w#ILqM>Koj{}AHgWbK>w>81}?DZX;S6*p8J7x<%o4J-(${0K-L$;pxbR`@K18bjr zjB(eQthcigU7Cx+c*!K4`I?gIN^f35e@k3Ryl^S=tWZAXG=jIkIAv{3`EpZn3Ct4p z#$WNH2UBqt9C6daHs!vT4?VYp#}uyG|02a!lWX#A@HhCZ zZU#F>*O|L^h`wZ}mBzRe{EkHHeJ&O1qc??;d+H!;pRMF%m_+OyYxCtuu@5h+osDv4 zR@(PxLD{&}XT?_Gdz0pQe;mWze=$kyy1JEqvjH1;lHH8lK5zt2%?-~o)UMh)o}R1R zw^+Zm6`z^>!~FWkAr-ZaKd;7kz78mOkA#mNOgv;aZgQ?HSIQyy)dJsB3#rH)GFg^h z&qh4ks0Q^_XYA`Db3!riW@!t85?*P~+(SI_l`4t8JJ{stKz+g9ZyvmQI1@I!H8!9Q z8~7kh_$CRRi?^^%Xi5T?OY=-_4qyLEe_P}py{k}Lp*})n4hH4iK588#C zdZIj+)j+m6a@Ah%7gsL(W(xt}37$;w89OzREu4zFl7XEOCpz z5@&YG`)K)?_afyaonMhM+!z~h#3;}qK^W%OpKD~68{T9ey(;S^rRw0?#e`c&@oTpG zw?@3a`Avw=xv;CR-LF->4ezLf%|l+_&O3Lo6sX@sW6K1cbRx1~Ik{9=Bq@~#(Y(%$ zqf?HBN$O&G9(>2a&JiQpo`)W%zAtJnw9eo6>_U6VsFfOH#!3009!GW9l6W?8fPkas zN}8U5s~k+A>81M|yYVJ`XQpv$#IC?^Bi%P0Z}QwdSQl?|q|O|^{py$UsP7K8(%cqI zk3^^XRqI|m)~Y-Av!$RfX0v!h*f zg{D|x#z`2?0h3#JGZ$Pv!R{gfPqht8%!j}Lo2V+~9%p;tQM|{_?t^cdMk;(Vj%cdS zz8V_7zjT%y2rqVQt4d94ReFrx(+X3?lbJ}`Sb?lL4PhP|hoX40#atf!I@VgvJD|Qc z#<)^q;hr_@;}JhnVpbg+is_&z3oQ)hJQJ>Ljl4+E=lAZow{TpxZ}GtoVNa6-A>=s4 zOH;*}^^@W|{Kq3gPTW4|WOviuevzrInD{xiH+Ls|LUiyPvXST31`3>1F?0Zl9KwzVWyo&r(}aNdERpbQM)$)94uhg13n4jJv8tmuX$1GJW`{TU(#-kGx}r3BpRM6e=D z1CE=D^*F zEf}SV@1|bYx-8v>>Q?u6)-+wlGyx2Oz&_=t|e#7awy!asRCjEP7t~-0_@Vj6gUT{>v z`GhAB+jaPW|9}VA=Vco$3_%Bt}sWGi$tB?3X7~sGA2R=+m z_=FGnq<$~RY;1(zd|ZD)$%Yc1+zAh6Ne(+3O=V`BN|Ht+3<(u6L;74tzs2eEj~VaE zOr4=OuWGc2biKdDj>;_!`}kg;eR2Am2c50U^3XAt)y`hQ8?8WEhf9fP1i@BhyQ6B? zkLtr(5FhY1Y5MgajSui0Hp;U!-k3fHX?N{H6i;rA9b*U?`kUWm-rkW9@Q6-vU+2n^>k|)R2g2k_+@zlT@1tnv?Jh{-AVFyOh1+E!a6Owo= z2u&gMP`KA-#E?NEqfA*?GjkXfeu(pe*5~XJ-00@TQ;LiMCP%ycvO+) zM|{76Ooo$>a@|TtbodDzd({ z5`b_-pf8|%D!75iHzARUp)t?K2n^o|A>lP`WA^zNhB{(nUt!J~^7WCCi$z;V2}MiA z!`xWKN7s*cyaC#1Fkb~4SwSE09hEw~eHsQh7}$Bs<#RnemavkhU1@+RW98nB1-YLw z$3`iqw{S9i>YA=CK}EhuJdcXu9p3~Qq|ih2@0xD(rBGu>*QV+HaG=(^I$c&&DjB+5 z^Q;gtrHD%$-jc0rbb$lBJ;HM~IsVjo5<3p2-dVk=1$r7Rb zLz%H^FRBlWV;rJod<}drA=lrEH^=?3_^8V0jF9UhFs%aQ$8L=oo|!;lrvf^MLJzwb zO;D^>^dD+pZlMwjFKYvd%jdLxkyG?7?Mz&6_@rcB=?qF)X`VZKu!9iD;F43u1Y61py>LCfb6-?`>XfE5pkmeQ9@Dgs;BQ!_vrtmmVzPkuCnZRol zB+8s~LkhZgrZ{doR}01zMNXpvu8~ad4r4w$wb>H@+ z|9JiDvy(5aD3qiz97Q&J75E;vI=u|Otf=MMikf7k0Q?c5-DFlBY4CD|z31L>72gJ} zGS8Vp9l6cN;v8(^1p|w5oAUKuJP0~wV6yZgX~Cdp*H13c>V2#^dvFKhI8#2yOf;4~*GyVbaThIou+n zf^3)4&MW;HLm?-Rz3XR=8?WF0(AdTE=QR~3dTVeGIkVg^Y(p_(a`PrDax?T57tC(KU~+{2Mnnz3S9q}| z)w@cw6A0B8GNE$!#)4w|JkrjD+YAF-9DS#UO_HDF;$BwZ<@oTiI}7GyJzdyI;=sIU26`+B#{d&|&#eL5bzQ0uTG~PFeqD zGwc=tx5>P^6A!^8Td0Lr>L0}9rWzod-GpU@J~^7X%#dT3DN&xQUGQGMqLsWo;4-h` z*vP8=eyJDEMuh46=6IWtHVmi7-fr-s+FDM(kgjep`aada_MrFp%*bK0fe{7!{&9mB<7TL9HZWKPXH~Rg%zkO4?Yoo2pgMFd0W+9F;X*-G~GR7*> zIsKa)NU#mcO;p>cfDA5~#NU^FzQ7DN|=B#;@kBSYFcm?)9DT)-U_*;2$f( ztE%ji-xh6l&`@^>r#ba9t zig+#_4X3hg*6xl8m3x=ZB5$tB!uo2B+S+3K6?ls(FjozpDzK)b_^vb_<_PA8q!$re z_Kcexao_I$gVno?A3yUs^y0?4I3MZC2%E28__#H6Cy?>I|!`=`+J@0j{LPjC=gnQ=(qA z!|}2dO2I1zTDA(+DIVDU?54-wDkTo%s@2}u$C})bc?4ZmY_I7KMr`GV^#LX}8VzluxBa33JPWZ)(eG#k(?3BbOHuyI z=bWmk4sHf`Y}sR7N13ThfQMbT^QITRvWb6wO00+Mxsi}R+!!b@g}CV$nGC6iV@5u5 zDBql%<`z(`zG0b*HowjY+*my237bB>ePpfIa}@q+0o7(li%{v246-`{8_Z1E`*+$C zKj0-ABwT;llt%8EFehDjlyhat>Mg1|x5q&y+xN%~F#W!HR@+u<-c7p=jGP+45IV;$ z`k&d{Ctn^Bs@Zo#rZ2dVe!la`0!uH=B#?dFc?xA?S(2npujl76h^_~1%x=!N$6KnH zR)yEI&yYY#6B>&wz>tF{QIU(W0u0x7lF&W`+oHhyCJjHv#&LEY;_i9dR39iAHs7@o zm1E%vleVUVH4Wkxm#(qzMQz`E9$20c(vpaWptSQX=Lhxg)H)w9`BE*NnN%>Y zsFIq}XZShHrM67Hyq5mJLQsVRJZMQ? zC&)_q)xh%Q*oEDfa2Ub8Bc^#-2)fTBgXgjRnUcfpHX4TCVVACcX821>mjn0GLs~ZN zY|yWXY>jv%IujFA5S=ugUO$X-TUunj`M8Yl#-TWbORmXer3`=%yxdn<%Vh*Zp>9a< zAnQ9W4h8XV>)NkDs1}! z-;(IQv>&?r=Qfry{8}frkm~PMvI6t;FYdXEUGLZ@r&2Gr_4O(6LS|NvR+_EPu`Y9K zBJ?TEF{H9^b&Noc_LPbt%Si(fnw-pS3(;fWEvMRwq!6k}06UNw+~J3;M#4^q<$JB) z(7xeyHua18=)W=1j|@&&hv?Cef=4qx-5So>HB>%N;MJ zQTLCI4)&o8qrI8-q0E3%*Embs41vclLkD@eF3|dnh@Q=pkVs#NLX-6nc$9jZg1g0b z0!&LDN6}!ieM+B(vE5*2)P=NZ^(%e>(_wi$ZO*yTKMB-2EgfJFI9@tH?OHhx)b}0G z-<9M%d3R76FtQQ!>y;k8B9v2%@A4LqXXG3k%C=A+pxm&#m}g-b%O`#dKy*PgYHkY{z060V#Y0Q>`u=sU5O06+PcjSG&%0#tlIm|J-W z_{V0>p16ft7dV{tU{tYA!8+)2Fm!fnw%+=+6&^G0m&=_=^l|7TJ~^}oB-_Q7o}khq ze84x4!4gnu8hyauCnkW=jf~a0N>6zn{2)Rv7+gO!P@}`M?|^3do9^UaCR%4Fs!`iV zG*2rV#MRNxJ`Ho%6#d69x9pjQg2=WOMRg9o+XiZg$Ng3J%VSGhJ^q2Gg|@oU8DL@o zWE>IYI`z$CC>X##s>guJqKFo1YyfI5^bLTo)|L6_p*)jwp+%TGj(*jY){I{^o|&B} z*s^3z-G{p*!TkN#NrR}6Z-;gG;=`O+55-EpEwYyNmDp!kT9Dt$rKcc7$84LysA?$B ztQ#L*BFB2Q_h<~WDA(Q8C5zoZ6JCb5x+zpm z$NDn2(8mR$a$9{0c!yg|m|+G1^&(B|z;P6XoGPidv48~zhnBcSYH5SMH!DD?m3udd zI5#vgh|UPV#q;~Y(j{+m#{LZf>l*@FBKL%(JR1Yu{TDsd`H@t|jfW#kZSH9X^l{1M z7SCgTslLxhyb}9pLcQ4yx5UZm`GjnXT1FSDdpt)4H1fJJ1$u4}Me(v#qAU~pH)@1> zh7Z=5!vb@vx$CY$%4J`X`_h$}&&S$*NA~Orj1SkBXVVUg@|A6Id$tcR35d&TD)LTe z-`ytw-_7_cG5Z<{L$M9*Od=0&v?BKy*dBewfD0rwyAEUte(JZl;1IS_gxxzZv~t_$ z+i*EBSmQNBjYxI#40K*VFmes-j%!Tw7Hmn&(WKa>Y)t8 zRxxS;CYgn-pryw|UfgSVoERYKmm-p1-~?gzrL%_LIxl}cb^lJ{H6q#N6u32(lvCNZxBQd=E?D-5zP$rnnpl zb@%$CZj~Os#>+<($rU%LhEEau#*Z&io8F4Lxc3ECd+c&aZ7<=M$q0t_1sI@w)znq= zpu;Y(RanaCO7V%?dh}bHK>gOYbCZ=m>;EXaaDMA@I|iRgZPJbBNglLdDt1p}WvtNE zJR-ZeoRpeIAE#cqTt|a_H0V7u3?bfXeZuvCeFNC7N%URA{%dpIO83rB4(M8rK6@E9>Uw$z$LT(rz{q_*@^Cdnf{#Lk>)iD_5 z)4<(g6Na*!ysS%a4wNQ7I&}lH#UZo?O|dWLdZiltjAX36S+aBkJg2~wW#<)HF7F=8 zdfo5ms#eIy={3h9pnbng4H=ET_`zc@OQdxyRSN=gUZmMFByoYV+!baAh4xhMp{hv7 zBjfiuoUsv;&42t`Q0aD`V|H%GulMd^x!BQ#sD5fbz(c`O?#wlThDsM{VnEA}Mh(dD zxwgmvP0jReu(lMOutiIG3Sy?|Bfsk_+~nNyvPb&JU5gImr;)Ms90V@N@O2 zqp%XWP)c}V5c3}LJb9^Q&1M7e8hh(allSa`7pWDqUQ+u$(RdL@&6U{-1^rt=@0PqQ zw9*ye9jM@M6J&7=e1Y&%I+&XH)Z2w+pxIK$gL!gjf4QHr#NJsx)kb8N$idFVjfOQ0 zcIxRh4f(1YwX3oWr@JhX+;Zt&*75wm$CNEwQ&_s^xWHmjb2Hz6gXr!`VuNSi`|ic; z1Gk%W{5tJc#$7CVS=xQh?aPW*6Wh9jH1I+~L!gy?IAV&x{q-Q+rt7xwbf+IN!4RB7 zk?*s>sRR0Yrcj$e4V5M#A?uzyEFJ`5W}mfcnyJWS{TJ^q8m1rpuI(M;>w&{yKCSSc zSadLJIqL!Zy5e@cMccPp0xeAQ2_M`P@e!|90Q?OsS%+u*^l4}W24_s28E+ft`k-!} z)Sbe*hHH%T=3J+A&jhI>(g=kb-JJy9LL!gbi*_d!ABXf@imi;gzc`=vx=22db!gd| z)VaZ5@A%(hcN@*xCTxz#r~`QZC;WFCxu5V4-jEXi!k6hrA~T~=``uuh;bJqMZfaE! zTp0kKsDYJ1I|fBvz$JC*Yb~9POlfVM+X^3wjBqiT7j5rYqM{sXf;I*_pEG{*E~qJ- z==Awc>Klu`y+QN2?v&odYBS@hd@m`~sNFg{fF)l_?*e#~lxX{4s$akP9)_HHFxBrj z#_$oEbF{9t4F?{g@ud1mz#o30`m`cQ+j?rL2>}Idn^~tB5%`S(0o8QYfJzr~pjrN^Z`|37vcP81$uYJ9Zzg zSYg=p6PomB=rnu%-U`!-M$wL;9VnBKanbVQ;xkhhsIq72w&I#ddysOp&kAacSEXy~ zuj;!6uvLp(UN+Grfz2#!lkp8a4*t3jF`+nAOK1*7d$`HLOVrYX&2GH80JY4PA(O;6 z1Yyak6GH54zk!E?d)&Nokx^0!`-DvtTbrVci`hK+Fgk!G+(ArxYuqHdeGdZj}NQBlcY}Fz|z$+)-d@08Rxi3HE6Bl%e-3> z>f&=G&nFI0@jfjp0KS0wEcGkz^0=Xbd&(>wzAulu9sQa%u5TE?PEe|tm^}Piw)!g9 z7I}=7P@+hh%>m6_$RFL?6<1uK`7XWvnZ93+vzP6INzvAZVoS1gY*@u>)bDi4>_8=f zk(om9{?!{?E5$wbt2AFBSBzIS7CfIQpx}MxH@=)107VaJ5nCQdJcBCC+7k}=XDG*f z$Rp!9?y(GO-fYgTT=ztV+TxLRK#vvwYthUj2;|lfu>K+sSf42p{H)I}27E?ifL0@@cawI0!;O}>~9Xf0i%U+Fs#Ogm`c<@&h@6oi_lJWsBQ|j>5 zLO*6Dz>iJF0X(R4p~h>ZIsHyul*w3R2&-t>^RO=72-~98%QZ(b^wvI~s4+J2X}R}{ zF6(bi`X)e!N1gln?MZ;o^Gle%V>q6(KCqw7<2(_Xv(^r}@*fWklu6>3X7L{D2mJ?p zvPc1*aq83~Wo^_ZJjgdEb**i%^8o(-ZHBULiRVUrkbfuSUGcpj7e64{C_mssT7)lk zSwCj)SLWqPpuThC`;%R90MF*>Uw)axGU;X0vqhM1*ToFKK|IRwWq;OGRb)Z74f}+T zEyj|QAn@lJqz;~SN86Jx>?xgu_LDiFNl!Z>b>W9@Z-Od1K=^@7z*06+N@r{>lY}s^X zR%iW;Z!kLkmM)$3<1W=it};KnHlP|G$1GZ$?UUuX1$i&f&+-OJKZ`F(nQW7-)5w^N zc8nlSA4~2A*|vx4nAQtq`(SPy;CmPmjU?3Z%59$86fGRI(Aa*(Eb3r)%ZF@VN`+fKKyWf2L z_??`bBxnD#*4k^Ywf8zFna2MggWh+||K9k|AM*dxh;RIFMI8Twj_0x8`|q{?Kj?TK z`yJyyJK*|H(sy_p|H<**51J1){?o$EA2^=Je(#^x{tq0_W4~kkr$*a9l)k>h_|FXX zziZxGao!>d6wh2TUVlqp+?#i)C+XVH*X@srnE3fzXXyEp5AJhzeVQSOCHm%f)>&(9 zF~nQSj~fT*e$SOlHcM`qR<|v$pXfYy=y$1dA0_Q|^{l+15o);L0#_!3>(iUn;UDp? zQxttQKRP_(&Yh!?m3=|}AJaa0={I4wcH3ra{hl;`-S~gkeAxf1_Ot%)+W$w*b1+PV ziGEu}7qxx$!>GX)lSl${`tqtbEKaPki4aixo}6QjWcDBD(>lK`8F4ISB0MH zYh4#^pF1{6*ypa>3(BoKhy92;ewA-|ys?hq7gWnn@=3c}Z}upfAMgnCtDVJy>wEaA z{v|UFmwdjd#s2>PUuv-j|K_3VXi@vxB04L@#+u%4lbF?6W~05&YdNfFE)8*n*5F+x z!{rP^Z7gwo^TDThGP?Pxf8V#Mb{SuiprOxZsD}NLjB%Z-V(NR4T3+JBM>!=&JBNLO zd*Fgi+H4_|k7)#{{tF$Z^hj+VIk-YEY4U#hDQj**p#P92ag#=_u!-1zl_lVQ=TY*4 z!#xv~G+S4zNv!Lg7!uRu-I(tYL+fa`WoO8ARPhW^V-RF@W_S?tgv@2P;#A%nwunA5 zUS*Wew`k}-DjCa9D0vro&@>iWK#5K;=Cj@5WCa8}>XKxcCWD1T*MLZ}9I8RJc70dE zVtsE2&%1K8E>SuVcPlAFKPgM*Ey~)Gh-b5xr3g>5lU#wePj-&b?CdS6;fIt8$3_Rc zJtf?7=H{iS!?|TO1wry_YNnCc?aYWjF7M*SUOsAO2?BI=47#|~sgVfd$N|;~o*WM- z;Y|*6vmDN($jF{+!`u=E*u;r8kjx2(ipE_u8Urx5@ihxHuAK>DJ)=Gm*D&}xykXLu zfPcIX zz5$cR=-pY2dAzpE%#_BPT>Mpevf-e!4+;-(N;)uRK&At$UKH6wcnalh0HajbCxK*4 z=>c;^^+VqJ$<72k7UY3H1sq~c5x}^jhZ1--Od=5vResvM(YCXT15q)E*z2@##miVQ2N8{n6lW@~(FqjxfbQ;JN(?BmLiVg5^Gp(npY zeP8Eq{J?=Rg~28hjZ=S|(6XVsB>I;?cj2tLi|fRC9pC1u8O3bUm+#^i7nBtZRCq?~ zT7VE}mKsBqQ}zNI(Yj7y3v910c)zE)Ce$ABmA#-CI#S`0j{32vcUF#%L=bIEd&GOv z7@Cj4I%pNV6P;}j@>AXKkJxIm*aa`?Xxe}hhQ+ev1YCpWAKX*V3aD=b3ohvd(a0Iy zN&XXe>zFEi$~CPxX1X*%oUu9NVkDHx^IhJbvm4TRfy+)O@a#3t!=a!VfZ1H;2;EUr zm#R3`ci&#HpSamvXxrQ{7WKNaULQsXKOJKin*rwarkHf4(?q)zk!CHk25l`jrzx{| zaX7n#AsFMHaP%knE1U+;fJ}A~4U@OgKr&7;UB2Q0uEX{HM5R%rnXXI3ca8F`$r`qg z%e!NMOcEEYzee-4M^terq1+&Bo9}ZKJI=h4r?5w~J3@~XwN@7%kyedG9Xooz@DI}g0gyIvLZ_ugH<4zx+MQ_UK8svOedlB zFEMuR^yFyI;5u)8FIdepqpDHQH4h9G85+1!R0db=q$J%|{+&-D2znVl37woiLm@M6cZbiel=wAdEDUfwd|3zEt5 z2Q|^;+Z>%qk>N91Bit>X($h9dG+EQJS#p<)Zx!i#OA{7o=I6EeVi#^LqWY8g^XQ>^ z(K%*_ZK*v1TDP5VO;H$66}d$NNcI|;rQnnlXdHd5H=>G9US}^NqHp^8{IqG%y70Yy z-5km3D>-?7?;R~n+pxbpMjMpHh{cVdsD>o&Hn5t=Fa|AA?(R_XPRiA^-SiA-80G7{ z^eND%%dAa_;@BQFv*;P1@tly#Cq4fiOjvZPui-Hbz@$Ca(|0h^Pp3dMH*Y8i>{H*M zpYF4GC^zrxt_iBV1~Bc$x$LA3`>#wShn4iq>OfI6Eq5E-pfECop`~#iiKY&WC~IaS zN&eX=-zC#esuFO`IP`9HPv^xInm*hrVrs&wrJsz|4+Z>pVT}KY=ARE^{KumCr@$D$ zHJblgPd@@<{GZU1F-7s6)x2|J$9nXhkg(Pt5owj0oxgcH1>8~Y*wV%iE+4O z_E&=o^i9K-rR$g#-XXuV>QdJKE~~kMm-*^hzGxP$so1xl17nQsT-jK7erZ=yX!G2i zOa2Z+>$)hrk@Km2W3jSq%%3RfuNDw-*BF9Tab0G5K|IM6q}}BYvtHBTW_pU0j|6lt zMk;g>!5Dk{aaoou(ka7*?$Mamg0nevuefn_!JZ;!vc6<*HLvYpZpC8%a|4t6!Vb!r zPv7>-3Bo`9oOb0^%g$LC0N3Ymq7LUb7}-&}(gCKg?fgVqsJ*wpW%}goSC7jt228K`GK2TKq1>VU#*_KoRZ+I} zgWRY?c{yW&TzSK2xTAN78MQwgd}=;Ea49@dSYhQ8DpM_UB+oDqGb-D4h54F1#LVR9 zkGuE+;TjaX{o&1YS;-77QD&^~nDyjFGRo3r^cDjRJIlMRi`=dg465&rF_M$@J%37H z(4CmgPHx~WwY~PaQNBD;^oLa61hH&LJo3unyjD(>ZLOBU-@8@o!aa)y)1n-`z_sHO zb4_9K?iMUcoM%9EB4b?56vY{M@muc^$fH1&g;<5r6~p}0TvaCh>;nNuSVB&)IA)NF z4Ab4Ts_9-Z0QSb4wUHOp2wQwGyRu@tzA4El!nIejQv!d%4oyuS$X`bn&6_>o?ka!P zjf#%sO$-Ivy2?fZxwyt9PUtJM8Jf2arum-iY^Y)H*nTZByg;%>Lnm?#86IPj(-|J* z(U?qzIC_mHHYrcV@-H{2yjN_OUr&;ikPR$0{X}P#KgPIeU2>s^81y+i6Pgui%KZ(2I6evZQ}bCKn&;z1wx#z&suJ+Pm`?{+Teh`gQMQv^RffH#Eo|S8 zYPGeXV}?leE$+N&FSJBBO$vU7VSq)H)M+ z1r4-%(zPfyk*^EX-wsX0f7#!4(YrDuq+*41V7$Wf%V*al;(I6b=MNaVfP?B zS%x)M>@71W5<|Y)6PJL;Fhe|IEF3CoIyaFPbx2<3C@2PwP}_MBO>qy1rr0oH5A{3R z;#x@5-cz%RApRNQZz?jSN!puYtLXmD!4Wo5KIjp4_Tk{Lx&*>~AEl!--5*>{R)D#a zfZpwd!FjtgOB>MF$G2wbYbPs2u1VbGsgLtvyj;epPrz?}K}*1E&!|!|?7fu$bMo3H z|HHla?V%@&p!HMAskG4j=Z+m_@88^AWh_WuiE|~y-7Z(qPipUtEj$i;e*5Rlmm~#n zb2Y!$%Da=5%6NXNykw^AvcJ-SrOK)3mfPzzXxZBt7Hy=m&NQh0EU$h@s;|Axle)%L zbm^M@-3f7)I{A>7s{VF1@3VWI^d%5vvlTYZ2UUh*_A8E1D!wi_WmLAWivkU%6hnUM zt4LGPs)7iW;1WMg+NBF#aQ9j=>%H3fF~0O}i_S0k!A{pg`igrDVF_Ft;z6avoDiNh zU1oNIr|EU#sgX)XnI|`Z-%XFv3)>PrEm&AGZ>K6eF+Yy+(I78Wb1R9rC$%9VnF{tp z!GevBf*qe9swpTg0@rJc02m8lNiBxFF-7rCG{FrbqOaKm@2s3p4Ng-{jPT!Xx;Mrb zl;5w3NK=6p>EVZ10VOk9EtRy7dvd~HNZ@An5Lyll2`Q2E7E%`wl$u?!Lhs5Q@{*sA zx#;=wyqXz4QF~j<`z&x!Ys9PKJ{ru0xDgZ;5$Xut9|4N81h&JttZX0k-%rs%;GYUK z1B^#BEvjx^@H?u)OZ?(P_dzrdTC8pw!Sj2&P2B?^k7iIX7H%(32%_QL zcBy~YcMk?jY?)28XTG#Ty3#lg*`ddPVaV&b+M*A=_gps&CE3qgQWZ7=x{cicFgwm$ z#t1jek)npw;!{NpV???so8?0&(eOfp>OMzOt19IBAKTaJ;s?|^T@;KiczaI>q^>i9 z8h`o`EG06;smcm)j|jamwGc_K$F;J+FL9%5u|AJ^#XGEWa?pG2JT@ZF2GQVu+Qvx0 z+s@Q6;~?+{7^)BNCt4+n{WYess7I0sd?l~=l(ZRAqPd-1A41j52uoMqOI6mE#|{Q% zfOdlTOX<oI7A(OOK05e9|2saGq$=+L4!*=UwzZ<3qVD>pEPa`z~O?vtLF+r{Ann##n4y>lI ziIV)nOniIL+4!AWfZ6Q{K@DE*AI`X`3!sdKbAcM3 zwlf+g7wNCnfv4#Wsr6JMz-ZVP*bwjasJ>+)hvzeFB+2DzOGBazu`9};D6gb zIk?n*-dF}W=ONPELE#+fZLenAp^N)l0$*_y+XI`&2*0EE#sq}wevQEIXqQV+tFolA z4AC%>``2oQ`Gm3_MfN@%ObRGrPqJk6BQiW7WM*Pm?G{buLkO{G8Vzmn4VW)g=}QE;Tf=U-MebJX7wezUpQ zF8!ctqqMYtlukIyz_;k;`}td)^^D*arG_Q&72ZOTg)1S*p@koVk()i4#M$n^Q5-Y88ms#ElOYi9&9km}F^u&>owsyFLdCj^<3$NlwEx)8q<{Tc7`>=C zAKWZV1&u}>%BK|7J6u_4ujxz*wX7ekEG{m}sfij=?qUTKwPtRxkL+|Zt9(A2;g>EY zbTLD@DioYlq|z`mkE#dNB@biAJ+_-nmpS3&O~zuFS=IVqkl(qi&g|?mR{8kxwaj9@ zK{4u?Czad0cFoBqlgA~uc`BNJ!%wBZAIq(N`cN)4B2X|Lxi=}8Ej5Lw`f_S$JI>s+ zvkw<(t>J;rJ4^vlH;^m|lvfP<>9@9qI;iQ(8-&qQNm4ZJFeFG{8Nn^<;b6W&%ZR#VxP_QRd@0LsYyHI$Nl|U~ zLHbWhOkNwj;&6?RSccw+g-O}8fD$A87KHy<2gc#R+-lVlipB?M4)~m}Gg+2g>Ca{J z^mjuO7WCEjRCq3%L~AtNenkm^On=Eo-fMrhCS~3Gr4U_FG*K_)-t<>EdXij(Euyub zR-6m7WII0J%<+{dH@RYncvX_*lJa7*#JeO*;}ejAd9RSjKQdbBN&CjlF{yuU025YE zR4qCSRud5H9Isje!KXzH{mGMiz78`jVb9_t3-q^X84K8zLWVSFGDKJ$utJfT@+EqB zK-6>W)|EXlYiOhNbR(*!X98fPQ)%G*_N0dI;Mdo%kFeVhho)3(-gt`8jk{RB!bY9I zDBaz!i4o?NYM3JDokYnh=`FM5ygCP5&BIVU;KIr-tsK)?4sbFb7SiOI^6LQ81}4lN zny(7b%XDnfFUE1J=dSrIX1MygutNB;pE4Qu_(e=*@+oC&zt~1)`^CM-*uFKn-BpJ7 z*~5?|-~i*0gF{SI8sF6~C*Za{eBr(<4V~N?rtugRu5mHFth+JnXjh`x0vUeZgTzF78=#eSE#X8L$1P=Q_4{tK7A%{`7~YG8IU{S;Lqd* zsN86*$uAoV?C9)H3O}WUDFDjVu=j7fdCA`=7Y9XUj1Mp`?>1%7CA&9SV{7;A@WRgC2Dh%mouxy`(bbnuVvAHdT7buh%DrtiL;NF~*f;bX7>d9>8???-?P>F8})!Trao^{(-!Yx@F8h3_SGfu zp6Q2qO$`I)yfwnm5wF@a>IA$GV0gB`;URbDCaCGG@O&7m4fgY84uH9%FSeb5oFSl2 zGrW*cnujHuWFZ4FNm4$SX(%rnW{Kg2pjEZAX8I!-%7V9J6X`iJp7k;(Gakb7)$#R4 z(3456UE*G`(KBFuWPMQ=%&#)|Rxg;3XGrf{ht-pF@KkC)KVjeK9=ajkYfT9)hJ}HR z_VchhaMI9vn0*8~nIieFdDxdIZ`HACVg8DlT8mXl_9@MFI?o9U1Kz6cfrXhflxXB4 zH_c_E58!}o0LLea-88ALGnJ+TDdTQh5qG*JgI-ySdJD7UL|BZ1^i-%T|&5+oF zk8>22P5WCQdU&*M;?AEON*FL&OkE5EF*iF#4})bJi8ndG5IAf5MY6bj6lb(nM8vYh$v1T69@6RZh0cvy7y>4>e0xJR1cE?64 zlmQr6swgg^(kBAq@-Rnh@S~OcG!e6ily4mGv!*%Nu6l-QuxIH4Gme{sP4@K@q)QW1_-xD*inmN>kW- zzPdy_hM_W+KqJI^#iC*B3q;3SM^NkB&NO9m|NTT(XL*ZFFoeBw$v3^JRk!Fn7$YQ5 zPiSC#TH>b3Y>WAh<~u#gT1nC3$_%^7zxM}?eyZkl%EG!fdP&?<`is4RgBg%8Gc z>t7_wQJ&v{BZJnUcS>*Gpl=cClaxuLIhUf_uXMYV-_d*vL-lTeA;H!BBN({nC5T4l z)}0&X93*o_TaAgLp+?J|_UWicFiMb3XdmQHraRfw7NU=t9v!8i(Ey{)-iKwwFp%3mBiB=&iI?gNSB%MsB%o&jjPffx`_=JK&k6!lq;1%{ z{pR6??O&tI5wCu7M(kr4?dBLNubvSvq`93icpBjoHF>)G_L6!ErdMr-?qsBzd(@w( z1wRd}UN5FZBX(P_<^iltQj&=R$)pw=PgO&_2T6j1ee&GYuZbhd_PpYg5wQ7b_5G<} z!cjls`|_ghsSW$SMxH@O`@6+K>8>2f!k&ls5`+GZ1~QmTwy{Y22#2l-$;xpn<+~Mh zcxqLtRU#b@gT-UBK6M^?jGM+@Ljppd!s3R3V@^jgIHJ7bb_eb9M#9uEwc+$_bF$y& zaT?dBr#h!9q6MOO;sh0+ z6s0MBWoAZ@pHVnG>PMR$gehuBS!9o4)DQ%7iUzr6^%EFsnGe<>K?*Bf;O#UFO`_)_ z3PH`7)w&(CXXxCQ_Cg!15*OQR#@~XQH#gT5pFBV1uQ+V5j8QP~Gmm-w)pTNIKRYZv zy36bkS@K|O}5f+x^PiOeG+T{q+P?$V55iyQgjJ3s%85(iZyDL0rh*r6)F1eprd=7!3E55*dp+-6 z@rvKbH#IL`6}Pk2DlEWe~X`$+#}s$;2Q z6>sIWLKupxtWkT}s(u{*Iw3QUke&8o{H=DGcl>YiPaj-1mSNI+{5F1awZ}^unf_T_ zoq;AVsfO^|>@aw;FVxB`oJ=Z#2{suh7(9imnKvXtl|gQTmwZ(S$9qjtc16JAqWVN$ zH!~y!*UX#fUPj$kem~8&r=?QN!8`kDs3Zi7+6Z_15Ff&2{ef(GDMtHEe%Koui0Xu2 zJ+*0V+p72*rah~c5eMJZKQ+Y=)-Aiyw5j2_;-)Mur$iU3<(eH>iad+glqBDN9AmP> z+Auc%B`Z`AMNgAjTp$0@Mcyyxw29u&7KL2g^sLSpGUR=wPd4POiEm8g?d6#vP#7wlm zZ9RPz?nO?=lZe0m@!;~mJX~}2sef-eeQ^2KlRc~B|CXm)ou_M~0nId+uugs~i7W3x zCeBNE{V+H9IL5||@-|C)WHwb`pzq(AfQMYwB#M_ZVuqw2LTM9T{soNRjxR6|=?gL^ z6WK(i>XjShY7FjR2ba0BaaDg@4IAqZrhnx_oy!>SczQKMJHq;u;4ZHv^k^7$Sp>tvXDJY{ckGVQ z`P$Z6OsGay2fsgp?;N2WQp(g={_X*L!CTQPt77Zg##$z^HEY$1k?ME#v3Hbfcu$Q+ zFMVU#Wux<0M$9M9J6w2&?-#QcodfQj|7S12GkpL$K5oAgZ^$9d9~rd zW0!b8jeok7M1G=uH`jUm3`;UIy{-I9f)U7gqrV3uXR^U)p1`~WH-&tx zCpU~cB!3jt@U3xfgkQJ;sm&2(Hw$KJLXCOhNAjR1Z*O0#Z`7&2!c<>J?=fonrYu!q zh;Fgv6Q!19MvwfOD`-ggN*cY(rLAK4IlEjDK@VmV7n}-!bC_%wX-_h9x=>}f41kMAg(Y;uy4g+ililaCs$c6tV*kRBpNr(g9ffF@;jR2h>4wW z_a_G5_$2zInqSXABN{i&nttJ9s|i)YQXDJ{-tf0^KArMEq--AZhr+WP6wKj5``n$W zs4YSTw}6;qO)u<>Kvfat{e`Sx@0)PT+x<|rL!?#TA2WOl!wGTJxnYw0-Fh;<^nIG| z!%!T7ZkWpNU|=>HW*6J5H=dexi170t<`LYvh{Fb=j%vPK-AJMvwvoS z<;w*a>!)<NWl^L-d7IkSq~Fu$O} zVRwG{e$8W;0+{Km-uQmcD~zkEfGefR|ZEjCP!GPbZW68FCE1H*%x3DldMdKF3BO0iO^Ii(BCWAUA ze)={HmcM*L!3lD-h84NR(9@g(@+%Qy!daS1(gQ5Hl=!wB|1F8~1Dp%YvXP{CO(p(NydO=Ys zz`%S&Z+l7 zQ-2KRv8y>=|5!556n(3w$1pCKkAP!R3dTJ-VuLmzNocMq2!)br#rwuyNd7~6Kh=i< z9ApmC_Uhs^EA4X3r|4xfqx0F=m3~7 zs41$>>GpeC_O>ea)j{sM4My@=FJXq6*`(?E+$@Y=E_(uIoyWpmrzEWAo9Q zOnOjU{Qy0sI%Zgs1^Ixmq*J5imN;aVMUf7*1u7?Vv}}1@KMTt;bdN_hZ;rq90@O>ifl>ySvo11Z5|ap8&y@?@O;Hyb(`W*RFnkMxB+t;zsqm zSC>zfF3ns4JM4k zQ>!tC%+ya~GgMdczm4Bor6=l+El`Wr?p~c&^k)df{}$hLb-DQ9o>eP|gG0Z1z61*6 zpnAUS0Ed3WkPgf4kg*s~wB0kSLZaeLFR z5(^Py{CV#cJ%YSHrO5Tq|7rf+H=qCGT*aG-N^&p>%J+-|Nl=o2rT8m&@;Z)EC!k?w zUJ*D%hbl`RlO3L@(&zFgcp2w#o99|y&QQ*|yEzq}pE7)Wyc?rF@;PiMPtKC5uFh&jdNM0< z!IFF96;dltTalz4z(-_p1G?*8`V~G_6)P*@YMkv*{~IgAdwdow`=SY7jsDQ@v-+7y zNtOwH)D!k5`f8g%t z=VfjvBvA5T#?1p9s^}9t!4T9eswbh=Fzr0QLaujdn9?+*c`WAM5I8U>&UFxMpw>{e)T@8A+{czzhI&3C3U* z1ndCQgu*;{daHcYOO8%gEM1!$!wg?0Z5r}cpJ!a;O?_ONP{?!vf^CStI$?gs<-P0= zy|+G$dur^X4~@?-d0G9KM{n3_^f3Q6o(3=8;bA}EJVqVimee+XlcC8VE$@;%w{dix^!k@0ld%DMZr1^S|Tx+u1;EpqP%HiUa+cKj~_!hWH-1Z5fUb%Fy zD*>a0^E!uUTrl62Y@XQ^4mNdAd^1njyn)z#tM1v`m`?wE)YVPm=cld9^!jfeEETW# zORjt>uOyyN(ZDKYR=L^&{iz9PVBOv0L>m~5o925XH#I6znF2Nc3A=l!t7e|1_ALCC z#BkdSWgaLWclL`J_`neDWN$ZfSwimMCI7vc+7y*o>+^<$&>61erG5>LZc{`BV|~Fs z=TzQr44oPLkJa<51mA;!(cr&;;ZiZV8v#EpYIBC8S5JR1mJ7H4gg>}kteYk3V?~Qh z%*JMRh>_2Sqetiu9B^|t3^OqiH+%oNJtJbayql>ww6!cbG=q%sEJ<+`rYSd@pn4>E z-vm3X8%9#Wf~JY&p#4w}wNP=q{u12w-;(Zs!GUXGoo;t}4(&@=(TwWoq%Dqu7!0yF zxQa(JntjpxnJc)Jx1_I=u7#x6XcgZ2T)hGRl#)|%Z^Nj+T@F>uZ{aOWjI(>z4q08I z6@DqT3sdavoTv=ncJj>VizyvvrXu#uL3v><4ogA8@7bHXLw3wo}d9hYS<<)M9HKl`jDPr59S&rm& zRo7U=FBa*Of@>X!yKvu?vP;oaMF7BrX8{bB!VZT@%bfG^ErT9WZ6KM24kKn+vWQyy z(#&8Z-s_9_4C!JQUnkVuh4l9iOnxFBhroa?bfmzTVH^(D ziq^7&FuEUy`4h%qXkZDOiD=9O6|IjCpXorb5n^6-*as+|f!;=b*f{cI8kr~y91cH> z>GKK`i)&&+8>k;)9tg=gQ3{&I+Wqi&CXUfV-iI@IPQZrA%tT>KPV{X7yxH&;6Qk1y zQH^R&2rZ0L&DfJDJAX(QjiUdP42+Lu=9a2DlMWjTV)CeSbC`^I z7N6@UECdl;VDcAXbs}!&BKVo8_t#=r8%mdcod9p2_rkoIv2l^}Z7!zzhGIHSo#m2% z0+Wg4RQGdhChZ@q=80zTm5+NSBB^uKAq>pGv`AFDC_^;PbQ?s!_`$R6#_J#IdAKh| zay#~)g-284@?YgMU!PJX1AKGc(8}JSxG~ngM zIs=cU#;>O17)U*Tr*ZMeat0iM&wM;!bg@MrvcInr2^&+twp;gWfiLV~(xz7$abGc} z{E?$>4Ye{JgwUSoh)p64e#DtXG!oHC#2^uqL@X!=ClS2FArY5Eo+RRt$cseYBw9ck z5l$jLi3B9_A(4F;pF~g`CnZrJiGoNJOd=VHLP!)!qA(K4Nfb^Z z1&J1tNJ*jy5=D|IibT;QT12A7Bw9iu6^UX<6icF|B#I-^G7>E((NiRfC(#NL8J{N6 zGbCC`qGw6;9EqMMk(xvr5@|`4Kq4K95}`JaM9CydAyF!c(nyp}q6`wPB2gxZR+A`; zMA;NXcLKwj3nAj zqAeu)35m9nXd8)MB2h7kN=USwL_0|IGKpZ0YA1=xNVJPYyGgW%M6Zx&FNyY%=v5N! zC(&ypDkstFB>ETlLL@Sg$V?&&i7H5BB~c}bsz_ubQ8kHbNMt9`0TR`c=pc#S0QtX3 zqB;`Qlc<42jU;L!(IFBwljtys93(nIqPIx&QxY8|(a%WqHi_OLk&{F%Bx)tmyCiBO z(J>OWljuDX9VgKV68$TQeomqe5}hQ``y@I=qD~TZnNT-$wD&zly`JB2j}6alZhv>wZEiQ152n5~H;&=-qE9Z-ykwgZoLec1c8{SUzZNcf{9*Tr42;ii zN4ih{_^A6t!i>j#nA6|*@ZWRdFNj6GKlDx9z1wyay|}A%^X4K6d+VO<+lsdBe%1Zy zcRbaPZNAY&W@Ide>j-cfF#LDVqI=SFXi8crG;Ibcl5HXF)6GLykrn;2+^Yih)_p(UZ92E^4I?j2#d$i}*2TuQ1 zR;{tGI4!%Wrb&P7^sRJJnOl7T^_=_M*C;=aQ2`J$`@cTxqlUi;!{GmoFs$+7rw4gg z>RI9*GfVMCsDj;ilpP>|bNWS7!?lP;Nfrj-#_e-{jn2=!)W><^Cbpv4!cz2^|4-ik z2j{?6ivQ!y#=m~1 zW7!>EOWc2r(|4ao8I6)Jn!Os0Rs(-2FP~2RB8=gaq$)~DD_y@suiB8eYTN2< z+NZWO{^Xt(?Qi_odn%*$C)8WDTMM`6Y~4hCG#j3;rZx%1u+ijE8y&ULBvPB~ENBwm zr^#MRy-AZyZ8Yv(_ny1yvv?THs@(eG-pasR&I`+*<2ndLp zV8rOQ&jv*YXVVreTH1$p0t7^>m6I0trq2e&j-AmKEOy(O_MPWi*zP^^&iwJr=b3l@ zS)V}u$oF$y*L~mD;dif_W7NO-dumrBQ}EeIy6?@C^yR+95123ROZTOpG))Ja5d`^T z19kC#c`*0{Us2d*1VadfiYS#73^x)K20|!NG{R0$FeZT@Os>(5a^q**R9q|S$AkuB zKP{kihM)&0SE{H1a;1gtS2=27Acv6whPYmivZSs`H7laMb$}tY8R1U^SF^?KT_|&T z&y0D1Blm~~x$%?6L0;nAywxjdzDwi1{wG9>t4DDyj)({Zr7i~;1ji8yT_ndS8e9c1 z%ex4Ki6{wF7f|J4;-!R^A1rie1%ilf58YedW}*4Tk7)(`QlpJ3?LgE_vD0W}i(NBX zRzz}}nk{u2QK4CiD77Tb03$cmq4AdAk`M9XZz+eoVkdE}?E?|4TYB(S(BsV2shf7?C z>CRa^0oz(67^b%yOU!mfG~d)jpcS+}lSv;w#7aM?%&j z77Zu1w`(v&g>`H2>Ywx*@i%9>26>y2R(B|sO94}mzN1XH)%ulpf@RRE^=O>Geky_f-ALx}V8aDH#9gY;HxI`X_NX`%8jS;9D*~(RE{kI^v8$WDY7z)D=Q-%Y*4_0B}JL6-~pyTG`eZ%yf5#2`w zJDa-tsb-{|QqKb0s3B^Q2Bzaz(*R8sL`?_yX|1QS##i2lY{XY{V^eVh#;=i(G{Td; zvufOlIfgh}mMgVVYT*?nssWGTXz_S0>MA|2L$%eWH0fEU$Q}AlL^tbIyxRQW#;dK# z^Jx(q=fpQ(yy?f9-j9w z^097eh#VWM2~lyIHFzE-KH^n`b=Ua*?WA}k{<}ViU^cfuUAnpFN-c7>tb53hDN$?v z-|vgHA)RFdG_(IHQ5O>379nOYFY};{uUuK=-()u3QfEZGJ^73kwbAvA7V*yGHruPn z)Rh#bwqt>U0F0U$q0Fm_fn1UHMkN&miGz9_i+C9 zi9n0@A$%-^BN`pTH&0t&>4;i~{7`e(`C9a>;R=FwRsiN!CZY5XJUDj!m9mI_Mv!-@ zywNN5Q0S@Mm0;@X;QPbt{D#h z?^0tm-XG7{2YK!h#N!q3=5xKQ%s8r-dLFzsh<`u8MG?Y`P?#cE*BF8mepCc;qnhO+ zgu*Tp6X@mu2sDmO-F`AY$TQzjK5P<> zrTPqV%@oY2q)-Cnx8rF6l?X+ext%~*61kg#NsLNZrg2%wUBmX<2~(vPZm*iIet1kV zefS|Sakg;cSmM3H{kIe6oZFAB(suBz%<{v=fuJy#oh|yPE7!^rAx0FDI_)X&i5Osf z{Wx|g`0t-szYsN>A1gIptLE&-qyB;W&>C{`oX-vz2&_W-Hpr~GM->d=Nw{vWCjdf!}&s?zS z^U^pm9z9bUIjL9kVhrQzExkbb-ExmTS+)+tbzbYRv9ySbixg$#nT566Wp8BZ z?Z8!DkJkr@r>@ZXBHUqRZRnTiQv0-HO~So>j?X#DujVsSv0d>xZ`F{*8KQB-r}8)o zn@=`Np%rFx@3)VtrM_2j&8qw0Hb&2fqgi5Mfk7Sdu1%1Wa%S>mcBE>tKi%vf>N}R^ z{|<3tT}rp-1F8h9Iewz03s9nA4zM(mb zHgZAFzC_xL$wVlZQ)455(dj4g}sqC=MR+L{} zKd*`k>*=S9BH9J&sB2RtR;Jk2s%A=EGZ{howUXKuA9v|>-iH3!CgCS@ICt&Gb9ptA zf+AfL=e184)Ld>PaENExt!yb@C(O<<6$rAWvo;@dHeKS;YdDu%QB9;y!p}%)Yah>! zd>3G%bO-o4?{`a`xkcU8dFz)IxBF&=K`1lf!x~}n0bQ!!HWtc|^Af-wyi*1zn{8gAx!k2s zBYl^vnfB<_V!U=D>&JkhJG$uE!MyYz4inx=pEl=RT|k=9zV7FM=l$!DG+VP)+zv41 z1dsYaulVg5 zcr1l)9^{x;Crs*-c{lO}xeHF)$t>Ne>XYipu6#i@r^m;d#EUdECmX+dm{VACOnQ%D zdh~E~{f`Di_WJK%eCVBWpU`dE;M?Fknjr4X9bkkJ^C}VILRd(I3%sMnP3kByY<_$= zsjMu+2;Ua6@>5Z*FPzsJ?ka2nHN!{Bb>0qNgAQjDIcj|;M!xq##F_g~s?p`(m{t86 zLao8TK3XS!C;q9VXB6R9Zoe1!C(+5<>r!vU-P_=+n6c=%nKLKWf90Zmk@%ok(?SDx z3^25W90i!xIwrtaStN`IazKb{?D5;9F>r zY;ErGxr2)F`E2Ia5g)Zin)RVh%|Y-1HB;~7Mrdvywf|qc?oxcp#AC-+{kHPMW2?VB zr?9Ttrqv(cFtt42Lz^*tfBTgs%ZOjXbXl1ZiEXeQgNw)v)Qs|%Ezy)ZNDfO4M^&oO z_^rbDm{$_OaJGqsTAVgZ<`I4S5sJ8M?2D|k?)@i|#UV;h7PT-ehe;Dc#zpg-)ca>t z*W3y$d+Oj~e^b5vSka>jd&_2B=Z<5mW>ei81)%{{i$HuBP6RP5X0S}xK#&H=gO3{a(kY0=!><=h=UPW0|9#eBH_N#amXH{v1+6#Nw#2*guDI~JO z&0=$h9wLO8?+-#+A&pt862MWY5Fu%zTp~M5<>qU-WbW$+ST)n?gKgS(&1{Kla07dA}m` z+=3r{oBN_UGm#h3h6N1;bT>tayK>bmF}GbUL0kkUcu!O70OSxR6w(k91|eZQxXo8J z0tud~Pphi_yv(Ym9jZP%}b(}scbSv zX1-dw@9lZw)v{R!CDwebvN99nHpx%Zemh#*r0^pwjBsB`Rnu~DlOC#-t~N9nLYN+G zS%M)skS2JP+h#tGQNhposY)HS;4JZ=*IIyCQ3FAPANUkF25-URJ-oFR6-&b0@uzV; zL780c)*+hG*en*p?Po_*xn`p~=*>N&jF_@B{Y=_hwX+ugpw5JJ?46kcE}j3_aXm7~ zX3=gFJN00ht~NE4EC@{qFmODP`Cd7>)xdEMFnzekYpFzm747k|0t`<9nQ#c?om%|J zBEXb0o~l>Z9Dfgk~*Qilc?)A-n0 zSm3sh!&R`Hf}YxSxOwlc?pm2y+^f?fhhmga@m9>T(an9aN(y`0)F9o=v?H>2LT7Jm zDSQ6twSWt@0YB|D^)d7MsVV#zLNci>jFz=9s29tE^FE9v8SOGB1uqBv! zAR2<>1+-qFDH);6$AM=l(UFa6Yo z3MBv&wFDDJ`bk8>>^GA&ElK0I6gp7MX@y`Kn{Pe2o*M1~(W&Zgx1ZRkwjpN#2FEJf zPWv$pRwepY*Oj_xHX^r`v@Z77M|zLY*iqd!_3*1+FI1-)Cc=Wz!aEB-FKM#3UEii9 z*LFEPUPiEkfNc*V*bM(fN=Vb#hhsi@VjS|4(_l5L$hu!iT4psFH4P*TZ3rIPinbwi zGgC$&#mkk1A0?vl2(o#aqC`0nl?~AvNtZjitcg#L+PP@L1ja;Bf50mtj(%EdQ?7?* zYHGW_-0NWF@N#o`N3NAgbeYsf=-?fF?gb7tFq%9?8(xu}R}H(6N(Y&9Z@3#n&at2aVQ zKBdO^mt6?TDx9}dLzq>bL2l+(?m=Fx$3AFi#>203H+<^UaF0D7Rr^vgJ(NlT?raw9 zaRc!SPCZJQ$c*D9(QrABLiNP)C|{Prp}qTg_@)iUt^2dqd`X-#pGurQd?9Pomx~up zBtCfYeO6&bmxE2h47|+CFVCR$ECQmJsf8~f`JAT+`}$@FMCmLaf$w;#AdgqoOVn;( zuB@ry#JA-i5uSK1IwE0&=+Pw1iJVA!;(2&H>1ofyY|2yjpjXmlfPNsJ=BD0>|8b6b zPix-&Xt25P#T?bHrL41g7naaGch-5)oH@EulaQZ6w6|G<2yKa#wF~N8mc%K7Vh*Ag zkxn(UnY%hopEL6GrrH(h-{f6~I(L-o8#_7ADfI~FxV~F2PGoX7q?QZ`zwJG#*D%~C z_3HVNlaPnYu{-gc$i?iWZ=GhS?WA%io)mepC$ID;*W`HG`pd#GU4-h9&j#<@ct@ds zmUc{=(k(odL$p(r%&-n_ zfcHOFi@#bYJy-q-o<|r@2Jq)i3gl@tC(M*h(J2yr@8##L`NrvE2zWZ^qr;0X6_gUte_)EH_Nk*J$qst3qysF2)Vqh3k=lWj zs0y=fCPSp8v}N!lX;iJZn5AZVCHHo5(&XImBXNh{Lm`h~ey`^7V#vyJ-J>(RR`Cm< zetNqF&jy&u{I|Ns^{i*fJ0`LmSKISRa-ON}Gp!wM1{%dGR81uiO`R|7@|JgSG&NTlmFy{Z*QU7f)=Kq7J`LBU7 z|A#?i{!hp7e+|vAFy{Ze1@(UoWBxB*PygBFWd8qiasMB{nEzXLnt#9V{kpi#|EYbi zk@>n3Ww9!eq`d_LUr?TMM{mYsOu8E>H(sgR#?BnPY1($MG9_|-sJM={u~4tVd|RKT z#kS%1kX1p6U7o-co5doCP@YFcVeROM&qpq^o6f92n$NeR=6Dt6u8cBq@|B0^e3h@RcYz3%v&1Ieqy zWw!=F9~MO*MNf%}auG*a3bH=SftD+R8ErqJL;y+G8+Yyp@^Ig-%W ztGSENfqdb$jWlnw$J(C=lS?cr zYu?f%*u8jO&H4)rl~SS(5|J>H&i-FuR6Uh7Az&G`pUO?q;t5b6;7dgUOj@*f2w8c% zBGd@N;;Y>8XL=K|5C(UaO+k83F{Af}$^vB8z?qxT+v2M*AAm9I6n|YgLkWE4V6%s2 zoW9tv2o9;DFH1ewrlMa{wn6mb@J1qF1v7b6f|ixoKHvt zsQM~3c+3+ya|hIN9*z=g)8I!^fyjI&q8a8G2VhQkD`kvWz zD6nfddYfc{h)fNc9inxV89nqMLXkrIZD#?-@M=<|(9@b`DN)y;kYf^mrXhnS3QS{V zUu$np0UCgV!vBb`Y6`i|QIWq~2?mkJl2&OZyKMhRRxFLZIt1DK@x=zeBOjM2QJNW< zX+&6MRU7@SKx5h)IC?Ys6}_7g#cy&m1lxq+6oH_^j!{c(CY6F)AsPmtRBn*pa zzcS4F2+Aj>qZLL61OYIjDS+AKGLkSRvd>7ije5W|1_V4{7Muo&mQ1Y4?R2 zL36Fok7>9+?awLHN%FH2cdO>T*ZgsD;#k7Z_WWatnZn7e*lSbIEM&!^X2VKqjL9KQ zahZg<)?NZRpsg|^Xccl&C*C)WZ{$?XndPbYzCu;=+Dw+OMs~FK*&xbncgVO!p8TXJSA8eppJz_q z-tgQoaboj>TlERSiu!NrlYLO(1#7$-3nEK7Ai{^J%MQ=T%%@A@Fovmlez+r zrG?ZYFf)Ba&%Ykr%=Fbl^^!ltNvD`<<27aC9Ohe4MkZ_)jbq!I%C0PUi$il>Jx=O9G>wm{O7|DN0YxtdIalsy%1P~EKIFhQUMii zkiEyz2=gbhzqh*`_C5X7c~q!wn?St{@%cLM1AX0tBIP80T^!yo)Ovk`wAF;DCq)c3 zDw+Qx3k!gT7!?`)g7WQ1+-=Ht(?T~ikW>VMbnPeneh85AbBfNi3o=q@&Xd|#bqB^6 z{y$1Kt&eQjaYFM`rbnQ>Jbiv3=rU$*rn7p_ZdK~Wmv9nDIwtsUY3gv^dHTsM;sAy{(8_N$oa4Ymcnsq=!fASP~3Ecv&l<4x*l^@3{h99&)+|OO6$N1?n1+P2OeFJrO7>n)BaR&eL=O{@eK zt;Y9vZZSh%EJW@k(F5Galjs<9sGmAPu<_Jg(_oerbF174UEX$XsXUhTlK&ajc7IxOh?3$p_B>k#A9Y5jJp>K zujr6(-6wB^wnPZrD2pqR6e!wFX5%{1{Fi6>#K0a6IByguBf-|p6ye%$_?3s$xuu%}Pquq+_)+rV zoppZ~MvZy%dbuDM^0Y%2Vzeur~|KC!JX2F~jNpK|iBjA&Yd0XKa{S_VBc z3NUa~3*UI+u7O@`+vlPJNF#7F*?YwIV%v4N4N@Mw@hiR((ew;7BB!ChhfquR-9XRc zv-i-(kWCF?TSZDD`k`~bci|t6t@jFfhSIUZBGagQ9na8pYxPv2n@8V%>}n1}3N$If zWcI)=hv70a{1+OOnf5M$GCy+x&1%e18>cdZ$_QGYzyMi|Cl5Y(IR7vLf9@xnv5nnf zZu~4{4oErKcSK8@nf&)EXYctn_Kn{(wUl)eTX-G1oYlJn$NCEelMhdvV}k zQ{tR(`z?j(xyGBSPjuW=lBQuU!N4%N5&Q%bpwAYqW|9Unt|bIqg_w)LDobN9JT@0F zRK2)|6IR)+lom%kXhiq&+kn6H%5doWJ?BS48~YyIGc`nX)rN&s&+}q=l|=vQtojd+ zZQj0hW@xofQ&m&KTtJ(J@m1jkUf@fNZqqLq1c~7=lN{MfE6U9N1;fKtCHlalhfHcn z!z7ZABjh6nMnCv_tV_zUV&elgzp+g13FU-*-6DIBDJ%51etLey^geF7VcI65QfRz+ zv8AZPwZm`CAGJjKnxIybcJ*AniX!kKF5Kfg5*@=zssk~PyfjJ}D0+f+wZixxRRK(| z3vYO76F`8051Gz@Wyk>sJme&d$}_LV-!q+0i!Ls`k|K3MD@gWM$feh$ha$rQ_~j9o zMxx6LAFyRRg<}>0Z@I;$XbPyg9g8~(*Jfggr>7zmB{*v2LEz-$Aeg(P|}bhvR#acP)KdPcIZL8|RLUoSmV`k+O&H?r9GjY1egtC3UXTMmr26ZzJzJ14=WD&dQ;e$;s@zrXCp58`yUCt z=X#T3B zk3RBgSfSK>rf;9Cd@zOMG=CWT*V`b`lEaWEhuVBDWr7vN$-Y}E%c#(_i{f1GtthnzynBu)|y8o$q z!h9p>gTAoBup^Nz4J9A+UaV(cY;{l~ctZNHqE#ZMF#MKN>gYR0)D&uPMAc0}$+_HH zvi}bT=^@rjoBT%@dcfF3%GAhECLh|Rat{BXK~yL+7e`#GkN)7X)D_)QZ*MSu5CJwi z68Wa9q%rb*J+tje_jP|>JFSl1mN=slTz}k+F*4nC9>wx1^y!|K zhHZ@vtzSw%sIPRDgc+?3C7FlMdzSMm^60eiCQ|3=8s8|`;ZCW;%H#_-nA?*@^q9ZE zz$Bv%gb=fz$5>i%l)tN9jzjEL9{mdpu4?tMnM2}88ZvmH-3X7BWC8cV)vxbG7uUCr z1bq-umlpkg&zr8W#;cc@{*4XujR4c!z}z8!ri|nXS6LbFl#XJ8SBQS5;2lC;$Jp75 z)>~k5*`@Z&*9f<@t0Ze*%Sf{0-Jg`(bOy}8E6zX<~iUMjqk%f_}#rqXO6$BQZ zDxB>PG*?VpGoq*tGM6I#B<-2SgdcIurGP`g2?ylNF}9!uL)4Zb*y!^3tTmy#u=FmYz!uTN(@lLw6kk4Yz1>1PL^Aq~01x)Io9SoeQ+QoKXF_2nLHn@ zccDl-$dlh4Q-`twL`tMMK$*o_aLThNjI!JT6vO0MNt;QH<(2@w75UTr;0Y~n=E6AR z#oWV*^dF|Sn@_B#yrbtUDZBC=8;ohToK22aH(f~8p+hVS%vB_xPKDW7D6dq_lC+l` z4U#xP?{q6Q?1)I!HDAYJ!YI(MHkC5r-RuU31*Q39djF&R(ZCwe@3<+&)04R7JNNfr zQ09d*Umy!kQ4VurTH%hT^@e*VP-ed$)tPxOCdYU1&$OS^MqKnXuUr0Z>4{v~7-$Lu za-b|o5YX&p_G`~$vS7or-$@_oOq6big0 zJH)Kg+uEuc;s5Nd8WnXv&C_f&OP%vxNyQPilWeX61WxR)gab+6^-YYbY37RAP@7iGApp=^e8>ZmxHflRe?}?en2fAwP}Zh1M>*CbaeTFL0;jL^SR#b??`GY zzU|UDvx=A7@^jgz+Rf^;sF}&pEMBB=K>Zd1n3FR?b+Z4u(leWQbM1FuUw^q;n0_|x z;zXK$$D0D(PgwQ1=7)LgF5@GKFDj(A*@q#53DB^#&e&L%8LOIQpBAK&r7b=94o^|zy&HtsP$>#I4bE?#aBWV26|o>Xhs`Jx)0 z!ij6nav@27;)G#bM4N3gmp5++%u9YW*_?g0;mtr@OhMgvwr^HEf4yMW#UkxT)8lj& zl_k8vs<6p%KGEZFLdgUgUAxjz7xNKj{akrXC$8P78X0y9H&o6w#aZJoXtoz1W^oPY zca_!0g~z0;Xa!XOFTRcDa=u*~MoD zf7Bt?2dA?H*X61*EXyp;ELcGC1ASweTW`xBsl6JWp8uTpkN7jXo(1Yo&5~ufksxMV z<$IS@o(iq%vwbk8WSufhX2m?h3Jkomp`x<0FbT`qykk?KZgZ_)ZuWNF>i|>q<{#3w ze)i%D3Nbs7vT{$8e3U81AZSpX!0Ho;2^xaTRGO6|lz&4|xvJE`LGE6>xi&5qkJY`F zoCuoWh9XBc=Xa0gO!ev*53VVegV2)~0}@#FtS6hvi*(x6EjR*VSf?Q`Ddy3nAct3W z?aYpip%?Sg*UY{y(5)$WGq?19vh75naO46?@iW> zCA@gjE?EB&)=W3EsaK(z+~YzJv0*q)hW+`1zap5dlN@rgFNuEzl z&=kSfk`3amT=#K5P(vWeTYI4u@+_o_23HV9vzdpAuGIQ5dcQv|n>noUWALhHj%j@Z z67FrF?8)aUA4>9b)=!xU&?A(7^w`b)8mnUw$rO|w?XusDk&&7?S%;&Jg?>d^ z;^nU|AwU=L7sIr9tpylT%rd(PMT!S#{%<))Zz7Y(%olizXM0*S!+qiOmiG(82!vrA zs80y(ysKQsH^2CvSoM3mriB_#9MDESGEDX>vMO8s5`T79af1H>qfO>FuzrD2+L+NK zOxnMOAvG(|jwZ7RJZ{9~3mNcOCcw1az?q%>LqS@nD1ahmy1xiLojF*$MGCzKV|j6y z8C3V3CM{!VbR6;7W;@Wh`S9j(Q9lxB(cbRJslDZU2xTr+27!%UN#gX@nutqtWJKWVA7C!Tr$B z-@5fYTpW2kbR7#p?h$`wYNg#w#0%}mUiquy{WoQQSR7Pg#wN=q#lsQU>y|7dYX>*I znryNDVwnyY_%B%|d|H5!8q;53lCW7YMLVQ0m8IQZtbmC|DkOW3dUVTU^HA-!7940O z&@T&XKw3|SQT2nh(iX!NNGeKW>-eeDcZrB}A}%BLMp^5oNb{~;C+}^YEe)p&yzOmG zjL++1@qa_3`jy58LWD@tPtxgFFb%1iR6Xe$6sa%;L`fMeAJv@5{2ORXv>Bkd92@f6 z8ZkhVwUo{JQ4p>I@#lz$8zz=epXT3Ecm=LQ^gXc9OGoayx3tOan__*m%IwIX%8Arh zXOx6i(RR+F^6?{@$w~ks_!rZFRs;@ih+v$;WCBbhD}4GqG@)NcuwsM@{SOK_N(r`6 zjkHZd)QF4leHh%?(HaMIvX z(lSQkO}8R%RBAqtxKZt6VWi8gl)x0C?N>j!;B4HTOsRz~FbUI1sSjF$L8i@l@aw;> zC*T^>;MNc@P13aJ3@q-2T!0zV`JXio44GOY+>HpcTI`mx${aVMQLMEAZ7GHUgQin# z3GnueyNiulVmQ|Kph>mN$0g0!_PPBe7Tc68SJ8z{kYz4e=32V~8m%@Z+1qs7G%wM_ zEYU&Oq z(FnJT#y{0{g%+)e$it}bjNB#m9*UoJWBzuNHe#r0d&*x05OFF+nZ2r?D!f&a0rfq& zi7>bG>scbFJ)L!}!vUso+8cwGk$#d)huwfBO@?qO<%n=~->3#RCnx%8p69M*0!gm<@!ST9S*q$fBi zlb--h)lXyy-)eR6R3ns^L4%&^8>lVmjDY4Qn1&Wj=T;S#W5UXfVOt8vA!s^tZzYDm zFx~PG6e(1(+=IC`v;Q1H{k$a9mY*r$9yImSheAvoXg470V9{aYQC1i}mcfJx6A1&U z`O+qgw315+OygBFq8Jpd>wzm55*Ao$^$4HbgBCJjIknL!Z}p&radVkUiC}PL52b