From 940f5913b7328868bb80c3562133f932fade032b Mon Sep 17 00:00:00 2001 From: Zebedee Nicholls Date: Fri, 24 Apr 2026 15:27:32 +1000 Subject: [PATCH 1/7] Start on clean up --- src/gcages/ar6/harmonisation.py | 4 +- src/gcages/ar6/infilling.py | 4 +- src/gcages/ar6/post_processing.py | 11 +- src/gcages/ar6/pre_processing.py | 8 +- src/gcages/ar6/scm_running.py | 4 +- src/gcages/cmip7_scenariomip/__init__.py | 4 +- src/gcages/cmip7_scenariomip/harmonisation.py | 2 + .../cmip7_scenariomip/post_processing.py | 337 +++--------------- .../pre_processing/pre_processor.py | 2 +- src/gcages/cmip7_scenariomip/scm_running.py | 4 +- src/gcages/scm_running/__init__.py | 2 + ...integration_cmip7_scenariomip_infilling.py | 1 + ...ation_cmip7_scenariomip_post_processing.py | 109 ------ ...tegration_cmip7_scenariomip_scm_running.py | 34 +- ...ion_ar6_scm_running_and_post_processing.py | 4 +- .../categories_AIM 3.0.csv | 4 +- .../categories_COFFEE 1.6.csv | 4 +- .../categories_GCAM 8s.csv | 4 +- .../categories_IMAGE 3.4.csv | 4 +- ...ries_MESSAGEix-GLOBIOM-GAINS 2.1-M-R12.csv | 4 +- .../categories_REMIND-MAgPIE 3.5-4.11.csv | 4 +- .../categories_WITCH 6.0.csv | 4 +- ...scenariomip_scm_running_post_processing.py | 101 +++--- ...ession_cmip7_scenariomip_whole_pipeline.py | 2 +- 24 files changed, 136 insertions(+), 525 deletions(-) delete mode 100644 tests/integration/cmip7-scenariomip/test_integration_cmip7_scenariomip_post_processing.py diff --git a/src/gcages/ar6/harmonisation.py b/src/gcages/ar6/harmonisation.py index d8b57009..97535a95 100644 --- a/src/gcages/ar6/harmonisation.py +++ b/src/gcages/ar6/harmonisation.py @@ -312,7 +312,7 @@ class AR6Harmoniser: """ Number of processes to use for parallel processing. - Set to 1 to process in serial. + Set to `None` to process serially. """ @aneris_overrides.validator @@ -476,7 +476,7 @@ def from_ar6_config( n_processes Number of processes to use for parallel processing. - Set to 1 to process in serial. + Set to `None` to process serially. Returns ------- diff --git a/src/gcages/ar6/infilling.py b/src/gcages/ar6/infilling.py index 6d688ed1..0013d9ce 100644 --- a/src/gcages/ar6/infilling.py +++ b/src/gcages/ar6/infilling.py @@ -486,7 +486,7 @@ class AR6Infiller: """ Number of processes to use for parallel processing. - Set to `None` to process in serial. + Set to `None` to process serially. """ def __call__(self, in_emissions: pd.DataFrame) -> pd.DataFrame: @@ -622,7 +622,7 @@ def from_ar6_config( # noqa: PLR0913 n_processes Number of processes to use for parallel processing. - Set to `None` to process in serial. + Set to `None` to process serially. Returns ------- diff --git a/src/gcages/ar6/post_processing.py b/src/gcages/ar6/post_processing.py index d484fe35..040d756b 100644 --- a/src/gcages/ar6/post_processing.py +++ b/src/gcages/ar6/post_processing.py @@ -349,7 +349,14 @@ def __call__(self, in_df: pd.DataFrame) -> PostProcessingResult: ) assert_data_is_all_numeric(in_df) assert_has_data_for_times( - in_df, name="in_df", times=[2100], allow_nan=False + in_df, + name="in_df", + times=[ + *self.gsat_assessment_pre_industrial_period, + *self.gsat_assessment_time_period, + 2100, + ], + allow_nan=False, ) if self.raw_gsat_variable_in not in in_df.index.get_level_values( @@ -557,7 +564,7 @@ def from_ar6_config( # noqa: PLR0913 n_processes Number of processes to use for parallel processing. - Set to 1 to process in serial. + Set to `None` to process serially. Returns ------- diff --git a/src/gcages/ar6/pre_processing.py b/src/gcages/ar6/pre_processing.py index 95448b7c..90d43ac5 100644 --- a/src/gcages/ar6/pre_processing.py +++ b/src/gcages/ar6/pre_processing.py @@ -310,7 +310,9 @@ def run_parallel_pre_processing( # noqa: PLR0913 If not supplied, we use a default description. n_processes - Number of parallel processes to use + Number of processes to use for parallel processing. + + Set to `None` to process serially. **kwargs Passed to `run_parallel` @@ -459,7 +461,7 @@ class AR6PreProcessor: """ Number of processes to use for parallel processing. - Set to `None` to process in serial. + Set to `None` to process serially. """ def __call__(self, in_emissions: pd.DataFrame) -> pd.DataFrame: @@ -600,7 +602,7 @@ def from_ar6_config( n_processes Number of processes to use for parallel processing. - Set to `None` to process in serial. + Set to `None` to process serially. Returns ------- diff --git a/src/gcages/ar6/scm_running.py b/src/gcages/ar6/scm_running.py index b862c227..74b95f48 100644 --- a/src/gcages/ar6/scm_running.py +++ b/src/gcages/ar6/scm_running.py @@ -221,7 +221,7 @@ class AR6SCMRunner: """ Number of processes to use for parallel processing. - Set to `None` to process in serial. + Set to `None` to process serially. """ def __call__( @@ -426,7 +426,7 @@ def from_ar6_config( # noqa: PLR0913 n_processes Number of processes to use for parallel processing. - Set to `None` to process in serial. + Set to `None` to process serially. Returns ------- diff --git a/src/gcages/cmip7_scenariomip/__init__.py b/src/gcages/cmip7_scenariomip/__init__.py index bc02f6fb..73c4f96c 100644 --- a/src/gcages/cmip7_scenariomip/__init__.py +++ b/src/gcages/cmip7_scenariomip/__init__.py @@ -11,7 +11,7 @@ CMIP7ScenarioMIPInfiller, ) from gcages.cmip7_scenariomip.post_processing import ( - CMIP7ScenarioMIPPostProcessor, + create_cmip7_scenariomip_postprocessor, ) from gcages.cmip7_scenariomip.pre_processing import ( CMIP7ScenarioMIPPreProcessingResult, @@ -25,11 +25,11 @@ __all__ = [ "CMIP7ScenarioMIPInfiller", - "CMIP7ScenarioMIPPostProcessor", "CMIP7ScenarioMIPPreProcessingResult", "CMIP7ScenarioMIPPreProcessor", "CMIP7ScenarioMIPSCMRunner", "ReaggregatorBasic", "ReaggregatorLike", "create_cmip7_scenariomip_global_harmoniser", + "create_cmip7_scenariomip_postprocessor", ] diff --git a/src/gcages/cmip7_scenariomip/harmonisation.py b/src/gcages/cmip7_scenariomip/harmonisation.py index 69d101ff..e3f646da 100644 --- a/src/gcages/cmip7_scenariomip/harmonisation.py +++ b/src/gcages/cmip7_scenariomip/harmonisation.py @@ -122,6 +122,8 @@ def create_cmip7_scenariomip_global_harmoniser( n_processes Number of processes to use for parallel processing. + Set to `None` to process serially. + Returns ------- : diff --git a/src/gcages/cmip7_scenariomip/post_processing.py b/src/gcages/cmip7_scenariomip/post_processing.py index ed79da79..abf6dab5 100644 --- a/src/gcages/cmip7_scenariomip/post_processing.py +++ b/src/gcages/cmip7_scenariomip/post_processing.py @@ -7,308 +7,63 @@ import multiprocessing import numpy as np -import pandas as pd -from attrs import define -from pandas_openscm.grouping import ( - fix_index_name_after_groupby_quantile, - groupby_except, -) -from pandas_openscm.index_manipulation import ( - set_index_levels_func, - update_index_levels_func, -) from gcages.ar6.post_processing import ( - categorise_scenarios, - get_exceedance_probabilities, - get_exceedance_probabilities_over_time, - get_temperatures_in_line_with_assessment, + AR6PostProcessor, ) -from gcages.post_processing import PostProcessingResult -@define -class CMIP7ScenarioMIPPostProcessor: - """ - CMIP7 ScenarioMIP fast-track post-processor +def create_cmip7_scenariomip_postprocessor( + run_checks: bool = True, + progress: bool = True, + n_processes: int | None = multiprocessing.cpu_count(), +) -> AR6PostProcessor: """ + Create a post-processor configured for CMIP7 ScenarioMIP - gsat_variable_name: str - """The name of the GSAT variable""" + Parameters + ---------- + run_checks + Should checks of the input and output data be performed? - gsat_in_line_with_assessment_variable_name: str - """The name of the GSAT variable once its been aligned with the assessment""" + If this is turned off, things are faster, + but error messages are much less clear if things go wrong. - gsat_assessment_median: float - """ - Median of the GSAT assessment - """ + progress + Should progress bars be shown for each operation? - gsat_assessment_time_period: tuple[int, ...] - """ - Time period over which the GSAT assessment applies - """ + n_processes + Number of processes to use for parallel processing. - gsat_assessment_pre_industrial_period: tuple[int, ...] - """ - Pre-industrial time period used for the GSAT assessment - """ + Set to `None` to process serially. - percentiles_to_calculate: tuple[float, ...] = (0.05, 0.33, 0.5, 0.67, 0.95) - """Percentiles to calculate and include in the output""" - exceedance_global_warming_levels: tuple[float, ...] = (1.5, 2.0, 2.5) + Returns + ------- + : + Initialised post-processor """ - Global-warming levels against which to calculate exceedance probabilities - """ - - run_checks: bool = True - """ - If `True`, run checks on both input and output data - - If you are sure about your workflow, - you can disable the checks to speed things up - (but we don't recommend this unless you really - are confident about what you're doing). - """ - - n_processes: int = multiprocessing.cpu_count() - """ - Number of processes to use for parallel processing. - - Set to 1 to process in serial. - """ - - def __call__(self, in_df: pd.DataFrame) -> PostProcessingResult: - """ - Do the post-processing - - Parameters - ---------- - in_df - Data to post-process - - Returns - ------- - : - Post-processed results - """ - if self.run_checks: - self._check_in_df(in_df) - - temperatures_in_line_with_assessment = update_index_levels_func( - get_temperatures_in_line_with_assessment( - in_df.loc[ - in_df.index.get_level_values("variable") == self.gsat_variable_name - ], - assessment_median=self.gsat_assessment_median, - assessment_time_period=self.gsat_assessment_time_period, - assessment_pre_industrial_period=self.gsat_assessment_pre_industrial_period, - group_cols=["climate_model", "model", "scenario"], - ), - {"variable": lambda x: self.gsat_in_line_with_assessment_variable_name}, - ) - - # Quantiles - temperatures_in_line_with_assessment_quantiles = ( - fix_index_name_after_groupby_quantile( - groupby_except( - temperatures_in_line_with_assessment, - "run_id", - ).quantile(list(self.percentiles_to_calculate)), # type: ignore # pandas-stubs confused - new_name="quantile", - ) - ) - - # Exceedance probabilities, peak warming and categorisation - exceedance_probabilities_over_time = get_exceedance_probabilities_over_time( - temperatures_in_line_with_assessment, - exceedance_thresholds_of_interest=self.exceedance_global_warming_levels, - group_cols=["model", "scenario", "climate_model"], - unit_col="unit", - groupby_except_levels="run_id", - ) - exceedance_probabilities = get_exceedance_probabilities( - temperatures_in_line_with_assessment, - exceedance_thresholds_of_interest=self.exceedance_global_warming_levels, - group_cols=["model", "scenario", "climate_model"], - unit_col="unit", - groupby_except_levels="run_id", - ) - - # Peak Warming - peak_warming_df = set_index_levels_func( - temperatures_in_line_with_assessment.max(axis="columns").to_frame("value"), - {"metric": "max"}, - ) - peak_warming_quantiles_df = fix_index_name_after_groupby_quantile( - groupby_except(peak_warming_df, "run_id").quantile( - np.array(self.percentiles_to_calculate) - ), - new_name="quantile", - ) - # Extract Series for categorization and final result - peak_warming_quantiles = peak_warming_quantiles_df["value"] - - # EOC Warming - eoc_warming_df = set_index_levels_func( - temperatures_in_line_with_assessment[2100].to_frame("value"), - {"metric": 2100}, - ) - eoc_warming_quantiles_df = fix_index_name_after_groupby_quantile( - groupby_except(eoc_warming_df, "run_id").quantile( - np.array(self.percentiles_to_calculate) - ), - new_name="quantile", - ) - eoc_warming_quantiles = eoc_warming_quantiles_df["value"] - - # Peak Year - peak_warming_year_df = set_index_levels_func( - update_index_levels_func( - temperatures_in_line_with_assessment.idxmax(axis="columns").to_frame( - "value" - ), - {"unit": lambda x: "yr"}, - ), - {"metric": "max_year"}, - ) - peak_warming_year_quantiles_df = fix_index_name_after_groupby_quantile( - groupby_except(peak_warming_year_df, "run_id").quantile( - np.array(self.percentiles_to_calculate) - ), - new_name="quantile", - ) - peak_warming_year_quantiles = peak_warming_year_quantiles_df["value"] - - # Categorisation - categories = categorise_scenarios( - peak_warming_quantiles=peak_warming_quantiles, - eoc_warming_quantiles=eoc_warming_quantiles, - group_levels=["climate_model", "model", "scenario"], - quantile_level="quantile", - ) - - # Metadata Compilation - metadata_run_id = pd.concat( - [ - peak_warming_df["value"], - eoc_warming_df["value"], - peak_warming_year_df["value"], - ] - ) - metadata_quantile = pd.concat( - [peak_warming_quantiles, eoc_warming_quantiles, peak_warming_year_quantiles] - ) - - # Compile climate output result - timeseries_run_id = pd.concat([temperatures_in_line_with_assessment]) - timeseries_quantile = pd.concat( - [temperatures_in_line_with_assessment_quantiles] - ) - timeseries_exceedance_probabilities = pd.concat( - [exceedance_probabilities_over_time] - ) - - metadata_exceedance_probabilities = exceedance_probabilities - metadata_categories = categories - - res = PostProcessingResult( - timeseries_run_id=timeseries_run_id, - timeseries_quantile=timeseries_quantile, - timeseries_exceedance_probabilities=timeseries_exceedance_probabilities, - metadata_run_id=metadata_run_id, - metadata_quantile=metadata_quantile, - metadata_exceedance_probabilities=metadata_exceedance_probabilities, - metadata_categories=metadata_categories, - ) - - return res - - @classmethod - def from_cmip7_scenariomip_config(cls) -> CMIP7ScenarioMIPPostProcessor: - """ - Initialise from the config used in CMIP7 ScenarioMIP - - Returns - ------- - : - Initialised post-processor - """ - return cls( - gsat_variable_name="Surface Air Temperature Change", - gsat_in_line_with_assessment_variable_name="Surface Temperature (GSAT)", - gsat_assessment_median=0.85, - gsat_assessment_time_period=tuple(range(1995, 2014 + 1)), - gsat_assessment_pre_industrial_period=tuple(range(1850, 1900 + 1)), - percentiles_to_calculate=( - 0.05, - 0.10, - 1.0 / 6.0, - 0.33, - 0.5, - 0.67, - 5.0 / 6.0, - 0.90, - 0.95, - ), - exceedance_global_warming_levels=(1.0, 4.01, 0.5), - run_checks=True, - ) - - def _check_in_df(self, in_df: pd.DataFrame) -> None: - """ - Perform checks on the input DataFrame - """ - # Check for known variable names - # Ensure that the variable we expect to process is actually present - available_vars = in_df.index.get_level_values("variable").unique() - if self.gsat_variable_name not in available_vars: - msg_tuple = ( - f"Required variable '{self.gsat_variable_name}' not found in input. " - f"Available variables: {available_vars.tolist()}" - ) - raise ValueError(msg_tuple) - - # Check for usable time axis - # Ensure columns are integers (years) and not empty - if in_df.columns.empty: - msg = "Input DataFrame has no time columns." - raise ValueError(msg) - - try: - # Check if all columns can be treated as integers - years = in_df.columns.astype(int) - except (ValueError, TypeError): - msg_tuple = ( - f"Input columns must be integer years. Found: {in_df.columns.tolist()}" - ) - raise ValueError(msg_tuple) - - # Ensure the time axis covers the required assessment periods - required_years = set(self.gsat_assessment_time_period) | set( - self.gsat_assessment_pre_industrial_period - ) - missing_years = required_years - set(years) - if missing_years: - msg_years = ( - "Input data is missing years required for assessment: " - f"{sorted(list(missing_years))}" - ) - raise ValueError(msg_years) - - # Check if metadata is appropriate/usable - # Check for required index levels that are used in grouping/processing - required_levels = ["model", "scenario", "climate_model", "run_id", "unit"] - missing_levels = [ - level for level in required_levels if level not in in_df.index.names - ] - if missing_levels: - msg_l = f"Input index is missing required metadata levels: {missing_levels}" - raise ValueError(msg_l) - - # Ensure there are no NaNs in the essential grouping metadata - for level in ["model", "scenario", "run_id"]: - if pd.isna(in_df.index.get_level_values(level)).any(): - msg_level = f"Found NaN values in required metadata level: '{level}'" - raise ValueError(msg_level) + res = AR6PostProcessor( + gsat_assessment_median=0.85, + gsat_assessment_time_period=tuple(range(1995, 2014 + 1)), + gsat_assessment_pre_industrial_period=tuple(range(1850, 1900 + 1)), + quantiles_of_interest=( + 0.05, + 0.10, + 1.0 / 6.0, + 0.33, + 0.5, + 0.67, + 5.0 / 6.0, + 0.90, + 0.95, + ), + exceedance_thresholds_of_interest=np.arange(1.0, 4.01, 0.5), + raw_gsat_variable_in="Surface Air Temperature Change", + assessed_gsat_variable="Surface Temperature (GSAT)", + run_checks=run_checks, + progress=progress, + n_processes=n_processes, + ) + + return res diff --git a/src/gcages/cmip7_scenariomip/pre_processing/pre_processor.py b/src/gcages/cmip7_scenariomip/pre_processing/pre_processor.py index f163d721..9e12f723 100644 --- a/src/gcages/cmip7_scenariomip/pre_processing/pre_processor.py +++ b/src/gcages/cmip7_scenariomip/pre_processing/pre_processor.py @@ -552,7 +552,7 @@ class CMIP7ScenarioMIPPreProcessor: """ Number of processes to use for parallel processing. - Set to `None` to process in serial. + Set to `None` to process serially. """ def __call__( diff --git a/src/gcages/cmip7_scenariomip/scm_running.py b/src/gcages/cmip7_scenariomip/scm_running.py index 179cf8a3..c59f4790 100644 --- a/src/gcages/cmip7_scenariomip/scm_running.py +++ b/src/gcages/cmip7_scenariomip/scm_running.py @@ -313,7 +313,7 @@ class CMIP7ScenarioMIPSCMRunner: """ Number of processes to use for parallel processing. - Set to `None` to process in serial. + Set to `None` to process serially. """ def __call__( # noqa: PLR0912 @@ -539,7 +539,7 @@ def from_cmip7_scenariomip_config( # noqa: PLR0913 n_processes Number of processes to use for parallel processing. - Set to `None` to process in serial. + Set to `None` to process serially. Returns ------- diff --git a/src/gcages/scm_running/__init__.py b/src/gcages/scm_running/__init__.py index d49be780..9b4f381f 100644 --- a/src/gcages/scm_running/__init__.py +++ b/src/gcages/scm_running/__init__.py @@ -262,6 +262,8 @@ def run_scms( # noqa: PLR0912, PLR0913 n_processes Number of parallel processes to use while running + TODO: fix this passing and handling + db Database in which to save the results diff --git a/tests/integration/cmip7-scenariomip/test_integration_cmip7_scenariomip_infilling.py b/tests/integration/cmip7-scenariomip/test_integration_cmip7_scenariomip_infilling.py index 5deaf43c..a1947eed 100644 --- a/tests/integration/cmip7-scenariomip/test_integration_cmip7_scenariomip_infilling.py +++ b/tests/integration/cmip7-scenariomip/test_integration_cmip7_scenariomip_infilling.py @@ -13,6 +13,7 @@ pytest.importorskip("pandas_indexing") +# TODO: clean this up class TestInfillBranches: """Test all branches in core infilling functions.""" diff --git a/tests/integration/cmip7-scenariomip/test_integration_cmip7_scenariomip_post_processing.py b/tests/integration/cmip7-scenariomip/test_integration_cmip7_scenariomip_post_processing.py deleted file mode 100644 index 91258967..00000000 --- a/tests/integration/cmip7-scenariomip/test_integration_cmip7_scenariomip_post_processing.py +++ /dev/null @@ -1,109 +0,0 @@ -""" -Tests of the `gcages.cmip7_scenariomip.post_processing` -""" - -import numpy as np -import pandas as pd -import pytest - -from gcages.cmip7_scenariomip.post_processing import CMIP7ScenarioMIPPostProcessor - -pix = pytest.importorskip("pandas_indexing") - - -def create_dummy_scm_results(years=range(1850, 2101), rand_weight=1): - """Creates a dummy DataFrame mimicking SCM output for multiple runs/scenarios.""" - index = pd.MultiIndex.from_product( - [ - ["MAGICC7"], - ["ModelA"], - ["Scenario1"], - ["World"], - ["run_1", "run_2"], # run_id - ["Surface Air Temperature Change"], - ["K"], - ], - names=[ - "climate_model", - "model", - "scenario", - "region", - "run_id", - "variable", - "unit", - ], - ) - - data = [] - for i in range(len(index)): - end_val = 1.0 + (i * 0.5) - base = np.linspace(0, end_val, len(years)) - rng = np.random.default_rng() - noise = rng.normal(0, 0.1, len(years)) * rand_weight - data.append(base + noise) - - return pd.DataFrame(data, index=index, columns=years) - - -def test_post_processor_initialization(): - """Tests the factory method from_cmip7_scenariomip_config.""" - processor = CMIP7ScenarioMIPPostProcessor.from_cmip7_scenariomip_config() - - assert processor.gsat_variable_name == "Surface Air Temperature Change" - assert processor.gsat_assessment_median == 0.85 - assert 1850 in processor.gsat_assessment_pre_industrial_period - assert 2014 in processor.gsat_assessment_time_period - - -@pytest.mark.parametrize("missing_col", ["model", "scenario", "run_id"]) -def test_post_processor_validation_errors(missing_col): - """Tests that _check_in_df catches missing metadata.""" - processor = CMIP7ScenarioMIPPostProcessor.from_cmip7_scenariomip_config() - df = create_dummy_scm_results() - - # Drop a required level - df_invalid = df.reset_index(missing_col, drop=True) - - with pytest.raises( - ValueError, match=f"missing required metadata levels:.*{missing_col}" - ): - processor(df_invalid) - - -def test_post_processor_missing_years(): - """Tests that validation fails if required assessment years are missing.""" - processor = CMIP7ScenarioMIPPostProcessor.from_cmip7_scenariomip_config() - # Create data only from 2020-2100 (missing pre-industrial period) - df = create_dummy_scm_results(years=range(2020, 2101)) - - with pytest.raises( - ValueError, match="Input data is missing years required for assessment" - ): - processor(df) - - -def test_post_processor_synthetic_input(): - """Tests that validation fails if required assessment years are missing.""" - processor = CMIP7ScenarioMIPPostProcessor.from_cmip7_scenariomip_config() - df = create_dummy_scm_results(years=range(1850, 2101), rand_weight=0) - - post_processed = processor(df) - - assert ( - post_processed.metadata_exceedance_probabilities.unstack("threshold").values - == [100, 100, 0] - ).all() - quantiles = ( - post_processed.metadata_quantile.loc[pix.isin(quantile=[0.05, 0.5, 0.95])] - .unstack(["quantile", "metric"]) - .round(2) - .values - ) - assert ( - quantiles[~np.isnan(quantiles)] - == np.array([1.12, 1.33, 1.53, 1.12, 1.33, 1.53, 2100, 2100, 2100]) - ).all() - assert ( - post_processed.metadata_run_id.values.round(2) - == np.array([1.10, 1.55, 1.10, 1.55, 2100, 2100]) - ).all() diff --git a/tests/integration/cmip7-scenariomip/test_integration_cmip7_scenariomip_scm_running.py b/tests/integration/cmip7-scenariomip/test_integration_cmip7_scenariomip_scm_running.py index 6f44783b..63a91b41 100644 --- a/tests/integration/cmip7-scenariomip/test_integration_cmip7_scenariomip_scm_running.py +++ b/tests/integration/cmip7-scenariomip/test_integration_cmip7_scenariomip_scm_running.py @@ -13,7 +13,6 @@ from gcages.cmip7_scenariomip.scm_running import ( CMIP7ScenarioMIPSCMRunner, get_complete_scenarios_for_magicc, - load_magicc_cfgs, ) from gcages.testing import guess_magicc_exe @@ -33,41 +32,10 @@ / "magicc-v7.6.0a3/configs/magicc-ar7-fast-track-drawnset-v0-3-0.json" ) -# Only works if pandas_indexing installed -pytest.importorskip("pandas_indexing") # Only works if openscm-runner installed pytest.importorskip("openscm_runner.adapters") -def test_load_magicc_cfgs_sets_common_and_physical_cfgs(tmp_path: Path): - prob = tmp_path / "prob.json" - prob.write_text( - """{ - "configurations": [ - { - "paraset_id": "cfg-1", - "nml_allcfgs": {"SCENARIO": "foo", "STARTYEAR": 1750} - } - ] - }""", - encoding="utf-8", # Optional, good practice - ) - - out = load_magicc_cfgs( - prob, output_variables=("Surface Air Temperature Change",), startyear=1750 - ) - - assert list(out) == ["MAGICC7"] - assert len(out["MAGICC7"]) == 1 - cfg = out["MAGICC7"][0] - assert cfg["run_id"] == "cfg-1" - assert cfg["scenario"] == "foo" - assert cfg["startyear"] == 1750 - assert cfg["out_ascii_binary"] == "BINARY" - assert cfg["out_binary_format"] == 2 - assert cfg["out_dynamic_vars"] - - @pytest.mark.skip_ci_default @pytest.mark.slow def test_get_complete_scenarios_for_magicc_adds_history_and_keeps_scenarios(): @@ -231,7 +199,7 @@ def test_get_complete_scenarios_for_magicc_interpolates_missing_years(): def test_cmip7_scenariomip_scmrunner( # noqa: PLR0913 scenario, history_path, run_checks, harmonisation_year, error_message, monkeypatch ): - monkeypatch.delenv("MAGICC_EXECUTABLE_7") + monkeypatch.delenv("MAGICC_EXECUTABLE_7", raising=False) scm_runner = CMIP7ScenarioMIPSCMRunner.from_cmip7_scenariomip_config( magicc_exe_path=guess_magicc_exe(CMIP7_SCENARIOMIP_MAGICC_EXECUTABLES_DIR), magicc_prob_distribution_path=CMIP7_SCENARIOMIP_MAGICC_PROBABILISTIC_CONFIG_FILE, diff --git a/tests/regression/ar6/test_regression_ar6_scm_running_and_post_processing.py b/tests/regression/ar6/test_regression_ar6_scm_running_and_post_processing.py index e6c48941..290235ad 100644 --- a/tests/regression/ar6/test_regression_ar6_scm_running_and_post_processing.py +++ b/tests/regression/ar6/test_regression_ar6_scm_running_and_post_processing.py @@ -170,7 +170,7 @@ def test_individual_scenario(model, scenario, monkeypatch): ] ) - monkeypatch.delenv("MAGICC_EXECUTABLE_7") + monkeypatch.delenv("MAGICC_EXECUTABLE_7", raising=False) magicc_exe = guess_magicc_exe(AR6_MAGICC_EXECUTABLES_DIR) scm_runner = AR6SCMRunner.from_ar6_config( # Has to be parallel otherwise this is too slow @@ -286,7 +286,7 @@ def test_parallel(tmp_path, monkeypatch): exp_temperature_percentiles = pd.concat(exp_temperature_percentiles_l) exp_metadata = pd.concat(exp_metadata_l) - monkeypatch.delenv("MAGICC_EXECUTABLE_7") + monkeypatch.delenv("MAGICC_EXECUTABLE_7", raising=False) magicc_exe = guess_magicc_exe(AR6_MAGICC_EXECUTABLES_DIR) scm_runner = AR6SCMRunner.from_ar6_config( n_processes=multiprocessing.cpu_count(), diff --git a/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_AIM 3.0.csv b/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_AIM 3.0.csv index cac44ac1..dade1e31 100644 --- a/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_AIM 3.0.csv +++ b/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_AIM 3.0.csv @@ -1,4 +1,2 @@ -,,,value,value -metric,,,category,category_name -climate_model,model,scenario,, +climate_model,model,scenario,category,category_name MAGICCv7.6.0a3,AIM 3.0,SSP2 - Low Overshoot_a,C2,C2: return warming to 1.5°C (>50%) after a high overshoot diff --git a/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_COFFEE 1.6.csv b/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_COFFEE 1.6.csv index 478f5995..fc518186 100644 --- a/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_COFFEE 1.6.csv +++ b/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_COFFEE 1.6.csv @@ -1,4 +1,2 @@ -,,,value,value -metric,,,category,category_name -climate_model,model,scenario,, +climate_model,model,scenario,category,category_name MAGICCv7.6.0a3,COFFEE 1.6,SSP2 - Medium-Low Emissions,C5,C5: limit warming to 2.5°C (>50%) diff --git a/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_GCAM 8s.csv b/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_GCAM 8s.csv index 05ea2b23..4c2dcd17 100644 --- a/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_GCAM 8s.csv +++ b/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_GCAM 8s.csv @@ -1,4 +1,2 @@ -,,,value,value -metric,,,category,category_name -climate_model,model,scenario,, +climate_model,model,scenario,category,category_name MAGICCv7.6.0a3,GCAM 8s,SSP3 - High Emissions,C7,C7: limit warming to 4°C (>50%) diff --git a/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_IMAGE 3.4.csv b/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_IMAGE 3.4.csv index c588cda0..bd1e816f 100644 --- a/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_IMAGE 3.4.csv +++ b/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_IMAGE 3.4.csv @@ -1,4 +1,2 @@ -,,,value,value -metric,,,category,category_name -climate_model,model,scenario,, +climate_model,model,scenario,category,category_name MAGICCv7.6.0a3,IMAGE 3.4,SSP2 - Medium Emissions,C6,C6: limit warming to 3°C (>50%) diff --git a/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_MESSAGEix-GLOBIOM-GAINS 2.1-M-R12.csv b/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_MESSAGEix-GLOBIOM-GAINS 2.1-M-R12.csv index 7fe10014..54c20f65 100644 --- a/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_MESSAGEix-GLOBIOM-GAINS 2.1-M-R12.csv +++ b/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_MESSAGEix-GLOBIOM-GAINS 2.1-M-R12.csv @@ -1,4 +1,2 @@ -,,,value,value -metric,,,category,category_name -climate_model,model,scenario,, +climate_model,model,scenario,category,category_name MAGICCv7.6.0a3,MESSAGEix-GLOBIOM-GAINS 2.1-M-R12,SSP2 - Low Emissions,C3,C3: limit warming to 2°C (>67%) diff --git a/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_REMIND-MAgPIE 3.5-4.11.csv b/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_REMIND-MAgPIE 3.5-4.11.csv index f0f125dc..d80b77a3 100644 --- a/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_REMIND-MAgPIE 3.5-4.11.csv +++ b/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_REMIND-MAgPIE 3.5-4.11.csv @@ -1,4 +1,2 @@ -,,,value,value -metric,,,category,category_name -climate_model,model,scenario,, +climate_model,model,scenario,category,category_name MAGICCv7.6.0a3,REMIND-MAgPIE 3.5-4.11,SSP1 - Very Low Emissions,C2,C2: return warming to 1.5°C (>50%) after a high overshoot diff --git a/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_WITCH 6.0.csv b/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_WITCH 6.0.csv index 7ccc9b58..df00e279 100644 --- a/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_WITCH 6.0.csv +++ b/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/categories_WITCH 6.0.csv @@ -1,4 +1,2 @@ -,,,value,value -metric,,,category,category_name -climate_model,model,scenario,, +climate_model,model,scenario,category,category_name MAGICCv7.6.0a3,WITCH 6.0,SSP5 - Medium-Low Emissions_a,C6,C6: limit warming to 3°C (>50%) diff --git a/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_scm_running_post_processing.py b/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_scm_running_post_processing.py index 32724575..9c080848 100644 --- a/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_scm_running_post_processing.py +++ b/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_scm_running_post_processing.py @@ -8,12 +8,16 @@ from functools import partial from pathlib import Path +import numpy as np import pandas as pd import pytest from pandas_openscm.index_manipulation import update_index_levels_func +from pandas_openscm.indexing import mi_loc from pandas_openscm.io import load_timeseries_csv -from gcages.cmip7_scenariomip.post_processing import CMIP7ScenarioMIPPostProcessor +from gcages.cmip7_scenariomip.post_processing import ( + create_cmip7_scenariomip_postprocessor, +) from gcages.cmip7_scenariomip.scm_running import ( CMIP7ScenarioMIPSCMRunner, ) @@ -54,15 +58,12 @@ KEY_CMIP7_SCENARIOMIP_TESTING_MODEL_SCENARIOS ) def test_individual_scenario(model, scenario, monkeypatch): - # Loading infilled results - file = CMIP7_SCENARIOMIP_OUT_DIR / f"{model}_{scenario}_complete.csv" complete = load_timeseries_csv( - file, + CMIP7_SCENARIOMIP_OUT_DIR / f"{model}_{scenario}_complete.csv", lower_column_names=True, index_columns=["model", "scenario", "region", "variable", "unit"], out_columns_type=int, ) - # Select scenario and drop aggregated/cumulative rows complete = complete.loc[ pix.ismatch(scenario=scenario) & ~pix.ismatch(variable=["**Kyoto**", "Cumulative**", "**CO2", "**GHG**"]) @@ -78,10 +79,22 @@ def test_individual_scenario(model, scenario, monkeypatch): }, ) - # Loading expected results - file = CMIP7_SCENARIOMIP_OUT_DIR / f"{model}_{scenario}_GSAT.csv" + monkeypatch.delenv("MAGICC_EXECUTABLE_7", raising=False) + scm_runner = CMIP7ScenarioMIPSCMRunner.from_cmip7_scenariomip_config( + magicc_exe_path=guess_magicc_exe(CMIP7_SCENARIOMIP_MAGICC_EXECUTABLES_DIR), + magicc_prob_distribution_path=CMIP7_SCENARIOMIP_MAGICC_PROBABILISTIC_CONFIG_FILE, + output_variables=("Surface Air Temperature Change",), + historical_emissions_path=CMIP7_SCENARIOMIP_HISTORICAL_GLOBAL_EMISSIONS_FILE, + harmonisation_year=HARMONISATION_YEAR, + n_processes=multiprocessing.cpu_count() - 2, + run_checks=True, + progress=True, + ) + + scm_results = scm_runner(complete) + exp_temperature = load_timeseries_csv( - file, + CMIP7_SCENARIOMIP_OUT_DIR / f"{model}_{scenario}_GSAT.csv", lower_column_names=True, index_columns=[ "climate_model", @@ -96,37 +109,26 @@ def test_individual_scenario(model, scenario, monkeypatch): ) exp_temperature.columns.name = "time" - monkeypatch.delenv("MAGICC_EXECUTABLE_7") - scm_runner = CMIP7ScenarioMIPSCMRunner.from_cmip7_scenariomip_config( - magicc_exe_path=guess_magicc_exe(CMIP7_SCENARIOMIP_MAGICC_EXECUTABLES_DIR), - magicc_prob_distribution_path=CMIP7_SCENARIOMIP_MAGICC_PROBABILISTIC_CONFIG_FILE, - output_variables=("Surface Air Temperature Change",), - historical_emissions_path=CMIP7_SCENARIOMIP_HISTORICAL_GLOBAL_EMISSIONS_FILE, - harmonisation_year=HARMONISATION_YEAR, - n_processes=multiprocessing.cpu_count(), - ) - - scm_results = scm_runner(complete) - assert_frame_equal( - scm_results[ - scm_results.index.get_level_values("variable").str.contains( - "Surface Air Temperature Change" - ) - ].iloc[:10], + mi_loc( + scm_results, + exp_temperature.index.droplevel( + exp_temperature.index.names.difference(["variable", "run_id"]) + ), + ), exp_temperature, + rtol=1e-5, ) - # Post-processing - post_processor = CMIP7ScenarioMIPPostProcessor.from_cmip7_scenariomip_config() + post_processor = create_cmip7_scenariomip_postprocessor( + progress=False, + n_processes=None, + ) post_processed = post_processor(scm_results) - # Loading and assessing quantiles timeseries results - file = ( - CMIP7_SCENARIOMIP_OUT_DIR / f"assessed-warming-timeseries-quantiles_{model}.csv" - ) exp_quantiles = load_timeseries_csv( - file, + CMIP7_SCENARIOMIP_OUT_DIR + / f"assessed-warming-timeseries-quantiles_{model}.csv", lower_column_names=True, index_columns=[ "climate_model", @@ -140,29 +142,24 @@ def test_individual_scenario(model, scenario, monkeypatch): out_columns_type=int, out_columns_name="time", ) - exp_quantiles.index = exp_quantiles.index.set_levels( - exp_quantiles.index.levels[exp_quantiles.index.names.index("quantile")].round( - 4 - ), - level="quantile", - ) processed_quantiles = post_processed.timeseries_quantile.iloc[:, 250:] - processed_quantiles.index = processed_quantiles.index.set_levels( - exp_quantiles.index.levels[exp_quantiles.index.names.index("quantile")].round( - 4 - ), - level="quantile", - ) + exp_quantiles = update_index_levels_func( + exp_quantiles, {"quantile": partial(np.round, decimals=4)} + ) + processed_quantiles = update_index_levels_func( + processed_quantiles, {"quantile": partial(np.round, decimals=4)} + ) assert_frame_equal( - processed_quantiles, - exp_quantiles, - rtol=1e-8, + processed_quantiles.loc[:, exp_quantiles.columns], exp_quantiles, rtol=1e-5 ) - # Loading and categories - file = CMIP7_SCENARIOMIP_OUT_DIR / f"categories_{model}.csv" - exp_categories = pd.read_csv(file) + exp_categories = pd.read_csv( + CMIP7_SCENARIOMIP_OUT_DIR / f"categories_{model}.csv", + index_col=["climate_model", "model", "scenario"], + ) + exp_categories.columns.name = "metric" - assert post_processed.metadata_categories.values[0] == exp_categories["value.1"][2] - assert post_processed.metadata_categories.values[1] == exp_categories["value"][2] + assert_frame_equal( + post_processed.metadata_categories.unstack("metric"), exp_categories + ) diff --git a/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_whole_pipeline.py b/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_whole_pipeline.py index 7760d339..749c2a22 100644 --- a/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_whole_pipeline.py +++ b/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_whole_pipeline.py @@ -302,7 +302,7 @@ def test_whole_pipeline(model, scenario, monkeypatch): # noqa: PLR0915 out_columns_name="time", ) - monkeypatch.delenv("MAGICC_EXECUTABLE_7") + monkeypatch.delenv("MAGICC_EXECUTABLE_7", raising=False) scm_runner = CMIP7ScenarioMIPSCMRunner.from_cmip7_scenariomip_config( magicc_exe_path=guess_magicc_exe(CMIP7_SCENARIOMIP_MAGICC_EXECUTABLES_DIR), magicc_prob_distribution_path=CMIP7_SCENARIOMIP_MAGICC_PROBABILISTIC_CONFIG_FILE, From 2271c428cf7fb47937111b1b07f80f994028ba2f Mon Sep 17 00:00:00 2001 From: Zebedee Nicholls Date: Sat, 25 Apr 2026 16:14:30 +1000 Subject: [PATCH 2/7] Remove tests that should be done differently --- ...tegration_cmip7_scenariomip_scm_running.py | 124 ------------------ 1 file changed, 124 deletions(-) diff --git a/tests/integration/cmip7-scenariomip/test_integration_cmip7_scenariomip_scm_running.py b/tests/integration/cmip7-scenariomip/test_integration_cmip7_scenariomip_scm_running.py index 63a91b41..391b1c41 100644 --- a/tests/integration/cmip7-scenariomip/test_integration_cmip7_scenariomip_scm_running.py +++ b/tests/integration/cmip7-scenariomip/test_integration_cmip7_scenariomip_scm_running.py @@ -2,42 +2,15 @@ Tests of the `gcages.cmip7_scenariomip.scm_running` """ -import re -from pathlib import Path - import numpy as np import pandas as pd import pytest -from gcages.assertions import MissingDataForTimesError from gcages.cmip7_scenariomip.scm_running import ( - CMIP7ScenarioMIPSCMRunner, get_complete_scenarios_for_magicc, ) -from gcages.testing import guess_magicc_exe - -PROCESSED_CMIP7_SCENARIOMIP_INPUT_DIR = ( - Path(__file__).parents[2] - / "regression/cmip7-scenariomip/cmip7-scenariomip-workflow-inputs" -) -CMIP7_SCENARIOMIP_HISTORICAL_GLOBAL_EMISSIONS_FILE = ( - PROCESSED_CMIP7_SCENARIOMIP_INPUT_DIR / "history_cmip7_scenariomip.csv" -) -CMIP7_SCENARIOMIP_MAGICC_EXECUTABLES_DIR = ( - PROCESSED_CMIP7_SCENARIOMIP_INPUT_DIR / "magicc-v7.6.0a3/bin" -) -CMIP7_SCENARIOMIP_MAGICC_PROBABILISTIC_CONFIG_FILE = ( - PROCESSED_CMIP7_SCENARIOMIP_INPUT_DIR - / "magicc-v7.6.0a3/configs/magicc-ar7-fast-track-drawnset-v0-3-0.json" -) -# Only works if openscm-runner installed -pytest.importorskip("openscm_runner.adapters") - - -@pytest.mark.skip_ci_default -@pytest.mark.slow def test_get_complete_scenarios_for_magicc_adds_history_and_keeps_scenarios(): scenario = pd.DataFrame( { @@ -77,31 +50,6 @@ def test_get_complete_scenarios_for_magicc_adds_history_and_keeps_scenarios(): assert out.loc[("M1", "S1", "World", "Emissions|CO2", "MtCO2/yr"), 2015] == 10.0 assert out.loc[("M1", "S1", "World", "Emissions|CH4", "MtCH4/yr"), 2016] == 14.0 - scm_runner_cfg = CMIP7ScenarioMIPSCMRunner.from_cmip7_scenariomip_config( - magicc_exe_path=guess_magicc_exe(CMIP7_SCENARIOMIP_MAGICC_EXECUTABLES_DIR), - magicc_prob_distribution_path=CMIP7_SCENARIOMIP_MAGICC_PROBABILISTIC_CONFIG_FILE, - output_variables=("Surface Air Temperature Change",), - run_checks=False, - ) - scm_runner = CMIP7ScenarioMIPSCMRunner( - climate_models_cfgs=scm_runner_cfg.climate_models_cfgs, - output_variables=("Surface Air Temperature Change",), - historical_emissions=history.reset_index( - level=[lvl for lvl in ["model", "scenario"] if lvl in history.index.names], - drop=True, - ), - harmonisation_year=2015, - run_checks=True, - res_column_type=int, - ) - - error_message = re.escape( - '"in_emissions is missing data for the following times: [2100]. ' - "Available times: Index([2015, 2016], dtype='int64')" - ) - with pytest.raises(MissingDataForTimesError, match=error_message): - scm_runner.__call__(scenario) - def test_get_complete_scenarios_for_magicc_interpolates_missing_years(): scenarios = pd.DataFrame( @@ -139,75 +87,3 @@ def test_get_complete_scenarios_for_magicc_interpolates_missing_years(): assert list(out.columns) == [2015, 2016, 2017] assert out.loc[("M1", "S1", "CO2", "MtCO2/yr"), 2016] == pytest.approx(11.0) assert out.loc[("M1", "S1", "CH4", "MtCH4/yr"), 2016] == 12.0 - - -@pytest.mark.skip_ci_default -@pytest.mark.magicc_v760a3 -@pytest.mark.parametrize( - "scenario, history_path,run_checks, harmonisation_year,error_message", - [ - ( - pd.DataFrame( - {2020 + i: [12.0 + i / 10, 14.0 + i / 10] for i in range(10)}, - index=pd.MultiIndex.from_tuples( - [ - ("M1", "S1", "CO2", "MtCO2/yr"), - ("M1", "S1", "CH4", "MtCH4/yr"), - ], - names=["model", "scenario", "variable", "unit"], - ), - ), - None, - True, - 2023, - "`self.historical_emissions` must be set to check the infilling", - ), - ( - pd.DataFrame( - {2020 + i: [12.0 + i / 10, 14.0 + i / 10] for i in range(10)}, - index=pd.MultiIndex.from_tuples( - [ - ("M1", "S1", "CO2", "MtCO2/yr"), - ("M1", "S1", "CH4", "MtCH4/yr"), - ], - names=["model", "scenario", "variable", "unit"], - ), - ), - CMIP7_SCENARIOMIP_HISTORICAL_GLOBAL_EMISSIONS_FILE, - True, - None, - "`self.harmonisation_year` must be set to check the infilling", - ), - ( - pd.DataFrame( - {2020 + i: [12.0 + i / 10, 14.0 + i / 10] for i in range(100)}, - index=pd.MultiIndex.from_tuples( - [ - ("M1", "S1", "Emissions|CO2", "MtCO2/yr"), - ("M1", "S1", "Emissions|CH4", "MtCH4/yr"), - ], - names=["model", "scenario", "variable", "unit"], - ), - ), - None, - False, - 2023, - "Emissions starting year must be set to `2015`", - ), - ], -) -def test_cmip7_scenariomip_scmrunner( # noqa: PLR0913 - scenario, history_path, run_checks, harmonisation_year, error_message, monkeypatch -): - monkeypatch.delenv("MAGICC_EXECUTABLE_7", raising=False) - scm_runner = CMIP7ScenarioMIPSCMRunner.from_cmip7_scenariomip_config( - magicc_exe_path=guess_magicc_exe(CMIP7_SCENARIOMIP_MAGICC_EXECUTABLES_DIR), - magicc_prob_distribution_path=CMIP7_SCENARIOMIP_MAGICC_PROBABILISTIC_CONFIG_FILE, - output_variables=("Surface Air Temperature Change",), - historical_emissions_path=history_path, - harmonisation_year=harmonisation_year, - run_checks=run_checks, - ) - - with pytest.raises(AssertionError, match=error_message): - scm_runner(scenario) From 2faf9b6dc977bc648204bd6d2f80101fded0e174 Mon Sep 17 00:00:00 2001 From: Zebedee Nicholls Date: Sat, 25 Apr 2026 17:34:01 +1000 Subject: [PATCH 3/7] Major refactor --- src/gcages/ar6/scm_running.py | 136 ++++------ src/gcages/cmip7_scenariomip/scm_running.py | 247 ++++++++---------- src/gcages/scm_running/__init__.py | 64 ++++- src/gcages/scm_running/default_variables.py | 48 ++++ src/gcages/scm_running/magicc.py | 62 +++++ ...ion_ar6_scm_running_and_post_processing.py | 8 +- ...scenariomip_scm_running_post_processing.py | 1 - ...ession_cmip7_scenariomip_whole_pipeline.py | 1 - 8 files changed, 334 insertions(+), 233 deletions(-) create mode 100644 src/gcages/scm_running/default_variables.py diff --git a/src/gcages/ar6/scm_running.py b/src/gcages/ar6/scm_running.py index 74b95f48..c78326e9 100644 --- a/src/gcages/ar6/scm_running.py +++ b/src/gcages/ar6/scm_running.py @@ -5,7 +5,6 @@ from __future__ import annotations import multiprocessing -import os from functools import partial from pathlib import Path from typing import Any @@ -23,74 +22,40 @@ assert_index_is_multiindex, ) from gcages.completeness import assert_all_groups_are_complete -from gcages.exceptions import MissingOptionalDependencyError from gcages.harmonisation import assert_harmonised from gcages.hashing import get_file_hash from gcages.renaming import SupportedNamingConventions, convert_variable_name from gcages.scm_running import ( + DEFAULT_OUTPUT_VARIABLES, convert_openscm_runner_output_names_to_magicc_output_names, run_scms, ) -from gcages.scm_running.magicc import load_magicc_probabilistic_config -from gcages.units_helpers import assert_has_no_pint_incompatible_characters - -DEFAULT_OUTPUT_VARIABLES: tuple[str, ...] = ( - # GSAT - "Surface Air Temperature Change", - # GMST - "Surface Air Ocean Blended Temperature Change", - # ERFs - "Effective Radiative Forcing", - "Effective Radiative Forcing|Anthropogenic", - "Effective Radiative Forcing|Aerosols", - "Effective Radiative Forcing|Aerosols|Direct Effect", - "Effective Radiative Forcing|Aerosols|Direct Effect|BC", - "Effective Radiative Forcing|Aerosols|Direct Effect|OC", - "Effective Radiative Forcing|Aerosols|Direct Effect|SOx", - "Effective Radiative Forcing|Aerosols|Indirect Effect", - "Effective Radiative Forcing|Greenhouse Gases", - "Effective Radiative Forcing|CO2", - "Effective Radiative Forcing|CH4", - "Effective Radiative Forcing|N2O", - "Effective Radiative Forcing|F-Gases", - "Effective Radiative Forcing|Montreal Protocol Halogen Gases", - "Effective Radiative Forcing|Ozone", - # Heat uptake - "Heat Uptake", - # "Heat Uptake|Ocean", - # Atmospheric concentrations - "Atmospheric Concentrations|CO2", - "Atmospheric Concentrations|CH4", - "Atmospheric Concentrations|N2O", - # Carbon cycle - "Net Atmosphere to Land Flux|CO2", - "Net Atmosphere to Ocean Flux|CO2", - # Permafrost - "Net Land to Atmosphere Flux|CO2|Earth System Feedbacks|Permafrost", - "Net Land to Atmosphere Flux|CH4|Earth System Feedbacks|Permafrost", +from gcages.scm_running.magicc import ( + check_magicc7_version, + combine_probabilistic_and_common_cfg, + load_magicc_probabilistic_config, ) -""" -Default output variables - -Note that it can be a bit of work -to get these variables to actually appear in the output, -depending on which simple climate model you're using. -""" +from gcages.units_helpers import assert_has_no_pint_incompatible_characters -def check_ar6_magicc7_version() -> None: +def check_ar6_magicc7_version(magicc_exe_path: Path) -> None: """ Check that the MAGICC7 version is what was used in AR6 - """ - try: - import openscm_runner.adapters - except ImportError as exc: - raise MissingOptionalDependencyError( - "check_ar6_magicc7_version", requirement="openscm_runner" - ) from exc - if openscm_runner.adapters.MAGICC7.get_version() != "v7.5.3": # type: ignore - raise AssertionError(openscm_runner.adapters.MAGICC7.get_version()) # type: ignore + Parameters + ---------- + magicc_exe_path + Path to the MAGICC executable to use + + Raises + ------ + AssertionError + The MAGICC version is not what we expect + + MissingOptionalDependencyError + [openscm-runner](https://github.com/openscm/openscm-runner) is not installed + """ + check_magicc7_version(magicc_exe_path, expected_version="v7.5.3") def load_ar6_magicc_probabilistic_config(filepath: Path) -> list[dict[str, Any]]: @@ -123,7 +88,12 @@ def load_ar6_magicc_probabilistic_config(filepath: Path) -> list[dict[str, Any]] cfgs = load_magicc_probabilistic_config(filepath) - return cfgs + # Common config that affect MAGICC behaviour + common_cfg = {"startyear": 1750} + + run_config = combine_probabilistic_and_common_cfg(cfgs, common_cfg=common_cfg) + + return run_config @define @@ -224,6 +194,13 @@ class AR6SCMRunner: Set to `None` to process serially. """ + magicc_exe_path: Path | None = None + """ + Path to the MAGICC executable to use + + Only required if we're running MAGICC + """ + def __call__( self, in_emissions: pd.DataFrame, force_rerun: bool = False ) -> pd.DataFrame: @@ -280,6 +257,20 @@ def __call__( complete_index=self.historical_emissions.index.droplevel("unit"), ) + if self.force_interpolate_to_yearly: + # TODO: put interpolate to annual steps in pandas-openscm + # Interpolate to ensure no nans. + for y in range( + in_emissions.columns.min(), + in_emissions.columns.max() + 1, + ): + if y not in in_emissions: + in_emissions[y] = np.nan + + in_emissions = ( + in_emissions.sort_index(axis="columns").T.interpolate("index").T + ) + openscm_runner_emissions = update_index_levels_func( in_emissions, { @@ -290,32 +281,19 @@ def __call__( ) }, ) - if self.force_interpolate_to_yearly: - # TODO: put interpolate to annual steps in pandas-openscm - # Interpolate to ensure no nans. - for y in range( - openscm_runner_emissions.columns.min(), - openscm_runner_emissions.columns.max() + 1, - ): - if y not in openscm_runner_emissions: - openscm_runner_emissions[y] = np.nan - - openscm_runner_emissions = ( - openscm_runner_emissions.sort_index(axis="columns") - .T.interpolate("index") - .T - ) scm_results_maybe = run_scms( openscm_runner_emissions, climate_models_cfgs=self.climate_models_cfgs, output_variables=self.output_variables, scenario_group_levels=["model", "scenario"], + # TODO: fix value in run_scms n_processes=self.n_processes if self.n_processes is not None else 1, db=self.db, verbose=self.verbose, batch_size_scenarios=self.batch_size_scenarios, force_rerun=force_rerun, + magicc_exe_path=self.magicc_exe_path, ) if self.db is not None: @@ -364,8 +342,9 @@ def from_ar6_config( # noqa: PLR0913 output_variables: tuple[str, ...] = DEFAULT_OUTPUT_VARIABLES, batch_size_scenarios: int | None = None, db: OpenSCMDB | None = None, + # TODO: Add helper for loading historical_emissions for MAGICC historical_emissions: pd.DataFrame | None = None, - harmonisation_year: int | None = None, + harmonisation_year: int = 2015, verbose: bool = True, run_checks: bool = True, progress: bool = True, @@ -433,16 +412,13 @@ def from_ar6_config( # noqa: PLR0913 : Initialised SCM runner """ - os.environ["MAGICC_EXECUTABLE_7"] = str(magicc_exe_path) - check_ar6_magicc7_version() + check_ar6_magicc7_version(magicc_exe_path) magicc_ar6_prob_cfg = load_ar6_magicc_probabilistic_config( magicc_prob_distribution_path ) - startyear = 1750 common_cfg = { - "startyear": startyear, "out_dynamic_vars": convert_openscm_runner_output_names_to_magicc_output_names( # noqa: E501 output_variables ), @@ -450,7 +426,10 @@ def from_ar6_config( # noqa: PLR0913 "out_binary_format": 2, } - run_config = [{**common_cfg, **base_cfg} for base_cfg in magicc_ar6_prob_cfg] + run_config = combine_probabilistic_and_common_cfg( + magicc_ar6_prob_cfg, common_cfg=common_cfg + ) + magicc_full_distribution_n_config = 600 if len(run_config) != magicc_full_distribution_n_config: raise AssertionError(len(run_config)) @@ -467,4 +446,5 @@ def from_ar6_config( # noqa: PLR0913 n_processes=n_processes, force_interpolate_to_yearly=True, # MAGICC safer with annual input res_column_type=int, # annual output by default + magicc_exe_path=magicc_exe_path, ) diff --git a/src/gcages/cmip7_scenariomip/scm_running.py b/src/gcages/cmip7_scenariomip/scm_running.py index c59f4790..ead548b7 100644 --- a/src/gcages/cmip7_scenariomip/scm_running.py +++ b/src/gcages/cmip7_scenariomip/scm_running.py @@ -9,9 +9,7 @@ from __future__ import annotations -import json import multiprocessing -import os from functools import partial from pathlib import Path from typing import Any, cast @@ -32,141 +30,80 @@ load_cmip7_scenariomip_historical_emissions, ) from gcages.completeness import assert_all_groups_are_complete -from gcages.exceptions import MissingOptionalDependencyError from gcages.harmonisation import assert_harmonised +from gcages.hashing import get_file_hash from gcages.renaming import SupportedNamingConventions, convert_variable_name from gcages.scm_running import ( + DEFAULT_OUTPUT_VARIABLES, convert_openscm_runner_output_names_to_magicc_output_names, run_scms, ) -from gcages.units_helpers import assert_has_no_pint_incompatible_characters - -SCM_OUTPUT_VARIABLES_DEFAULT: tuple[str, ...] = ( - # GSAT - "Surface Air Temperature Change", - # # GMST - # "Surface Air Ocean Blended Temperature Change", - # ERFs - "Effective Radiative Forcing", - "Effective Radiative Forcing|Anthropogenic", - "Effective Radiative Forcing|Aerosols", - "Effective Radiative Forcing|Aerosols|Direct Effect", - "Effective Radiative Forcing|Aerosols|Direct Effect|BC", - "Effective Radiative Forcing|Aerosols|Direct Effect|OC", - "Effective Radiative Forcing|Aerosols|Direct Effect|SOx", - "Effective Radiative Forcing|Aerosols|Indirect Effect", - "Effective Radiative Forcing|Greenhouse Gases", - "Effective Radiative Forcing|CO2", - "Effective Radiative Forcing|CH4", - "Effective Radiative Forcing|N2O", - "Effective Radiative Forcing|F-Gases", - "Effective Radiative Forcing|Montreal Protocol Halogen Gases", - "Effective Radiative Forcing|Ozone", - "Effective Radiative Forcing|Aviation|Cirrus", - "Effective Radiative Forcing|Aviation|Contrail", - "Effective Radiative Forcing|Aviation|H2O", - "Effective Radiative Forcing|Black Carbon on Snow", - # 'Effective Radiative Forcing|CH4 Oxidation Stratospheric', - "CH4OXSTRATH2O_ERF", - "Effective Radiative Forcing|Land-use Change", - # "Effective Radiative Forcing|CFC11", - # "Effective Radiative Forcing|CFC12", - # "Effective Radiative Forcing|HCFC22", - # "Effective Radiative Forcing|HFC125", - # "Effective Radiative Forcing|HFC134a", - # "Effective Radiative Forcing|HFC143a", - # "Effective Radiative Forcing|HFC227ea", - # "Effective Radiative Forcing|HFC23", - # "Effective Radiative Forcing|HFC245fa", - # "Effective Radiative Forcing|HFC32", - # "Effective Radiative Forcing|HFC4310mee", - # "Effective Radiative Forcing|CF4", - # "Effective Radiative Forcing|C6F14", - # "Effective Radiative Forcing|C2F6", - # "Effective Radiative Forcing|SF6", - # # Heat uptake - # "Heat Uptake", - # "Heat Uptake|Ocean", - # Atmospheric concentrations - "Atmospheric Concentrations|CO2", - "Atmospheric Concentrations|CH4", - "Atmospheric Concentrations|N2O", - # # Carbon cycle - # "Net Atmosphere to Land Flux|CO2", - # "Net Atmosphere to Ocean Flux|CO2", - # # permafrost - # "Net Land to Atmosphere Flux|CO2|Earth System Feedbacks|Permafrost", - # "Net Land to Atmosphere Flux|CH4|Earth System Feedbacks|Permafrost", +from gcages.scm_running.magicc import ( + check_magicc7_version, + combine_probabilistic_and_common_cfg, + load_magicc_probabilistic_config, ) -""" -Default variables to get from SCMs -""" +from gcages.units_helpers import assert_has_no_pint_incompatible_characters -def load_magicc_cfgs( - prob_distribution_path: Path, - output_variables: tuple[str, ...] = SCM_OUTPUT_VARIABLES_DEFAULT, - startyear: int = 1750, -) -> dict[str, list[dict[str, Any]]]: +def check_cmip7_scenariomip_magicc7_version(magicc_exe_path: Path) -> None: """ - Load MAGICC's configuration + Check that the MAGICC7 version is what was used in CMIP7 ScenarioMIP Parameters ---------- - prob_distribution_path - Path to the file containing the probabilistic distribution + magicc_exe_path + Path to the MAGICC executable to use - output_variables - Output variables + Raises + ------ + AssertionError + The MAGICC version is not what we expect - startyear - Starting year of the runs + MissingOptionalDependencyError + [openscm-runner](https://github.com/openscm/openscm-runner) is not installed + """ + check_magicc7_version(magicc_exe_path, expected_version="v7.6.0a3") - Returns - ------- - : - Config that can be used to run MAGICC + +def load_cmip7_scenariomip_magicc_probabilistic_config( + filepath: Path, +) -> list[dict[str, Any]]: """ - with open(prob_distribution_path) as fh: - cfgs_raw = json.load(fh) + Load the probabilistic config used with MAGICC in CMIP7 ScenarioMIP - cfgs_physical = [ - { - "run_id": c["paraset_id"], - **{k.lower(): v for k, v in c["nml_allcfgs"].items()}, - } - for c in cfgs_raw["configurations"] - ] + Parameters + ---------- + filepath + Filepath from which to load the probabilistic configuration - common_cfg = { - "startyear": startyear, - # Note: endyear handled in gcages, which I don't love but is fine for now - "out_dynamic_vars": convert_openscm_runner_output_names_to_magicc_output_names( - output_variables - ), - "out_ascii_binary": "BINARY", - "out_binary_format": 2, - } + Returns + ------- + : + Probabilistic configuration used with MAGICC in CMIP7 ScenarioMIP - run_config = [{**common_cfg, **physical_cfg} for physical_cfg in cfgs_physical] - climate_models_cfgs = {"MAGICC7": run_config} + Raises + ------ + AssertionError + `filepath` points to a file that does not have the expected hash + """ + fp_hash = get_file_hash(filepath, algorithm="sha256") + fp_hash_exp = "b386c89ddb3996a21b93658cb4a36efa68f6bed6ea979017c0eadcdc65aa6e72" + if fp_hash != fp_hash_exp: + msg = ( + f"The sha256 hash of {filepath} is {fp_hash}. " + f"This does not match what we expect ({fp_hash_exp=})." + ) + raise AssertionError(msg) - return climate_models_cfgs + cfgs = load_magicc_probabilistic_config(filepath) + # Common config that affect MAGICC behaviour + common_cfg = {"startyear": 1750} -def check_cmip7_scenariomip_magicc7_version() -> None: - """ - Check that the MAGICC7 version is what was used in CMIP7 ScenarioMIP - """ - try: - import openscm_runner.adapters - except ImportError as exc: - raise MissingOptionalDependencyError( - "check_cmip7_scenariomip_magicc7_version", requirement="openscm_runner" - ) from exc + run_config = combine_probabilistic_and_common_cfg(cfgs, common_cfg=common_cfg) - if openscm_runner.adapters.MAGICC7.get_version() != "v7.6.0a3": # type: ignore - raise AssertionError(openscm_runner.adapters.MAGICC7.get_version()) # type: ignore + return run_config def get_complete_scenarios_for_magicc( @@ -316,6 +253,13 @@ class CMIP7ScenarioMIPSCMRunner: Set to `None` to process serially. """ + magicc_exe_path: Path | None = None + """ + Path to the MAGICC executable to use + + Only required if we're running MAGICC + """ + def __call__( # noqa: PLR0912 self, in_emissions: pd.DataFrame, force_rerun: bool = False ) -> pd.DataFrame: @@ -379,8 +323,11 @@ def __call__( # noqa: PLR0912 complete_emissions.columns = complete_emissions.columns.astype(int) # Validate MAGICC requirement magicc_start_year = 2015 - if int(min(complete_emissions.columns.to_numpy())) != magicc_start_year: - msg = "Emissions starting year must be set to `2015`" + if complete_emissions.columns.min() != magicc_start_year: + msg = ( + "Emissions starting year must be set to `2015` " + "when running MAGICC7 without providing `historical_emissions`" + ) raise AssertionError(msg) else: # History provided merge with scenarios @@ -393,6 +340,22 @@ def __call__( # noqa: PLR0912 # Not running MAGICC, use emissions as-is complete_emissions = in_emissions + # if self.force_interpolate_to_yearly: + # # TODO: put interpolate to annual steps in pandas-openscm + # # Interpolate to ensure no nans. + # for y in range( + # in_emissions.columns.min(), + # in_emissions.columns.max() + 1, + # ): + # if y not in in_emissions: + # in_emissions[y] = np.nan + # + # in_emissions = ( + # in_emissions.sort_index(axis="columns").T.interpolate("index").T + # ) + + # Start function to split out + # `run_scms_gcages` ? openscm_runner_emissions = update_index_levels_func( complete_emissions, { @@ -404,36 +367,24 @@ def __call__( # noqa: PLR0912 }, ) - # if self.force_interpolate_to_yearly: - # # TODO: put interpolate to annual steps in pandas-openscm - # # Interpolate to ensure no nans. - # for y in range( - # openscm_runner_emissions.columns.min(), - # openscm_runner_emissions.columns.max() + 1, - # ): - # if y not in openscm_runner_emissions: - # openscm_runner_emissions[y] = np.nan - # - # openscm_runner_emissions = ( - # openscm_runner_emissions.sort_index(axis="columns") - # .T.interpolate("index") - # .T - # ) scm_results_maybe = run_scms( scenarios=openscm_runner_emissions, climate_models_cfgs=self.climate_models_cfgs, output_variables=self.output_variables, scenario_group_levels=["model", "scenario"], + # TODO: fix value in run_scms n_processes=self.n_processes if self.n_processes is not None else 1, db=self.db, verbose=self.verbose, batch_size_scenarios=self.batch_size_scenarios, - force_rerun=True, + force_rerun=force_rerun, + magicc_exe_path=self.magicc_exe_path, ) if self.db is not None: # Results aren't kept in memory during running, so have to load them now. # User can use `run_scms` directly if they want to process differently. + # TODO: only load the scenarios we ran out_maybe = self.db.load() if out_maybe is None: raise TypeError(out_maybe) @@ -447,6 +398,7 @@ def __call__( # noqa: PLR0912 out = scm_results_maybe out.columns = out.columns.astype(self.res_column_type) + # End function to split out if self.run_checks: # All scenarios have output @@ -474,9 +426,11 @@ def from_cmip7_scenariomip_config( # noqa: PLR0913 cls, magicc_exe_path: Path, magicc_prob_distribution_path: Path, - output_variables: tuple[str, ...] = SCM_OUTPUT_VARIABLES_DEFAULT, + output_variables: tuple[str, ...] = DEFAULT_OUTPUT_VARIABLES, batch_size_scenarios: int | None = None, db: OpenSCMDB | None = None, + # TODO: switch to `historical_emissions` + # and add helper for loading historical_emissions for MAGICC historical_emissions_path: Path | None = None, harmonisation_year: int = 2023, verbose: bool = True, @@ -546,8 +500,7 @@ def from_cmip7_scenariomip_config( # noqa: PLR0913 : Initialised SCM runner """ - os.environ["MAGICC_EXECUTABLE_7"] = str(magicc_exe_path) - check_cmip7_scenariomip_magicc7_version() + check_cmip7_scenariomip_magicc7_version(magicc_exe_path) if historical_emissions_path is not None: # Load history @@ -575,17 +528,32 @@ def from_cmip7_scenariomip_config( # noqa: PLR0913 ], drop=True, ) + else: historical_emissions = None - magicc_prob_cfg = load_magicc_cfgs( - prob_distribution_path=magicc_prob_distribution_path, - output_variables=output_variables, - startyear=1750, + magicc_prob_cfg = load_cmip7_scenariomip_magicc_probabilistic_config( + magicc_prob_distribution_path, + ) + + common_cfg = { + "out_dynamic_vars": convert_openscm_runner_output_names_to_magicc_output_names( # noqa: E501 + output_variables + ), + "out_ascii_binary": "BINARY", + "out_binary_format": 2, + } + + run_config = combine_probabilistic_and_common_cfg( + magicc_prob_cfg, common_cfg=common_cfg ) + magicc_full_distribution_n_config = 600 + if len(run_config) != magicc_full_distribution_n_config: + raise AssertionError(len(run_config)) + return cls( - climate_models_cfgs=magicc_prob_cfg, + climate_models_cfgs={"MAGICC7": run_config}, output_variables=output_variables, batch_size_scenarios=batch_size_scenarios, db=db, @@ -595,4 +563,5 @@ def from_cmip7_scenariomip_config( # noqa: PLR0913 run_checks=run_checks, n_processes=n_processes, res_column_type=int, # annual output by default + magicc_exe_path=magicc_exe_path, ) diff --git a/src/gcages/scm_running/__init__.py b/src/gcages/scm_running/__init__.py index 9b4f381f..5c63ab6d 100644 --- a/src/gcages/scm_running/__init__.py +++ b/src/gcages/scm_running/__init__.py @@ -2,10 +2,13 @@ General simple climate model (SCM) running tools """ +# TODO: read this and clean up from __future__ import annotations +import contextlib import os from collections.abc import Iterable +from pathlib import Path from typing import Any, Optional, cast import pandas as pd @@ -15,6 +18,8 @@ from pandas_openscm.parallelisation import ParallelOpConfig from gcages.exceptions import MissingOptionalDependencyError +from gcages.scm_running.default_variables import DEFAULT_OUTPUT_VARIABLES +from gcages.scm_running.magicc import temporary_env_var def convert_openscm_runner_output_names_to_magicc_output_names( @@ -65,9 +70,28 @@ def convert_openscm_runner_output_names_to_magicc_output_names( return tuple(res_l) -def batch_df( # noqa: D103 +def batch_df( df: pd.DataFrame, batch_index: pd.MultiIndex, batch_size: int | None ) -> list[pd.DataFrame]: + """ + Convert a [pd.DataFrame][pandas.DataFrame] into batches + + Parameters + ---------- + df + [pd.DataFrame][pandas.DataFrame] to batch + + batch_index + Index to use for batching + + batch_size + Batch size to use + + Returns + ------- + : + Created batches + """ # TOOD: move this to pandas-openscm if batch_size is None: batches = [df] @@ -223,7 +247,7 @@ def get_scenarios_to_run_after_checking_cache( # noqa: PLR0913 return batch_to_run -def run_scms( # noqa: PLR0912, PLR0913 +def run_scms( # noqa: PLR0912, PLR0913, PLR0915 scenarios: pd.DataFrame, climate_models_cfgs: dict[str, list[dict[str, Any]]], output_variables: tuple[str, ...], @@ -235,6 +259,7 @@ def run_scms( # noqa: PLR0912, PLR0913 progress: bool = True, batch_size_scenarios: int | None = None, force_rerun: bool = False, + magicc_exe_path: Path | None = None, ) -> pd.DataFrame | None: """ Run simple climate models (SCMs) @@ -291,6 +316,11 @@ def run_scms( # noqa: PLR0912, PLR0913 force_rerun Should we force the scenarios to be re-run, even if they are already in `db` + magicc_exe_path + Path to the MAGICC executable to use + + Only required if we're running MAGICC + Returns ------- : @@ -390,13 +420,21 @@ def run_scms( # noqa: PLR0912, PLR0913 res_l = [] for scenario_batch in scenario_batches: - batch_res = run_batch( - batch=scenario_batch, - climate_models_cfgs={climate_model: cfg_use}, - output_variables=output_variables, - ) + if climate_model.startswith("MAGICC"): + contextblock = temporary_env_var( + "MAGICC_EXECUTABLE_7", str(magicc_exe_path) + ) + else: + contextblock = contextlib.nullcontext - if climate_model == "MAGICC7": + with contextblock: + batch_res = run_batch( + batch=scenario_batch, + climate_models_cfgs={climate_model: cfg_use}, + output_variables=output_variables, + ) + + if climate_model.startswith("MAGICC"): # Chop off the extra years batch_res = batch_res.iloc[:, :-magicc_extra_years] # Chop out regional results @@ -417,3 +455,13 @@ def run_scms( # noqa: PLR0912, PLR0913 res = pd.concat(res_l) return res + + +__all__ = [ + "DEFAULT_OUTPUT_VARIABLES", + "batch_df", + "convert_openscm_runner_output_names_to_magicc_output_names", + "get_scenarios_to_run_after_checking_cache", + "run_batch", + "run_scms", +] diff --git a/src/gcages/scm_running/default_variables.py b/src/gcages/scm_running/default_variables.py new file mode 100644 index 00000000..1d04385d --- /dev/null +++ b/src/gcages/scm_running/default_variables.py @@ -0,0 +1,48 @@ +""" +Default variables to use when running simple climate models (SCMs) +""" + +DEFAULT_OUTPUT_VARIABLES: tuple[str, ...] = ( + # GSAT + "Surface Air Temperature Change", + # TODO: delete as not available from all SCMs + # GMST + "Surface Air Ocean Blended Temperature Change", + # ERFs + "Effective Radiative Forcing", + "Effective Radiative Forcing|Anthropogenic", + "Effective Radiative Forcing|Aerosols", + "Effective Radiative Forcing|Aerosols|Direct Effect", + "Effective Radiative Forcing|Aerosols|Direct Effect|BC", + "Effective Radiative Forcing|Aerosols|Direct Effect|OC", + "Effective Radiative Forcing|Aerosols|Direct Effect|SOx", + "Effective Radiative Forcing|Aerosols|Indirect Effect", + "Effective Radiative Forcing|Greenhouse Gases", + "Effective Radiative Forcing|CO2", + "Effective Radiative Forcing|CH4", + "Effective Radiative Forcing|N2O", + "Effective Radiative Forcing|F-Gases", + "Effective Radiative Forcing|Montreal Protocol Halogen Gases", + "Effective Radiative Forcing|Ozone", + # Heat uptake + "Heat Uptake", + # Atmospheric concentrations + "Atmospheric Concentrations|CO2", + "Atmospheric Concentrations|CH4", + "Atmospheric Concentrations|N2O", + # TODO: delete as not available from all SCMs + # Carbon cycle + "Net Atmosphere to Land Flux|CO2", + "Net Atmosphere to Ocean Flux|CO2", + # TODO: delete as not available from all SCMs + # Permafrost + "Net Land to Atmosphere Flux|CO2|Earth System Feedbacks|Permafrost", + "Net Land to Atmosphere Flux|CH4|Earth System Feedbacks|Permafrost", +) +""" +Default output variables + +Note that it can be a bit of work +to get these variables to actually appear in the output, +depending on which simple climate model you're using. +""" diff --git a/src/gcages/scm_running/magicc.py b/src/gcages/scm_running/magicc.py index 76b0e74f..95f2e89c 100644 --- a/src/gcages/scm_running/magicc.py +++ b/src/gcages/scm_running/magicc.py @@ -4,10 +4,15 @@ from __future__ import annotations +import contextlib import json +import os +from collections.abc import Iterator from pathlib import Path from typing import Any +from gcages.exceptions import MissingOptionalDependencyError + def load_magicc_probabilistic_config(config_file: Path) -> list[dict[str, Any]]: """ @@ -59,3 +64,60 @@ def combine_probabilistic_and_common_cfg( run_config = [{**common_cfg, **prob_cfg} for prob_cfg in probabilistic_cfgs] return run_config + + +def check_magicc7_version(magicc_exe_path: Path, expected_version: str) -> None: + """ + Check that the MAGICC7 version is what we expect + + Parameters + ---------- + magicc_exe_path + Path to the MAGICC executable to use + + expected_version + Expected version + + Raises + ------ + AssertionError + The MAGICC version is not what we expect + + MissingOptionalDependencyError + [openscm-runner](https://github.com/openscm/openscm-runner) is not installed + """ + try: + import openscm_runner.adapters + except ImportError as exc: + raise MissingOptionalDependencyError( + "check_cmip7_scenariomip_magicc7_version", requirement="openscm_runner" + ) from exc + + with temporary_env_var("MAGICC_EXECUTABLE_7", str(magicc_exe_path)): + magicc_version = openscm_runner.adapters.MAGICC7.get_version() + if magicc_version != expected_version: # type: ignore + raise AssertionError(openscm_runner.adapters.MAGICC7.get_version()) # type: ignore + + +@contextlib.contextmanager +def temporary_env_var(env_var: str, value: str) -> Iterator[None]: + """ + Set a temporary value for an environment variable + + Parameters + ---------- + env_var + Environment variable to set + + value + Value to set + """ + current_value = os.environ.get(env_var, None) + os.environ[env_var] = value + try: + yield + finally: + if current_value is None: + os.environ.pop(env_var, None) + else: + os.environ[env_var] = current_value diff --git a/tests/regression/ar6/test_regression_ar6_scm_running_and_post_processing.py b/tests/regression/ar6/test_regression_ar6_scm_running_and_post_processing.py index 290235ad..aab11b94 100644 --- a/tests/regression/ar6/test_regression_ar6_scm_running_and_post_processing.py +++ b/tests/regression/ar6/test_regression_ar6_scm_running_and_post_processing.py @@ -170,13 +170,11 @@ def test_individual_scenario(model, scenario, monkeypatch): ] ) - monkeypatch.delenv("MAGICC_EXECUTABLE_7", raising=False) - magicc_exe = guess_magicc_exe(AR6_MAGICC_EXECUTABLES_DIR) scm_runner = AR6SCMRunner.from_ar6_config( # Has to be parallel otherwise this is too slow n_processes=multiprocessing.cpu_count(), progress=False, - magicc_exe_path=magicc_exe, + magicc_exe_path=guess_magicc_exe(AR6_MAGICC_EXECUTABLES_DIR), magicc_prob_distribution_path=AR6_MAGICC_PROBABILISTIC_CONFIG_FILE, historical_emissions=get_ar6_full_historical_emissions( AR6_INFILLING_DB_CFCS_FILE @@ -286,8 +284,6 @@ def test_parallel(tmp_path, monkeypatch): exp_temperature_percentiles = pd.concat(exp_temperature_percentiles_l) exp_metadata = pd.concat(exp_metadata_l) - monkeypatch.delenv("MAGICC_EXECUTABLE_7", raising=False) - magicc_exe = guess_magicc_exe(AR6_MAGICC_EXECUTABLES_DIR) scm_runner = AR6SCMRunner.from_ar6_config( n_processes=multiprocessing.cpu_count(), # run with progress bars is the default @@ -300,7 +296,7 @@ def test_parallel(tmp_path, monkeypatch): ), # Force some batching too batch_size_scenarios=2, - magicc_exe_path=magicc_exe, + magicc_exe_path=guess_magicc_exe(AR6_MAGICC_EXECUTABLES_DIR), magicc_prob_distribution_path=AR6_MAGICC_PROBABILISTIC_CONFIG_FILE, historical_emissions=get_ar6_full_historical_emissions( AR6_INFILLING_DB_CFCS_FILE diff --git a/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_scm_running_post_processing.py b/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_scm_running_post_processing.py index 9c080848..a8104b6e 100644 --- a/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_scm_running_post_processing.py +++ b/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_scm_running_post_processing.py @@ -79,7 +79,6 @@ def test_individual_scenario(model, scenario, monkeypatch): }, ) - monkeypatch.delenv("MAGICC_EXECUTABLE_7", raising=False) scm_runner = CMIP7ScenarioMIPSCMRunner.from_cmip7_scenariomip_config( magicc_exe_path=guess_magicc_exe(CMIP7_SCENARIOMIP_MAGICC_EXECUTABLES_DIR), magicc_prob_distribution_path=CMIP7_SCENARIOMIP_MAGICC_PROBABILISTIC_CONFIG_FILE, diff --git a/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_whole_pipeline.py b/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_whole_pipeline.py index 749c2a22..5eaaa85f 100644 --- a/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_whole_pipeline.py +++ b/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_whole_pipeline.py @@ -302,7 +302,6 @@ def test_whole_pipeline(model, scenario, monkeypatch): # noqa: PLR0915 out_columns_name="time", ) - monkeypatch.delenv("MAGICC_EXECUTABLE_7", raising=False) scm_runner = CMIP7ScenarioMIPSCMRunner.from_cmip7_scenariomip_config( magicc_exe_path=guess_magicc_exe(CMIP7_SCENARIOMIP_MAGICC_EXECUTABLES_DIR), magicc_prob_distribution_path=CMIP7_SCENARIOMIP_MAGICC_PROBABILISTIC_CONFIG_FILE, From f92a0040a867d8d19567434f4db6c8cdc4bf4d75 Mon Sep 17 00:00:00 2001 From: Zebedee Nicholls Date: Sat, 25 Apr 2026 17:36:30 +1000 Subject: [PATCH 4/7] Remove comment that won't be used --- src/gcages/cmip7_scenariomip/scm_running.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/gcages/cmip7_scenariomip/scm_running.py b/src/gcages/cmip7_scenariomip/scm_running.py index ead548b7..a691291f 100644 --- a/src/gcages/cmip7_scenariomip/scm_running.py +++ b/src/gcages/cmip7_scenariomip/scm_running.py @@ -340,20 +340,6 @@ def __call__( # noqa: PLR0912 # Not running MAGICC, use emissions as-is complete_emissions = in_emissions - # if self.force_interpolate_to_yearly: - # # TODO: put interpolate to annual steps in pandas-openscm - # # Interpolate to ensure no nans. - # for y in range( - # in_emissions.columns.min(), - # in_emissions.columns.max() + 1, - # ): - # if y not in in_emissions: - # in_emissions[y] = np.nan - # - # in_emissions = ( - # in_emissions.sort_index(axis="columns").T.interpolate("index").T - # ) - # Start function to split out # `run_scms_gcages` ? openscm_runner_emissions = update_index_levels_func( From ebd1989f7d3801d3a30d0a31a1a27c20b538db81 Mon Sep 17 00:00:00 2001 From: Zebedee Nicholls Date: Sat, 25 Apr 2026 17:36:53 +1000 Subject: [PATCH 5/7] Add comment [ci skip] --- src/gcages/cmip7_scenariomip/scm_running.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/gcages/cmip7_scenariomip/scm_running.py b/src/gcages/cmip7_scenariomip/scm_running.py index a691291f..d545ad4e 100644 --- a/src/gcages/cmip7_scenariomip/scm_running.py +++ b/src/gcages/cmip7_scenariomip/scm_running.py @@ -342,6 +342,7 @@ def __call__( # noqa: PLR0912 # Start function to split out # `run_scms_gcages` ? + # `run_scms_with_db_cache` ? openscm_runner_emissions = update_index_levels_func( complete_emissions, { From a1b2fba2b7619b8cf39d7c830c0bb8785565d3ea Mon Sep 17 00:00:00 2001 From: Zebedee Nicholls Date: Sun, 26 Apr 2026 11:08:08 +1000 Subject: [PATCH 6/7] Clean up whole pipeline tests --- .../pre_processing/pre_processor.py | 5 +- src/gcages/pandas_openscm_tmp.py | 17 ++ src/gcages/testing.py | 1 + .../categories_REMIND-MAgPIE 3.5-4.11.csv | 4 +- ...scenariomip_scm_running_post_processing.py | 7 +- ...ession_cmip7_scenariomip_whole_pipeline.py | 247 ++++++++---------- 6 files changed, 131 insertions(+), 150 deletions(-) create mode 100644 src/gcages/pandas_openscm_tmp.py diff --git a/src/gcages/cmip7_scenariomip/pre_processing/pre_processor.py b/src/gcages/cmip7_scenariomip/pre_processing/pre_processor.py index 9e12f723..579f2346 100644 --- a/src/gcages/cmip7_scenariomip/pre_processing/pre_processor.py +++ b/src/gcages/cmip7_scenariomip/pre_processing/pre_processor.py @@ -219,8 +219,9 @@ def do_pre_processing( # noqa: PLR0912, PLR0913, PLR0915 if run_checks: indf_reported_times_nan = indf_reported_times.isnull().any(axis="columns") if indf_reported_times_nan.any(): - issue_rows = indf.loc[indf_reported_times_nan, :] - msg = f"NaNs after dropping unreported times:\n{issue_rows}" + issue_points = indf.loc[indf_reported_times_nan, :] + issue_points = issue_points.loc[:, issue_points.isnull().any(axis="rows")] + msg = f"NaNs after dropping unreported times:\n{issue_points}" raise AssertionError(msg) indf_clean_units = strip_pint_incompatible_characters_from_units( diff --git a/src/gcages/pandas_openscm_tmp.py b/src/gcages/pandas_openscm_tmp.py new file mode 100644 index 00000000..b9102344 --- /dev/null +++ b/src/gcages/pandas_openscm_tmp.py @@ -0,0 +1,17 @@ +""" +Functionality that should be moved into [pandas-openscm][] +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + + +# interpolate to annual +def interpolate_to_annual_timesteps(indf: pd.DataFrame) -> pd.DataFrame: + # TODO: add checks + yearly_timesteps = np.arange(indf.columns.min(), indf.columns.max() + 1) + res = indf.reindex(columns=yearly_timesteps).T.interpolate(method="index").T + + return res diff --git a/src/gcages/testing.py b/src/gcages/testing.py index 17a679e1..0bb330bb 100644 --- a/src/gcages/testing.py +++ b/src/gcages/testing.py @@ -409,6 +409,7 @@ def get_cmip7_scenariomip_harmonised_emissions( / f"{model}_{scenario}_harmonised.csv", index_columns=["model", "scenario", "variable", "region", "unit", "workflow"], out_columns_type=int, + out_columns_name="year", ) return res diff --git a/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/whole_pipeline/categories_REMIND-MAgPIE 3.5-4.11.csv b/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/whole_pipeline/categories_REMIND-MAgPIE 3.5-4.11.csv index f0f125dc..d80b77a3 100644 --- a/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/whole_pipeline/categories_REMIND-MAgPIE 3.5-4.11.csv +++ b/tests/regression/cmip7-scenariomip/cmip7-scenariomip-output/whole_pipeline/categories_REMIND-MAgPIE 3.5-4.11.csv @@ -1,4 +1,2 @@ -,,,value,value -metric,,,category,category_name -climate_model,model,scenario,, +climate_model,model,scenario,category,category_name MAGICCv7.6.0a3,REMIND-MAgPIE 3.5-4.11,SSP1 - Very Low Emissions,C2,C2: return warming to 1.5°C (>50%) after a high overshoot diff --git a/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_scm_running_post_processing.py b/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_scm_running_post_processing.py index a8104b6e..af6bcc80 100644 --- a/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_scm_running_post_processing.py +++ b/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_scm_running_post_processing.py @@ -57,7 +57,7 @@ @get_key_testing_model_scenario_parameters( KEY_CMIP7_SCENARIOMIP_TESTING_MODEL_SCENARIOS ) -def test_individual_scenario(model, scenario, monkeypatch): +def test_individual_scenario(model, scenario): complete = load_timeseries_csv( CMIP7_SCENARIOMIP_OUT_DIR / f"{model}_{scenario}_complete.csv", lower_column_names=True, @@ -105,8 +105,8 @@ def test_individual_scenario(model, scenario, monkeypatch): "variable", ], out_columns_type=int, + out_columns_name="time", ) - exp_temperature.columns.name = "time" assert_frame_equal( mi_loc( @@ -141,13 +141,12 @@ def test_individual_scenario(model, scenario, monkeypatch): out_columns_type=int, out_columns_name="time", ) - processed_quantiles = post_processed.timeseries_quantile.iloc[:, 250:] exp_quantiles = update_index_levels_func( exp_quantiles, {"quantile": partial(np.round, decimals=4)} ) processed_quantiles = update_index_levels_func( - processed_quantiles, {"quantile": partial(np.round, decimals=4)} + post_processed.timeseries_quantile, {"quantile": partial(np.round, decimals=4)} ) assert_frame_equal( processed_quantiles.loc[:, exp_quantiles.columns], exp_quantiles, rtol=1e-5 diff --git a/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_whole_pipeline.py b/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_whole_pipeline.py index 5eaaa85f..681ac2d6 100644 --- a/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_whole_pipeline.py +++ b/tests/regression/cmip7-scenariomip/test_regression_cmip7_scenariomip_whole_pipeline.py @@ -1,11 +1,12 @@ """ -Test infilling compared for CMIP7 ScenarioMIP +Test running the whole pipeline compared to CMIP7 ScenarioMIP """ +# TODOs: own PR from __future__ import annotations -import importlib import multiprocessing +from functools import partial from pathlib import Path import numpy as np @@ -16,24 +17,22 @@ update_index_levels_func, update_levels_from_other, ) +from pandas_openscm.indexing import mi_loc from pandas_openscm.io import load_timeseries_csv -from gcages.cmip7_scenariomip.harmonisation import ( - create_cmip7_scenariomip_global_harmoniser, -) -from gcages.cmip7_scenariomip.infilling import ( +from gcages.cmip7_scenariomip import ( CMIP7ScenarioMIPInfiller, + CMIP7ScenarioMIPPreProcessor, + CMIP7ScenarioMIPSCMRunner, + ReaggregatorBasic, + create_cmip7_scenariomip_global_harmoniser, + create_cmip7_scenariomip_postprocessor, ) -from gcages.cmip7_scenariomip.post_processing import CMIP7ScenarioMIPPostProcessor -from gcages.cmip7_scenariomip.pre_processing import CMIP7ScenarioMIPPreProcessor -from gcages.cmip7_scenariomip.pre_processing.reaggregation import ReaggregatorBasic from gcages.cmip7_scenariomip.pre_processing.reaggregation.basic import ( get_required_timeseries_index, ) -from gcages.cmip7_scenariomip.scm_running import ( - CMIP7ScenarioMIPSCMRunner, -) from gcages.completeness import get_missing_levels +from gcages.pandas_openscm_tmp import interpolate_to_annual_timesteps from gcages.renaming import SupportedNamingConventions, convert_variable_name from gcages.testing import ( assert_frame_equal, @@ -118,20 +117,22 @@ def guess_unit(v_in: str) -> str: "model, scenario", [("REMIND-MAgPIE 3.5-4.11", "SSP1 - Very Low Emissions")], ) -def test_whole_pipeline(model, scenario, monkeypatch): # noqa: PLR0915 - """Test a few scenarios, not all to save compute time""" - # LOADING SCENARIO - file = CMIP7_SCENARIOMIP_OUT_DIR / f"{model}_{scenario}_raw-scenario.csv" +def test_whole_pipeline(model, scenario): input_df = load_timeseries_csv( - file, + CMIP7_SCENARIOMIP_OUT_DIR / f"{model}_{scenario}_raw-scenario.csv", index_columns=["model", "scenario", "variable", "region", "unit"], out_columns_type=int, out_columns_name="year", ) - # In case the new data needs a bit of make-up - input_df = input_df.loc[:, 2015:2100:1].dropna(how="all", axis="columns") - input_df = input_df.T.interpolate(method="index").T + # This input data only has non-NaNs from 2015 onwards + input_df = input_df.loc[:, 2015:] + + # Ensure on annual timesteps before continuing. + # TODO: figure out where in the pipeline to put a check for this. + input_df = interpolate_to_annual_timesteps( + input_df.dropna(how="all", axis="columns") + ) model_regions = [ r @@ -143,66 +144,44 @@ def test_whole_pipeline(model, scenario, monkeypatch): # noqa: PLR0915 input_df = missing_reporting_zero_hack(reaggregator, input_df, model_regions) - if importlib.util.find_spec("openscm_units") is None: - # Loosen the tolerance given what we know about the units - reaggregator.internal_consistency_tolerances["Emissions|CO2"]["atol"] = 1.0 - pre_processor = CMIP7ScenarioMIPPreProcessor( reaggregator=reaggregator, n_processes=None, # run serially progress=False, run_checks=True, ) - pre_processed = pre_processor(input_df) + pre_processed_res = pre_processor(input_df) - # TODO should we move this ? + # TODO: put this logic into the pre-processor + # TODO: explicit tests going from raw ScenarioMIP input + # to pre-processed emissions for the rest of the workflow + pre_processed = pre_processed_res.global_workflow_emissions # Hard override the global workflow emissions for CO2 AFOLU # to use globally reported numbers, # even if they're not consistent with region-sector reporting. - pre_processed.global_workflow_emissions = pix.concat( + pre_processed = pix.concat( [ - pre_processed.global_workflow_emissions.loc[ - ~pix.isin(variable="Emissions|CO2|Biosphere") - ], + pre_processed.loc[~pix.isin(variable="Emissions|CO2|Biosphere")], input_df.loc[ pix.isin(variable="Emissions|CO2|AFOLU", region="World") ].pix.assign(variable="Emissions|CO2|Biosphere"), ] ) - - pre_processed.global_workflow_emissions_raw_names = pix.concat( - [ - pre_processed.global_workflow_emissions_raw_names.loc[ - ~pix.isin(variable="Emissions|CO2|AFOLU") - ], - input_df.loc[pix.isin(variable="Emissions|CO2|AFOLU", region="World")], - ] - ) - - # for attr in [ - # "assumed_zero_emissions", - # "global_workflow_emissions", - # "global_workflow_emissions_raw_names", - # "gridding_workflow_emissions", - # ]: - # # Interestingly, this won't fail if there are extra, unexpected columns - # # in the regression data against which we are comparing. - # dataframe_regression.check( - # getattr(pre_processed, attr).sort_index(), - # basename=f"{input_file.stem}_{attr}" - # ) - - # HARMONISATION - pre_processed = pre_processed.global_workflow_emissions[ - pix.ismatch( - region="World", - ) - ] + # Need to make sure the override happens for both naming conventions + # when we move this into pre-processing. + # pre_processed.global_workflow_emissions_raw_names = pix.concat( + # [ + # pre_processed.global_workflow_emissions_raw_names.loc[ + # ~pix.isin(variable="Emissions|CO2|AFOLU") + # ], + # input_df.loc[pix.isin(variable="Emissions|CO2|AFOLU", region="World")], + # ] + # ) if pre_processed.empty: raise AssertionError - # Harmonise - # Only works if aneris installed + # TODO: explicit test of pre-processed results + pytest.importorskip("aneris") harmoniser = create_cmip7_scenariomip_global_harmoniser( cmip7_scenariomip_global_historical_emissions_file=CMIP7_SCENARIOMIP_HISTORICAL_GLOBAL_EMISSIONS_FILE, @@ -214,22 +193,25 @@ def test_whole_pipeline(model, scenario, monkeypatch): # noqa: PLR0915 harmonised = harmoniser(pre_processed) # Get expected result - harmonised_all = get_cmip7_scenariomip_harmonised_emissions( + harmonised_model_scenario_all = get_cmip7_scenariomip_harmonised_emissions( model=model, scenario=scenario, + # TODO: check if we can get rid of the "whole_pipeline" + # folder and just use the regression results from elsewhere + # (answer should be yes...) processed_cmip7_scenariomip_output_data_dir=CMIP7_SCENARIOMIP_OUT_DIR / "whole_pipeline", ) - exp = harmonised_all.loc[pix.ismatch(workflow="global")].reset_index( - "workflow", drop=True - ) - if exp.empty: + exp_harmonised = harmonised_model_scenario_all.loc[ + pix.ismatch(workflow="global") + ].reset_index("workflow", drop=True) + if exp_harmonised.empty: raise AssertionError # Convert names to gcages naming before comparing - exp = update_index_levels_func( - exp, + exp_harmonised = update_index_levels_func( + exp_harmonised, { "variable": lambda x: convert_variable_name( x, @@ -239,20 +221,27 @@ def test_whole_pipeline(model, scenario, monkeypatch): # noqa: PLR0915 }, copy=False, ) - exp.columns.name = "year" - assert_frame_equal(harmonised, exp) + assert_frame_equal(harmonised, exp_harmonised) - ## INFILLING - # Load infilled results - exp = get_cmip7_scenariomip_complete_emissions( + infiller = CMIP7ScenarioMIPInfiller.from_cmip7_scenariomip_config( + cmip7_scenariomip_infilling_leader_emissions_file=PROCESSED_CMIP7_SCENARIOMIP_INPUT_DIR + / "infilling_db_cmip7_scenariomip.csv", + cmip7_ghg_inversions_file=PROCESSED_CMIP7_SCENARIOMIP_INPUT_DIR + / "cmip7_ghg_inversions.csv", + cmip7_scenariomip_global_historical_emissions_file=PROCESSED_CMIP7_SCENARIOMIP_INPUT_DIR + / "history_cmip7_scenariomip.csv", + ) + infilled = infiller(harmonised) + + complete_model_scenario = get_cmip7_scenariomip_complete_emissions( model=model, scenario=scenario, processed_cmip7_scenariomip_output_data_dir=CMIP7_SCENARIOMIP_OUT_DIR / "whole_pipeline", ) - exp = update_index_levels_func( - exp, + exp_infilled = update_index_levels_func( + complete_model_scenario, { "variable": lambda x: convert_variable_name( x, @@ -262,32 +251,23 @@ def test_whole_pipeline(model, scenario, monkeypatch): # noqa: PLR0915 }, copy=False, ) - exp.columns.name = "year" - infiller = CMIP7ScenarioMIPInfiller.from_cmip7_scenariomip_config( - cmip7_scenariomip_infilling_leader_emissions_file=PROCESSED_CMIP7_SCENARIOMIP_INPUT_DIR - / "infilling_db_cmip7_scenariomip.csv", - cmip7_ghg_inversions_file=PROCESSED_CMIP7_SCENARIOMIP_INPUT_DIR - / "cmip7_ghg_inversions.csv", - cmip7_scenariomip_global_historical_emissions_file=PROCESSED_CMIP7_SCENARIOMIP_INPUT_DIR - / "history_cmip7_scenariomip.csv", - ur=None, - ) - infilled = infiller(harmonised) + assert_frame_equal(infilled, exp_infilled) - assert_frame_equal(infilled, exp) + scm_runner = CMIP7ScenarioMIPSCMRunner.from_cmip7_scenariomip_config( + magicc_exe_path=guess_magicc_exe(CMIP7_SCENARIOMIP_MAGICC_EXECUTABLES_DIR), + magicc_prob_distribution_path=CMIP7_SCENARIOMIP_MAGICC_PROBABILISTIC_CONFIG_FILE, + output_variables=("Surface Air Temperature Change",), + historical_emissions_path=CMIP7_SCENARIOMIP_HISTORICAL_GLOBAL_EMISSIONS_FILE, + harmonisation_year=HARMONISATION_YEAR, + n_processes=multiprocessing.cpu_count(), + ) - # MAGICC and post_processing - # Select scenario and drop aggregated/cumulative rows - infilled = infilled.loc[ - pix.ismatch(scenario=scenario) - & ~pix.ismatch(variable=["**Kyoto**", "Cumulative**", "**CO2", "**GHG**"]) - ] + scm_results = scm_runner(infilled) - # Loading expected results - file = CMIP7_SCENARIOMIP_OUT_DIR / "whole_pipeline" / f"{model}_{scenario}_GSAT.csv" exp_temperature = load_timeseries_csv( - file, + # TODO: check if this differs from the 'non whole pipeline' result + CMIP7_SCENARIOMIP_OUT_DIR / "whole_pipeline" / f"{model}_{scenario}_GSAT.csv", lower_column_names=True, index_columns=[ "climate_model", @@ -302,38 +282,29 @@ def test_whole_pipeline(model, scenario, monkeypatch): # noqa: PLR0915 out_columns_name="time", ) - scm_runner = CMIP7ScenarioMIPSCMRunner.from_cmip7_scenariomip_config( - magicc_exe_path=guess_magicc_exe(CMIP7_SCENARIOMIP_MAGICC_EXECUTABLES_DIR), - magicc_prob_distribution_path=CMIP7_SCENARIOMIP_MAGICC_PROBABILISTIC_CONFIG_FILE, - output_variables=("Surface Air Temperature Change",), - historical_emissions_path=CMIP7_SCENARIOMIP_HISTORICAL_GLOBAL_EMISSIONS_FILE, - harmonisation_year=HARMONISATION_YEAR, - n_processes=multiprocessing.cpu_count(), - ) - - scm_results = scm_runner(infilled) - assert_frame_equal( - scm_results[ - scm_results.index.get_level_values("variable").str.contains( - "Surface Air Temperature Change" - ) - ].iloc[:10], + mi_loc( + scm_results, + exp_temperature.index.droplevel( + exp_temperature.index.names.difference(["variable", "run_id"]) + ), + ), exp_temperature, - rtol=1e-6, + rtol=1e-5, ) - post_processor = CMIP7ScenarioMIPPostProcessor.from_cmip7_scenariomip_config() + post_processor = create_cmip7_scenariomip_postprocessor( + progress=False, + n_processes=None, + ) post_processed = post_processor(scm_results) - # Loading and assessing quantiles timeseries results - file = ( - CMIP7_SCENARIOMIP_OUT_DIR - / "whole_pipeline" - / f"assessed-warming-timeseries-quantiles_{model}.csv" - ) exp_quantiles = load_timeseries_csv( - file, + ( + CMIP7_SCENARIOMIP_OUT_DIR + / "whole_pipeline" + / f"assessed-warming-timeseries-quantiles_{model}.csv" + ), lower_column_names=True, index_columns=[ "climate_model", @@ -347,29 +318,23 @@ def test_whole_pipeline(model, scenario, monkeypatch): # noqa: PLR0915 out_columns_type=int, out_columns_name="time", ) - exp_quantiles.index = exp_quantiles.index.set_levels( - exp_quantiles.index.levels[exp_quantiles.index.names.index("quantile")].round( - 4 - ), - level="quantile", + + exp_quantiles = update_index_levels_func( + exp_quantiles, {"quantile": partial(np.round, decimals=4)} ) - processed_quantiles = post_processed.timeseries_quantile.iloc[:, 250:] - processed_quantiles.index = processed_quantiles.index.set_levels( - exp_quantiles.index.levels[exp_quantiles.index.names.index("quantile")].round( - 4 - ), - level="quantile", + processed_quantiles = update_index_levels_func( + post_processed.timeseries_quantile, {"quantile": partial(np.round, decimals=4)} ) - assert_frame_equal( - processed_quantiles, - exp_quantiles, - rtol=1e-8, + processed_quantiles.loc[:, exp_quantiles.columns], exp_quantiles, rtol=1e-5 ) - # Loading and categories - file = CMIP7_SCENARIOMIP_OUT_DIR / "whole_pipeline" / f"categories_{model}.csv" - exp_categories = pd.read_csv(file) + exp_categories = pd.read_csv( + CMIP7_SCENARIOMIP_OUT_DIR / "whole_pipeline" / f"categories_{model}.csv", + index_col=["climate_model", "model", "scenario"], + ) + exp_categories.columns.name = "metric" - assert post_processed.metadata_categories.values[0] == exp_categories["value.1"][2] - assert post_processed.metadata_categories.values[1] == exp_categories["value"][2] + assert_frame_equal( + post_processed.metadata_categories.unstack("metric"), exp_categories + ) From a56ee982607f59333152e6bebb0f6cf6d56c8236 Mon Sep 17 00:00:00 2001 From: Zebedee Nicholls Date: Sun, 26 Apr 2026 14:54:03 +1000 Subject: [PATCH 7/7] Add regression testing strategy docs --- docs/NAVIGATION.md | 3 +- docs/{development.md => development/index.md} | 0 .../regression-testing-strategy.md | 33 +++++++++++++++++++ src/gcages/pandas_openscm_tmp.py | 16 +++++++-- 4 files changed, 49 insertions(+), 3 deletions(-) rename docs/{development.md => development/index.md} (100%) create mode 100644 docs/development/regression-testing-strategy.md diff --git a/docs/NAVIGATION.md b/docs/NAVIGATION.md index c3c86ced..cf94042b 100644 --- a/docs/NAVIGATION.md +++ b/docs/NAVIGATION.md @@ -12,6 +12,7 @@ See https://oprypin.github.io/mkdocs-literate-nav/ - [Understanding the naming conventions](tutorials/understanding-the-naming-conventions.py) - [Further background](further-background/index.md) - [Dependency pinning and testing](further-background/dependency-pinning-and-testing.md) -- [Development](development.md) +- [Development](development/index.md) + - [Regression testing strategy](development/regression-testing-strategy.md) - [API reference](api/gcages/) - [Changelog](changelog.md) diff --git a/docs/development.md b/docs/development/index.md similarity index 100% rename from docs/development.md rename to docs/development/index.md diff --git a/docs/development/regression-testing-strategy.md b/docs/development/regression-testing-strategy.md new file mode 100644 index 00000000..5a54843c --- /dev/null +++ b/docs/development/regression-testing-strategy.md @@ -0,0 +1,33 @@ +# Regression testing strategy + +We provide workflows that have been used in or replicate key scientific processes. +To check these, we reproduce the full set of results. +However, this reproduction is relatively computationally expensive. +As a result, we have to have a slightly more complicated testing strategy +that allows us to check reproduction while also offering testing paths +that facilitate fast iteration during development. + +The strategy is as follows. +For each workflow we support, we put our tests in `tests/regression/[workflow_name]`. +The workflow tests then go in `tests/regression/[workflow_name]/test_[workflow_name]_workflow.py`. +These test functions that support running the complete workflows from end-to-end in full. +As a result, these tests are relatively expensive. +To avoid making our CI very slow, these tests are marked with `pytest.mark.skip_ci_default` +and these tests are skipped in the CI, except for commits to the main branch. + +We then include tests of individual stages in the workflow +(e.g. harmonisation, infilling) +in `tests/regression/[workflow_name]/test_[workflow_name]_[stage].py` +These tests are deliberately made fast and not marked with `pytest.mark.skip_ci_default` +so that they are run in the CI on every pull request +(and give us at least basic coverage of all the different workflow stages). +If this is possible while still running the stage as it was done in the workflow, +then we simply compare against the same inputs and outputs as the full workflow (for the given stage). +However, if this is not possible, we simplify and test against computationally faster +workflows that capture the key elements as much as possible. +For example, the AR6 workflow harmonisation is very fast to run, +so we simply run harmonisation in full when testing the AR6 harmonisation. +However, the AR6 workflow simple climate model running is slow, +so we only run the simple climate model with a fraction of its full probabilistic ensemble +when testing the simple climate model running step +(and leave the workflow tests to cover the case where the simple climate model is run in full). diff --git a/src/gcages/pandas_openscm_tmp.py b/src/gcages/pandas_openscm_tmp.py index b9102344..dc83b587 100644 --- a/src/gcages/pandas_openscm_tmp.py +++ b/src/gcages/pandas_openscm_tmp.py @@ -1,5 +1,5 @@ """ -Functionality that should be moved into [pandas-openscm][] +Functionality that should be moved into [pandas_openscm][] """ from __future__ import annotations @@ -8,8 +8,20 @@ import pandas as pd -# interpolate to annual def interpolate_to_annual_timesteps(indf: pd.DataFrame) -> pd.DataFrame: + """ + Interpolate to annual timesteps + + Parameters + ---------- + indf + Input data + + Returns + ------- + : + `indf`, interpolated to annual timesteps + """ # TODO: add checks yearly_timesteps = np.arange(indf.columns.min(), indf.columns.max() + 1) res = indf.reindex(columns=yearly_timesteps).T.interpolate(method="index").T