diff --git a/README.md b/README.md index 365cffb..a07afec 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ pip install earthkit-climate Example usage: ```python -from earthkit.climate.atmos import precipitation, temperature +from earthkit.climate.indicators import precipitation, temperature from earthkit.climate.utils import conversions # Example: compute a precipitation index diff --git a/docs/getting-started.rst b/docs/getting-started.rst index bfadabe..4b268d1 100644 --- a/docs/getting-started.rst +++ b/docs/getting-started.rst @@ -18,5 +18,5 @@ Compute a precipitation indicator from xclim: .. code-block:: python - from earthkit.climate.atmos import precipitation + from earthkit.climate.indicators import precipitation pr = precipitation.simple_daily_intensity(precip_data, freq="monthly") diff --git a/docs/how-tos/intro_precipitation_indices.ipynb b/docs/how-tos/intro_precipitation_indices.ipynb index 0dd6f09..4a5fbbd 100644 --- a/docs/how-tos/intro_precipitation_indices.ipynb +++ b/docs/how-tos/intro_precipitation_indices.ipynb @@ -34,7 +34,7 @@ "import matplotlib.pyplot as plt\n", "import xarray as xr\n", "\n", - "from earthkit.climate.atmos import precipitation\n", + "from earthkit.climate.indicators import precipitation\n", "\n", "warnings.filterwarnings(\"ignore\")\n", "\n", diff --git a/docs/how-tos/intro_temperature_indices.ipynb b/docs/how-tos/intro_temperature_indices.ipynb index b06c6a0..a0c6801 100644 --- a/docs/how-tos/intro_temperature_indices.ipynb +++ b/docs/how-tos/intro_temperature_indices.ipynb @@ -35,7 +35,7 @@ "import matplotlib.pyplot as plt\n", "import xarray as xr\n", "\n", - "from earthkit.climate.atmos import temperature\n", + "from earthkit.climate.indicators import temperature\n", "from earthkit.climate.utils.percentile import calculate_percentile_doy\n", "\n", "warnings.filterwarnings(\"ignore\")\n", diff --git a/docs/tutorials/era5_decadal_warming.ipynb b/docs/tutorials/era5_decadal_warming.ipynb index 8223d2e..8e59b7f 100644 --- a/docs/tutorials/era5_decadal_warming.ipynb +++ b/docs/tutorials/era5_decadal_warming.ipynb @@ -37,7 +37,7 @@ "import matplotlib.pyplot as plt\n", "import pandas as pd\n", "\n", - "from earthkit.climate.atmos import temperature" + "from earthkit.climate.indicators import temperature" ] }, { diff --git a/docs/tutorials/frost_days_pyrenees.ipynb b/docs/tutorials/frost_days_pyrenees.ipynb index 969f029..74a6ca7 100644 --- a/docs/tutorials/frost_days_pyrenees.ipynb +++ b/docs/tutorials/frost_days_pyrenees.ipynb @@ -44,7 +44,7 @@ "import earthkit.transforms as ekt\n", "import numpy as np\n", "\n", - "from earthkit.climate.atmos import temperature" + "from earthkit.climate.indicators import temperature" ] }, { diff --git a/docs/tutorials/heatwave_evolution.ipynb b/docs/tutorials/heatwave_evolution.ipynb index 181412e..af15c1f 100644 --- a/docs/tutorials/heatwave_evolution.ipynb +++ b/docs/tutorials/heatwave_evolution.ipynb @@ -31,7 +31,7 @@ "import numpy\n", "import xarray\n", "\n", - "from earthkit.climate.atmos import temperature\n", + "from earthkit.climate.indicators import temperature\n", "\n", "warnings.filterwarnings(\"ignore\")" ] diff --git a/docs/tutorials/tropical_nights_cooling_demand.ipynb b/docs/tutorials/tropical_nights_cooling_demand.ipynb index c0d3f43..c0dbb5a 100644 --- a/docs/tutorials/tropical_nights_cooling_demand.ipynb +++ b/docs/tutorials/tropical_nights_cooling_demand.ipynb @@ -30,7 +30,7 @@ "import earthkit.plots as ekp\n", "import matplotlib.pyplot as plt\n", "\n", - "from earthkit.climate.atmos import temperature\n", + "from earthkit.climate.indicators import temperature\n", "\n", "warnings.filterwarnings(\"ignore\")\n", "plt.rcParams[\"figure.figsize\"] = (12, 6)" diff --git a/docs/user-guide.rst b/docs/user-guide.rst index ddabac4..b6cdb96 100644 --- a/docs/user-guide.rst +++ b/docs/user-guide.rst @@ -5,7 +5,7 @@ Example usage: .. code-block:: python - from earthkit.climate.atmos import precipitation, temperature + from earthkit.climate.indicators import precipitation, temperature from earthkit.climate.utils import conversions # Example: compute a precipitation index diff --git a/src/earthkit/climate/__init__.py b/src/earthkit/climate/__init__.py index db97ea4..4515e80 100644 --- a/src/earthkit/climate/__init__.py +++ b/src/earthkit/climate/__init__.py @@ -15,13 +15,9 @@ __version__ = "999" -import earthkit.climate.atmos as atmos -import earthkit.climate.land as land -import earthkit.climate.ocean as ocean +import earthkit.climate.indicators as indicators # noqa __all__ = [ "__version__", - "atmos", - "land", - "ocean", + "indicators", ] diff --git a/src/earthkit/climate/atmos/__init__.py b/src/earthkit/climate/atmos/__init__.py deleted file mode 100644 index 72a72db..0000000 --- a/src/earthkit/climate/atmos/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# (C) Copyright 2025 - ECMWF and individual contributors. - -"""Atmos indicators.""" - -from earthkit.climate.atmos.precipitation import * # noqa -from earthkit.climate.atmos.synoptic import * # noqa -from earthkit.climate.atmos.temperature import * # noqa -from earthkit.climate.atmos.wind import * # noqa diff --git a/src/earthkit/climate/indicators/__init__.py b/src/earthkit/climate/indicators/__init__.py new file mode 100644 index 0000000..7731e6c --- /dev/null +++ b/src/earthkit/climate/indicators/__init__.py @@ -0,0 +1,12 @@ +# (C) Copyright 2025 - ECMWF and individual contributors. + +"""Climate indicators.""" + +from earthkit.climate.indicators.hydrology import * # noqa +from earthkit.climate.indicators.land import * # noqa +from earthkit.climate.indicators.precipitation import * # noqa +from earthkit.climate.indicators.sea_ice import * # noqa +from earthkit.climate.indicators.snow import * # noqa +from earthkit.climate.indicators.synoptic import * # noqa +from earthkit.climate.indicators.temperature import * # noqa +from earthkit.climate.indicators.wind import * # noqa diff --git a/src/earthkit/climate/land/hydrology.py b/src/earthkit/climate/indicators/hydrology.py similarity index 100% rename from src/earthkit/climate/land/hydrology.py rename to src/earthkit/climate/indicators/hydrology.py diff --git a/src/earthkit/climate/land/land.py b/src/earthkit/climate/indicators/land.py similarity index 100% rename from src/earthkit/climate/land/land.py rename to src/earthkit/climate/indicators/land.py diff --git a/src/earthkit/climate/atmos/precipitation.py b/src/earthkit/climate/indicators/precipitation.py similarity index 100% rename from src/earthkit/climate/atmos/precipitation.py rename to src/earthkit/climate/indicators/precipitation.py diff --git a/src/earthkit/climate/ocean/sea_ice.py b/src/earthkit/climate/indicators/sea_ice.py similarity index 100% rename from src/earthkit/climate/ocean/sea_ice.py rename to src/earthkit/climate/indicators/sea_ice.py diff --git a/src/earthkit/climate/land/snow.py b/src/earthkit/climate/indicators/snow.py similarity index 100% rename from src/earthkit/climate/land/snow.py rename to src/earthkit/climate/indicators/snow.py diff --git a/src/earthkit/climate/atmos/synoptic.py b/src/earthkit/climate/indicators/synoptic.py similarity index 100% rename from src/earthkit/climate/atmos/synoptic.py rename to src/earthkit/climate/indicators/synoptic.py diff --git a/src/earthkit/climate/atmos/temperature.py b/src/earthkit/climate/indicators/temperature.py similarity index 100% rename from src/earthkit/climate/atmos/temperature.py rename to src/earthkit/climate/indicators/temperature.py diff --git a/src/earthkit/climate/atmos/wind.py b/src/earthkit/climate/indicators/wind.py similarity index 100% rename from src/earthkit/climate/atmos/wind.py rename to src/earthkit/climate/indicators/wind.py diff --git a/src/earthkit/climate/land/__init__.py b/src/earthkit/climate/land/__init__.py deleted file mode 100644 index 8e27de1..0000000 --- a/src/earthkit/climate/land/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# (C) Copyright 2025 - ECMWF and individual contributors. - -"""Land indicators.""" - -from earthkit.climate.land.hydrology import * # noqa -from earthkit.climate.land.land import * # noqa -from earthkit.climate.land.snow import * # noqa diff --git a/src/earthkit/climate/ocean/__init__.py b/src/earthkit/climate/ocean/__init__.py deleted file mode 100644 index 80a44c2..0000000 --- a/src/earthkit/climate/ocean/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# (C) Copyright 2025 - ECMWF and individual contributors. - -"""Sea ice indicators.""" - -from earthkit.climate.ocean.sea_ice import * # noqa diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index c96011b..2b52ced 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -15,8 +15,8 @@ import pytest import xarray as xr -import earthkit.climate.atmos.precipitation as ek_pr -import earthkit.climate.atmos.temperature as ek_temp +import earthkit.climate.indicators.precipitation as ek_pr +import earthkit.climate.indicators.temperature as ek_temp from earthkit.climate.utils import percentile_doy diff --git a/tests/integration/test_performance.py b/tests/integration/test_performance.py index bcc05bd..88e9b52 100644 --- a/tests/integration/test_performance.py +++ b/tests/integration/test_performance.py @@ -29,8 +29,8 @@ import pytest import xarray as xr -import earthkit.climate.atmos.precipitation as ek_pr -import earthkit.climate.atmos.temperature as ek_temp +import earthkit.climate.indicators.precipitation as ek_pr +import earthkit.climate.indicators.temperature as ek_temp from earthkit.climate.utils.percentile import percentile_doy warnings.filterwarnings("ignore") diff --git a/tests/unit/atmos/test_precipitation.py b/tests/unit/atmos/test_precipitation.py index e9ffe65..9b61ef5 100644 --- a/tests/unit/atmos/test_precipitation.py +++ b/tests/unit/atmos/test_precipitation.py @@ -14,7 +14,7 @@ import xarray from pytest_mock import MockerFixture -from earthkit.climate.atmos import precipitation +from earthkit.climate.indicators import precipitation INDICATORS = [ (precipitation.antecedent_precipitation_index, "antecedent_precipitation_index", {"val": "test"}), diff --git a/tests/unit/atmos/test_synoptic.py b/tests/unit/atmos/test_synoptic.py index efe2f7c..855d3cf 100644 --- a/tests/unit/atmos/test_synoptic.py +++ b/tests/unit/atmos/test_synoptic.py @@ -14,7 +14,7 @@ import xarray from pytest_mock import MockerFixture -from earthkit.climate.atmos import synoptic +from earthkit.climate.indicators import synoptic INDICATORS = [ (synoptic.jetstream_metric_woollings, "jetstream_metric_woollings"), diff --git a/tests/unit/atmos/test_temperature.py b/tests/unit/atmos/test_temperature.py index c899abf..8ad7701 100644 --- a/tests/unit/atmos/test_temperature.py +++ b/tests/unit/atmos/test_temperature.py @@ -14,7 +14,7 @@ import xarray from pytest_mock import MockerFixture -from earthkit.climate.atmos import temperature +from earthkit.climate.indicators import temperature INDICATORS = [ (temperature.australian_hardiness_zones, "australian_hardiness_zones"), diff --git a/tests/unit/atmos/test_wind.py b/tests/unit/atmos/test_wind.py index 038964f..b72730b 100644 --- a/tests/unit/atmos/test_wind.py +++ b/tests/unit/atmos/test_wind.py @@ -14,7 +14,7 @@ import xarray from pytest_mock import MockerFixture -from earthkit.climate.atmos import wind +from earthkit.climate.indicators import wind INDICATORS = [ (wind.calm_days, "calm_days"), diff --git a/tests/unit/land/test_hydrology.py b/tests/unit/land/test_hydrology.py index d6e7012..a1f31f6 100644 --- a/tests/unit/land/test_hydrology.py +++ b/tests/unit/land/test_hydrology.py @@ -14,7 +14,7 @@ import xarray from pytest_mock import MockerFixture -from earthkit.climate.land import hydrology +from earthkit.climate.indicators import hydrology INDICATORS = [ (hydrology.base_flow_index, "base_flow_index", {"val": "test"}), diff --git a/tests/unit/land/test_land.py b/tests/unit/land/test_land.py index 9cf7e6b..baa8661 100644 --- a/tests/unit/land/test_land.py +++ b/tests/unit/land/test_land.py @@ -14,7 +14,7 @@ import xarray from pytest_mock import MockerFixture -from earthkit.climate.land import land +from earthkit.climate.indicators import land INDICATORS = [ (land.flow_index, "flow_index", {"val": "test"}), diff --git a/tests/unit/land/test_snow.py b/tests/unit/land/test_snow.py index 1511c8a..ca2e07c 100644 --- a/tests/unit/land/test_snow.py +++ b/tests/unit/land/test_snow.py @@ -14,7 +14,7 @@ import xarray from pytest_mock import MockerFixture -from earthkit.climate.land import snow +from earthkit.climate.indicators import snow INDICATORS = [ (snow.blowing_snow, "blowing_snow", {"val": "test"}), diff --git a/tests/unit/ocean/test_sea_ice.py b/tests/unit/ocean/test_sea_ice.py index fe370b7..dee619d 100644 --- a/tests/unit/ocean/test_sea_ice.py +++ b/tests/unit/ocean/test_sea_ice.py @@ -14,7 +14,7 @@ import xarray from pytest_mock import MockerFixture -from earthkit.climate.ocean import sea_ice +from earthkit.climate.indicators import sea_ice INDICATORS = [ (sea_ice.sea_ice_area, "sea_ice_area", {"val": "test"}), diff --git a/tools/xclim_wrappers_generator.py b/tools/xclim_wrappers_generator.py index c5e9bcd..fcc7962 100644 --- a/tools/xclim_wrappers_generator.py +++ b/tools/xclim_wrappers_generator.py @@ -388,126 +388,120 @@ def generate_module_content(category: str, module_name: str, indicators: List[An ) -def generate_for_module(module_name: str, output_base_dir: Any) -> None: - """Generate wrappers for a specific xclim indicator module. +def _collect_categories(module_name: str) -> dict[str, list[Any]]: + """Collect indicators by category from an xclim indicator module. Parameters ---------- module_name : str The xclim module name (e.g. 'atmos', 'land', 'seaIce'). - output_base_dir : Any - The path-like directory where submodules should be written. Returns ------- - None + dict[str, list[Any]] + Mapping from category name to list of xclim indicator objects. """ try: module = importlib.import_module(f"xclim.indicators.{module_name}") except ImportError: print(f"Skipping xclim.indicators.{module_name} (not found)") - return - - # Use snake_case for directory names - dir_name = module_name - if module_name == "seaIce": - dir_name = "ocean" + return {} - output_dir = output_base_dir / dir_name - output_dir.mkdir(exist_ok=True, parents=True) - - # Get all potential indicators from __all__ if present if hasattr(module, "__all__"): names = module.__all__ else: - # Fallback to public members names = [n for n, o in inspect.getmembers(module) if not n.startswith("_")] - # Map internal xclim module names to our category names - # This mapping is mostly for 'atmos' where indicators are split module_to_category = { "_precip": "precipitation", "_temperature": "temperature", "_wind": "wind", "_synoptic": "synoptic", - "_conversion": "precipitation", # Snow depth/water equivalent conversions + "_conversion": "precipitation", "_snow": "snow", "_streamflow": "hydrology", "_seaice": "sea_ice", } - indicators_map = {} + indicators_map: dict[str, list[Any]] = {} for name in names: - # We need the object to check type/attributes and pass to generation try: obj = getattr(module, name) except AttributeError: continue - # Basic check if it is likely an indicator (has identifier) if not hasattr(obj, "identifier"): continue - # Check which submodule/category it comes from - # e.g. xclim.indicators.atmos._precip or xclim.indicators.land._snow obj_module = getattr(obj, "__module__", "") - # Extract the last part of the module path submodule_name = obj_module.split(".")[-1] if submodule_name in module_to_category: category = module_to_category[submodule_name] else: - # Fallback based on keywords or other attributes keywords = getattr(obj, "keywords", "") if "precipitation" in keywords or "snow" in keywords or "rain" in keywords: category = "precipitation" elif "temperature" in keywords or "heat" in keywords or "cold" in keywords: category = "temperature" else: - category = module_name # Use module name as fallback category + category = module_name if category not in indicators_map: indicators_map[category] = [] indicators_map[category].append(obj) + return indicators_map + + +def generate_for_module( + module_name: str, output_dir: Any, all_categories: set[str] | None = None +) -> set[str]: + """Generate category .py files in a single indicators directory. + + Parameters + ---------- + module_name : str + The xclim module name (e.g. 'atmos', 'land', 'seaIce'). + output_dir : Any + The path-like directory where category .py files are written. + all_categories : set[str] | None + Accumulator for all categories generated across modules. + + Returns + ------- + set[str] + Updated set of all category names produced. + """ + if all_categories is None: + all_categories = set() + + indicators_map = _collect_categories(module_name) + if not indicators_map: + return all_categories + for category, indicators in indicators_map.items(): - if not indicators: - continue + all_categories.add(category) filename = f"{category}.py" filepath = output_dir / filename print(f"Generating {filepath} with {len(indicators)} indicators...") content = generate_module_content(category, module_name, indicators) - with open(filepath, "w") as f: f.write(content) print(f"Written {filepath}") - # Create __init__.py for the module if it doesn't exist or needs update - init_path = output_dir / "__init__.py" - # For all modules, we want to expose all submodules - categories = sorted(indicators_map.keys()) - init_content = "# (C) Copyright 2025 - ECMWF and individual contributors.\n\n" - - title = module_name.capitalize() - if module_name == "seaIce": - title = "Sea ice" - init_content += f'"""{title} indicators."""\n\n' - - for cat in categories: - init_content += f"from .{cat} import * # noqa\n" - - with open(init_path, "w") as f: - f.write(init_content) + return all_categories def main() -> None: """Run the wrapper generator to generate climate index modules. - This function locates the target package directory using pathlib - and triggers the generation for atmos, land, and seaIce indicators. + This function locates the target indicators package directory using + pathlib and triggers the generation for atmos, land, and seaIce + indicators, writing everything flat into indicators/. Returns ------- @@ -515,14 +509,26 @@ def main() -> None: """ import pathlib - output_base_dir = pathlib.Path(__file__).parent.parent / "src" / "earthkit" / "climate" + output_dir = pathlib.Path(__file__).parent.parent / "src" / "earthkit" / "climate" / "indicators" + output_dir.mkdir(exist_ok=True, parents=True) - # Categories to generate - # atmos covers most current ones, adding land and seaIce xclim_modules = ["atmos", "land", "seaIce"] + all_categories: set[str] = set() for module_name in xclim_modules: - generate_for_module(module_name, output_base_dir) + all_categories = generate_for_module(module_name, output_dir, all_categories) + + # Generate single __init__.py that imports all category modules + init_path = output_dir / "__init__.py" + categories = sorted(all_categories) + init_content = "# (C) Copyright 2025 - ECMWF and individual contributors.\n\n" + init_content += '"""Climate indicators."""\n\n' + for cat in categories: + init_content += f"from earthkit.climate.indicators.{cat} import * # noqa\n" + + with open(init_path, "w") as f: + f.write(init_content) + print(f"Generated {init_path} with {len(categories)} modules") if __name__ == "__main__":