Skip to content

Commit 168d71a

Browse files
committed
Centralized the logging level and made it settable via CLI and config file. Moved the resample logic into a resampling engine, added a convenience for_each domain expansion, changed time_interval to just TIME and made generally possible to define multiple time domains there as a list, removed time iteration to plot and added a _plot_one func, eventually this plot func will abstract iteration and move up to Plot class
1 parent 393a652 commit 168d71a

30 files changed

Lines changed: 402 additions & 99 deletions

ClimateGraph/appkernel.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44

55
from ClimateGraph.utils.parser import Parser
66

7-
logging.basicConfig(level=logging.INFO)
8-
97

108
class AppKernel:
119
"""ClimateGraph execution and state manager. Orquestrates the other modules."""
@@ -65,19 +63,34 @@ def set_analysis_data(self, analysis: dict | None = None):
6563
self.output_path = analysis.get("output_path", Path("./"))
6664
self.workers = analysis.get("workers")
6765

68-
def run(self, control_path: Path):
66+
def _configure_logging(self):
67+
"""_configure_logging Set the root logger level from self.debug.
68+
69+
The only place in the codebase that calls ``logging.basicConfig``.
70+
``force=True`` so this always wins regardless of import order, even if
71+
some other library configured logging before this runs.
72+
"""
73+
level = logging.DEBUG if self.debug else logging.INFO
74+
logging.basicConfig(level=level, force=True)
75+
76+
def run(self, control_path: Path, debug_override: bool = False):
6977
"""run Run the ClimateGraph routine.
7078
7179
Parameters
7280
----------
7381
control_path : Path
7482
Path of the configuration file for the ClimateGraph run.
83+
debug_override : bool, optional
84+
CLI override for the control file's ``debug`` setting. ORed with
85+
the control file's value, so passing True always wins. By default False
7586
"""
7687
self.analysis, self.data, self.plots, self.domains = self.read_control(
7788
control_path
7889
)
7990

8091
self.set_analysis_data()
92+
self.debug = debug_override or self.debug
93+
self._configure_logging()
8194
# if self.eager : self.load_data()
8295
with self._dask_client():
8396
self.plot()

ClimateGraph/cli.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,26 @@ def run(
2424
help="File defining the analysis.",
2525
),
2626
],
27+
debug: Annotated[
28+
bool,
29+
typer.Option(
30+
"--debug",
31+
help="Force DEBUG-level logging. ORed with the control file's debug setting.",
32+
),
33+
] = False,
2734
):
28-
"""run Run the ClimateGraph routine with a control file.
35+
"""Run the ClimateGraph routine with a control file.
2936
3037
Parameters
3138
----------
3239
control_file : Annotated[ Path, typer.Argument, optional
3340
Path of the configuration file for the ClimateGraph run, by default True, dir_okay=False, file_okay=True, resolve_path=True, help="File defining the analysis.", ), ]
41+
debug : Annotated[bool, typer.Option, optional
42+
When passed, forces DEBUG-level logging regardless of the control file's
43+
debug setting. By default False
3444
"""
3545
appK = AppKernel()
36-
appK.run(control_file)
46+
appK.run(control_file, debug_override=debug)
3747

3848

3949
@app.command()

ClimateGraph/data/data.py

Lines changed: 16 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import numpy as np
77
import pandas as pd
88
import xarray as xr
9-
from pyresample import kd_tree
9+
from ClimateGraph.utils.resample_engine import ResampleEngine, get_engine
1010

1111
from ClimateGraph.reader import Reader
1212
from ClimateGraph.reader.reader.reader import ReadSpec
@@ -398,7 +398,6 @@ def get_coordinates(
398398
coords = coords[0]
399399
return coords
400400

401-
# Might need to become a whole pairing engine in some time. But for now this will do
402401
def resample_vars(
403402
self,
404403
other: "Data",
@@ -407,8 +406,10 @@ def resample_vars(
407406
time_interval: str | None = None,
408407
radius_of_influence: int = 10000,
409408
time_tolerance: str | None = "30min",
409+
engine: str | ResampleEngine = "pyresample",
410+
engine_kwargs: dict | None = None,
410411
) -> xr.DataArray | xr.Dataset:
411-
"""resample_vars Resample the requested vars using Pyresample and the geom attributes. Until now only NearestNeighbour method is being used.
412+
"""Resample the requested vars using the specified ResampleEngine.
412413
413414
Parameters
414415
----------
@@ -421,9 +422,15 @@ def resample_vars(
421422
time_interval : str | None, optional
422423
Time interval in dd/mm/yyyy-dd/mm/yyyy or d/m/yyyy-d/m/yyyy, by default None
423424
radius_of_influence : int, optional
424-
Radius length in meters to use for resampling with nearest neighbours. Lower improves computation time but may result in less resulting data, by default 10000
425+
Radius length in meters to use for resampling. by default 10000
425426
time_tolerance : str | None, optional
426-
Pandas-style timedelta used as the tolerance when snapping ``other``'s time axis onto ``self``'s via nearest-neighbour reindex. Handles cases where the two sources are on the same cadence but offset (e.g. CHIMERE at HH:30 vs. point-surface at HH:00). Set to ``None`` to disable snapping. Default ``"30min"``.
427+
Pandas-style timedelta used as the tolerance when snapping ``other``'s
428+
time axis onto ``self``'s via nearest-neighbour reindex. Default ``"30min"``.
429+
engine : str | ResampleEngine, optional
430+
Resample backend name or instance. Default ``"pyresample"``.
431+
engine_kwargs : dict | None, optional
432+
Extra kwargs forwarded to the engine constructor
433+
(e.g. ``method``, ``sigmas``). Default None.
427434
428435
Returns
429436
-------
@@ -433,20 +440,15 @@ def resample_vars(
433440
if isinstance(vars, str):
434441
vars = [vars]
435442

443+
resample_engine = get_engine(engine, **(engine_kwargs or {}))
436444
src_geom = other.geom
437445
dst_geom = self.geom
438446

439-
valid_input, valid_output, index_array, dist_array = kd_tree.get_neighbour_info(
440-
src_geom,
441-
dst_geom,
442-
radius_of_influence=radius_of_influence,
443-
neighbours=1,
447+
info = resample_engine.prepare(
448+
src_geom, dst_geom, radius_of_influence=radius_of_influence,
444449
)
450+
_resample = resample_engine.make_resampler(info, dst_geom.shape)
445451

446-
# Establish the destination time grid up front so var_src can be snapped onto
447-
# it before resampling. Without this, sources whose time axis is offset from
448-
# self's (e.g. CHIMERE labelled at HH:30 vs point-surface at HH:00) end up
449-
# producing a resampled array whose time length doesn't match self's.
450452
if self.resampled is None:
451453
self.resampled = self.obj.drop_vars(list(self.obj.data_vars))
452454
self.resampled = time_resampling(
@@ -458,14 +460,10 @@ def resample_vars(
458460
var_dst_dims = self.get_var(var).sizes
459461
var_src = other.get_var(var)
460462

461-
# The caller wants the var either in a specified unit (dict form) or
462-
# in the unit of the resampling base (list/str form). var_unit yields
463-
# None when units weren't declared, and change_unit no-ops on None.
464463
dst_unit = self.var_unit(var) if isinstance(vars, list | str) else vars[var]
465464
src_unit = other.var_unit(var)
466465
var_src = change_unit(var_src, src_unit, dst_unit)
467466

468-
# time resampling
469467
var_src = time_resampling(
470468
var_src, timestep=timestep, time_interval=time_interval
471469
)
@@ -477,22 +475,6 @@ def resample_vars(
477475
tolerance=pd.Timedelta(time_tolerance),
478476
)
479477

480-
def _resample(x):
481-
return kd_tree.get_sample_from_neighbour_info(
482-
"nn",
483-
dst_geom.shape,
484-
x,
485-
valid_input,
486-
valid_output,
487-
index_array,
488-
dist_array,
489-
fill_value=np.nan,
490-
)
491-
492-
# input_core_dims: only the geom_dims of the source (the horizontal dims
493-
# the pyresample geometry was built from). Any extra dims like z are left
494-
# out so apply_ufunc loops over them automatically, broadcasting the
495-
# horizontal resampling across each vertical level independently.
496478
src_geom_dims = [d for d in var_src.dims if d in set(other.geom_dims)]
497479
dst_geom_dims = [d for d in var_dst_dims if d in set(self.geom_dims)]
498480
resampled = xr.apply_ufunc(

ClimateGraph/domain/domain.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
1-
import logging
21
from abc import ABC, abstractmethod
32

43
import xarray as xr
54
from pydantic import BaseModel
65

76
from ClimateGraph.utils.registry import RegistryMixin
87

9-
logging.basicConfig(level=logging.INFO) # TODO: make this settable from yaml file.
10-
118
# TODO: Change the use of BaseModel for actual attributes to improve modularization.
129

1310

ClimateGraph/domain/domains.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ class AttributeConfig(BaseModel):
1616
type: Literal["attribute", "attr"]
1717
field_name: str
1818
field_value: Any
19+
one_for_each: bool = False
20+
"""When field_value is a list and this is True, the parser expands this
21+
single domain block into one Attribute domain per value. When False (default),
22+
the set is analyzed together."""
1923

2024

2125
class Attribute(Domain):
@@ -39,7 +43,11 @@ def apply(self, data: xr.Dataset | xr.DataArray):
3943
"""
4044
field_name = self.domain_config.field_name
4145
field_value = self.domain_config.field_value
42-
return data.where((data[field_name] == field_value).compute(), drop=True)
46+
if isinstance(field_value, list):
47+
mask = data[field_name].isin(field_value)
48+
else:
49+
mask = data[field_name] == field_value
50+
return data.where(mask.compute(), drop=True)
4351

4452

4553
class PolygonConfig(BaseModel):

ClimateGraph/plot/plot.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import logging
21
from abc import ABC, abstractmethod
32
from pathlib import Path
43

@@ -12,8 +11,6 @@
1211
from ClimateGraph.domain import Domain
1312
from ClimateGraph.utils.registry import RegistryMixin
1413

15-
logging.basicConfig(level=logging.INFO) # TODO: make this settable from yaml file.
16-
1714
# Which keys in plot_kwargs get routed to which matplotlib call. A given key
1815
# may legitimately belong to more than one sink (e.g. `dpi` applies to both
1916
# the figure and savefig), so the sets overlap on purpose. matplotlib rejects

ClimateGraph/plot/plots.py

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
TimeBucketEnum,
1818
TimestepEnum,
1919
manage_time_interval,
20+
normalize_time,
2021
)
2122

2223
from .plot import Plot
@@ -93,7 +94,7 @@ class TimeSeriesConfig(BasePlotConfig):
9394
base: str
9495
radius_of_influence: int | None = Field(default=None)
9596
other_data: str | list[str] | None = Field(default=None)
96-
time_interval: str | list[str] | None = Field(default=None)
97+
time: str | list[str] | None = Field(default=None)
9798
timestep: TimestepEnum | None = Field(default=None)
9899
reduction_method: ReductionMethodEnum = Field(default=ReductionMethodEnum.mean)
99100
colors: str | None = Field(default=None) # TODO: implement
@@ -106,7 +107,12 @@ class Timeseries(Plot):
106107
aliases = ["ts", "time-series"]
107108

108109
def plot(self):
109-
"""plot Timeseries plotting method.
110+
"""plot Iterate over self.plot_config.time entries, rendering once per entry."""
111+
for time_interval in normalize_time(self.plot_config.time):
112+
self._plot_one(time_interval)
113+
114+
def _plot_one(self, time_interval: str | None):
115+
"""_plot_one Timeseries plotting method for a single time entry.
110116
The process goes as follows:
111117
1) Process arguments.
112118
2) Process base data: Time resampling and aligning, and unit conversion.
@@ -131,7 +137,6 @@ def plot(self):
131137

132138
radius_of_influence = self.plot_config.radius_of_influence
133139
timestep = self.plot_config.timestep
134-
time_interval = self.plot_config.time_interval
135140

136141
base = self.data[self.plot_config.base]
137142

@@ -239,7 +244,7 @@ class ScatterConfig(BasePlotConfig):
239244
base: str
240245
other: str
241246
radius_of_influence: int
242-
time_interval: str | None = Field(default=None)
247+
time: str | list[str] | None = Field(default=None)
243248
dimension: str = Field(default="time")
244249
timestep: TimestepEnum | None = Field(default=None)
245250
reduction_method: ReductionMethodEnum = Field(default=ReductionMethodEnum.mean)
@@ -259,7 +264,12 @@ class Scatter(Plot):
259264
aliases = ["sc"]
260265

261266
def plot(self):
262-
"""plot Scatter plotting method.
267+
"""plot Iterate over self.plot_config.time entries, rendering once per entry."""
268+
for time_interval in normalize_time(self.plot_config.time):
269+
self._plot_one(time_interval)
270+
271+
def _plot_one(self, time_interval: str | None):
272+
"""_plot_one Scatter plotting method for a single time entry.
263273
The process goes as follows:
264274
1) Process arguments.
265275
2) Process base data: Time resampling and aligning, and unit conversion.
@@ -277,7 +287,6 @@ def plot(self):
277287
vars = self.plot_config.vars
278288
radius_of_influence = self.plot_config.radius_of_influence
279289
timestep = self.plot_config.timestep
280-
time_interval = self.plot_config.time_interval
281290
domains = {
282291
name: dom
283292
for name, dom in self.domains.items()
@@ -394,7 +403,7 @@ class SpatialOverlayConfig(BasePlotConfig):
394403
type: Literal["spatial-overlay", "spatialoverlay", "so"]
395404
base: str
396405
superposed: str
397-
time_interval: str | None = Field(default=None)
406+
time: str | list[str] | None = Field(default=None)
398407
levels: int = Field(default=10)
399408
reduction_method: ReductionMethodEnum = Field(default=ReductionMethodEnum.mean)
400409
crs: CRSEnum | None = Field(default=None)
@@ -413,7 +422,12 @@ class SpatialOverlay(Plot):
413422
config = SpatialOverlayConfig
414423

415424
def plot(self):
416-
"""plot Spatial Overlay plotting method.
425+
"""plot Iterate over self.plot_config.time entries, rendering once per entry."""
426+
for time_interval in normalize_time(self.plot_config.time):
427+
self._plot_one(time_interval)
428+
429+
def _plot_one(self, time_interval: str | None):
430+
"""_plot_one Spatial Overlay plotting method for a single time entry.
417431
The process goes as follows:
418432
1) Process arguments.
419433
2) Iterate through Domains.
@@ -429,7 +443,6 @@ def plot(self):
429443
base: RegularGrid = self.data[self.plot_config.base]
430444
superposed: PointSurface = self.data[self.plot_config.superposed]
431445
vars = self.plot_config.vars
432-
time_interval = self.plot_config.time_interval
433446
crs = (
434447
base.crs.crs if self.plot_config.crs is None else self.plot_config.crs.crs
435448
) # TODO: make this simpler haha
@@ -574,7 +587,7 @@ class SpatialMapConfig(BasePlotConfig):
574587

575588
type: Literal["spatial-map", "spatialmap", "map", "sm"]
576589
data: str
577-
time_interval: str | None = Field(default=None)
590+
time: str | list[str] | None = Field(default=None)
578591
levels: int = Field(default=10)
579592
reduction_method: ReductionMethodEnum = Field(default=ReductionMethodEnum.mean)
580593
crs: CRSEnum | None = Field(default=None)
@@ -601,7 +614,12 @@ class SpatialMap(Plot):
601614
config = SpatialMapConfig
602615

603616
def plot(self):
604-
"""plot Spatial Map plotting method.
617+
"""plot Iterate over self.plot_config.time entries, rendering once per entry."""
618+
for time_interval in normalize_time(self.plot_config.time):
619+
self._plot_one(time_interval)
620+
621+
def _plot_one(self, time_interval: str | None):
622+
"""_plot_one Spatial Map plotting method for a single time entry.
605623
The process goes as follows:
606624
1) Process arguments.
607625
2) Iterate through Domains.
@@ -615,7 +633,6 @@ def plot(self):
615633
# Get relevant data from the config
616634
data: RegularGrid | PointSurface = self.data[self.plot_config.data]
617635
vars = self.plot_config.vars
618-
time_interval = self.plot_config.time_interval
619636
crs = data.crs.crs if self.plot_config.crs is None else self.plot_config.crs.crs
620637
domains = {
621638
name: dom
@@ -735,7 +752,7 @@ class TimeCycleConfig(BasePlotConfig):
735752
base: str
736753
other_data: str | list[str] | None = Field(default=None)
737754
radius_of_influence: int | None = Field(default=None)
738-
time_interval: str | list[str] | None = Field(default=None)
755+
time: str | list[str] | None = Field(default=None)
739756
timestep: TimestepEnum | None = Field(default=None)
740757
time_buckets: TimeBucketEnum = Field(default=TimeBucketEnum.day)
741758
reduction_method: ReductionMethodEnum = Field(default=ReductionMethodEnum.mean)
@@ -771,6 +788,11 @@ class TimeCycle(Plot):
771788
aliases = ["time cycle", "cycle"]
772789

773790
def plot(self):
791+
"""plot Iterate over self.plot_config.time entries, rendering once per entry."""
792+
for time_interval in normalize_time(self.plot_config.time):
793+
self._plot_one(time_interval)
794+
795+
def _plot_one(self, time_interval: str | None):
774796
# Get relevant data from the config
775797
vars = self.plot_config.vars
776798
domains = {
@@ -782,7 +804,6 @@ def plot(self):
782804
domains = {"": None}
783805

784806
radius_of_influence = self.plot_config.radius_of_influence
785-
time_interval = self.plot_config.time_interval
786807
timestep = self.plot_config.timestep
787808
time_bucket = (
788809
self.plot_config.time_buckets.value

0 commit comments

Comments
 (0)