Skip to content

Commit 5922da9

Browse files
committed
wip: om domains
1 parent 835a926 commit 5922da9

File tree

3 files changed

+513
-0
lines changed

3 files changed

+513
-0
lines changed

python/omfiles/om_domains.py

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
from abc import ABC, abstractmethod
2+
from functools import cached_property
3+
from typing import Optional, Tuple
4+
5+
import numpy as np
6+
7+
from omfiles.utils import _modulo_positive
8+
9+
10+
class AbstractGrid(ABC):
11+
"""
12+
Abstract base class for weather model grid definitions.
13+
14+
This defines the interface that all grid implementations must follow.
15+
"""
16+
17+
@property
18+
@abstractmethod
19+
def grid_type(self) -> str:
20+
"""Return the grid type identifier."""
21+
pass
22+
23+
@cached_property
24+
@abstractmethod
25+
def latitude(self) -> np.ndarray:
26+
"""
27+
Return the latitude coordinates array.
28+
29+
Uses cached_property to ensure the array is computed only once.
30+
"""
31+
pass
32+
33+
@cached_property
34+
@abstractmethod
35+
def longitude(self) -> np.ndarray:
36+
"""
37+
Return the longitude coordinates array.
38+
39+
Uses cached_property to ensure the array is computed only once.
40+
"""
41+
pass
42+
43+
@property
44+
@abstractmethod
45+
def shape(self) -> Tuple[int, int]:
46+
"""Return the grid shape as (n_lat, n_lon)."""
47+
pass
48+
49+
@abstractmethod
50+
def findPointXy(self, lat: float, lon: float) -> Optional[Tuple[int, int]]:
51+
"""
52+
Find grid point indices (x, y) for given lat/lon coordinates.
53+
"""
54+
pass
55+
56+
@abstractmethod
57+
def getCoordinates(self, x: int, y: int) -> Tuple[float, float]:
58+
"""
59+
Get lat/lon coordinates for a given grid point indices.
60+
"""
61+
pass
62+
63+
64+
65+
class RegularLatLonGrid(AbstractGrid):
66+
"""
67+
Regular latitude-longitude grid implementation.
68+
69+
This represents a standard equirectangular grid with uniform spacing.
70+
"""
71+
72+
def __init__(
73+
self,
74+
lat_start: float,
75+
lat_steps: int,
76+
lat_step_size: float,
77+
lon_start: float,
78+
lon_steps: int,
79+
lon_step_size: float,
80+
):
81+
"""
82+
Initialize a regular lat/lon grid.
83+
84+
Parameters:
85+
-----------
86+
lat_start : float
87+
Starting latitude value
88+
lat_steps : int
89+
Number of latitude points
90+
lat_step_size : float
91+
Spacing between latitude points
92+
lon_start : float
93+
Starting longitude value
94+
lon_steps : int
95+
Number of longitude points
96+
lon_step_size : float
97+
Spacing between longitude points
98+
"""
99+
self._lat_start = lat_start
100+
self._lat_steps = lat_steps
101+
self._lat_step_size = lat_step_size
102+
self._lon_start = lon_start
103+
self._lon_steps = lon_steps
104+
self._lon_step_size = lon_step_size
105+
106+
@property
107+
def grid_type(self) -> str:
108+
return "regular_latlon"
109+
110+
@cached_property
111+
def latitude(self) -> np.ndarray:
112+
"""
113+
Lazily compute and cache the latitude coordinate array.
114+
"""
115+
return np.linspace(
116+
self._lat_start,
117+
self._lat_start + self._lat_step_size * self._lat_steps,
118+
self._lat_steps,
119+
endpoint=False
120+
)
121+
122+
@cached_property
123+
def longitude(self) -> np.ndarray:
124+
"""
125+
Lazily compute and cache the longitude coordinate array.
126+
"""
127+
return np.linspace(
128+
self._lon_start,
129+
self._lon_start + self._lon_step_size * self._lon_steps,
130+
self._lon_steps,
131+
endpoint=False
132+
)
133+
134+
@property
135+
def shape(self) -> Tuple[int, int]:
136+
return (self._lat_steps, self._lon_steps)
137+
138+
def findPointXy(self, lat: float, lon: float) -> Optional[Tuple[int, int]]:
139+
"""
140+
Find grid point indices (x, y) for given lat/lon coordinates.
141+
142+
Parameters:
143+
-----------
144+
lat : float
145+
Latitude in degrees
146+
lon : float
147+
Longitude in degrees
148+
149+
Returns:
150+
--------
151+
tuple or None
152+
(x, y) grid indices if point is in grid, None otherwise
153+
"""
154+
# Calculate raw x and y indices
155+
x = int(round((lon - self._lon_start) / self._lon_step_size))
156+
y = int(round((lat - self._lat_start) / self._lat_step_size))
157+
158+
# Handle wrapping for global grids
159+
xx = _modulo_positive(x, self._lon_steps) if (self._lon_steps * self._lon_step_size) >= 359 else x
160+
yy = _modulo_positive(y, self._lat_steps) if (self._lat_steps * self._lat_step_size) >= 179 else y
161+
162+
# Check if point is within grid bounds
163+
if yy < 0 or xx < 0 or yy >= self._lat_steps or xx >= self._lon_steps:
164+
return None
165+
166+
return (xx, yy)
167+
168+
def getCoordinates(self, x: int, y: int) -> Tuple[float, float]:
169+
"""
170+
Get lat/lon coordinates for a given grid point indices.
171+
172+
Parameters:
173+
-----------
174+
x : longitude index
175+
y : latitude index
176+
177+
Returns:
178+
--------
179+
tuple
180+
(latitude, longitude) coordinates
181+
"""
182+
183+
lat = self._lat_start + float(y) * self._lat_step_size
184+
lon = self._lon_start + float(x) * self._lon_step_size
185+
186+
return (lat, lon)
187+
188+
class OmDomain:
189+
"""
190+
Class representing a domain configuration for a weather model.
191+
192+
This class provides metadata and configuration for different
193+
weather model grids used in Open-Meteo.
194+
"""
195+
196+
def __init__(
197+
self,
198+
name: str,
199+
grid: AbstractGrid,
200+
file_length: int,
201+
temporal_resolution_seconds: int = 3600
202+
):
203+
"""
204+
Initialize a domain configuration.
205+
206+
Parameters:
207+
-----------
208+
name : str
209+
Name of the domain
210+
grid : AbstractGrid
211+
Grid implementation for this domain
212+
file_length : int
213+
Number of time steps in each file chunk
214+
description : str, optional
215+
Human-readable description of the domain
216+
variables : list, optional
217+
List of variable names available in this domain
218+
temporal_resolution_seconds : int, optional
219+
Time resolution in seconds (default: 3600 = 1 hour)
220+
"""
221+
self.name = name
222+
self.grid = grid
223+
self.file_length = file_length
224+
self.temporal_resolution_seconds = temporal_resolution_seconds
225+
226+
def time_to_chunk_index(self, timestamp: np.datetime64) -> int:
227+
"""
228+
Convert a timestamp to a chunk index.
229+
230+
Parameters:
231+
-----------
232+
timestamp : np.datetime64
233+
The timestamp to convert
234+
235+
Returns:
236+
--------
237+
int
238+
The chunk index containing the timestamp
239+
"""
240+
# Basic implementation - can be extended with domain-specific logic
241+
epoch = np.datetime64('1970-01-01T00:00:00')
242+
seconds_since_epoch = (timestamp - epoch) / np.timedelta64(1, 's')
243+
244+
# Get temporal resolution from metadata (placeholder - real implementation would get from metadata)
245+
temporal_resolution = 3600 # 1 hour in seconds
246+
247+
# Calculate chunk index
248+
chunk_index = int(seconds_since_epoch / (self.file_length * temporal_resolution))
249+
return chunk_index
250+
251+
def get_chunk_time_range(self, chunk_index: int, temporal_resolution_seconds: int = 3600) -> np.ndarray:
252+
"""
253+
Get the time range covered by a specific chunk.
254+
255+
Parameters:
256+
-----------
257+
chunk_index : int
258+
Index of the chunk
259+
temporal_resolution_seconds : int, optional
260+
Time resolution in seconds (default: 3600 = 1 hour)
261+
262+
Returns:
263+
--------
264+
np.ndarray
265+
Array of datetime64 objects representing the time points in the chunk
266+
"""
267+
epoch = np.datetime64('1970-01-01T00:00:00')
268+
chunk_start_seconds = chunk_index * self.file_length * temporal_resolution_seconds
269+
start_time = epoch + np.timedelta64(chunk_start_seconds, 's')
270+
271+
# Create array of time points
272+
time_points = start_time + np.arange(self.file_length) * np.timedelta64(temporal_resolution_seconds, 's')
273+
return time_points
274+
275+
# - MARK: Create grid instances for supported domains
276+
277+
# DWD ICON D2 is regularized during download to nx: 1215, ny: 746 points
278+
# https://github.com/open-meteo/open-meteo/blob/1753ebb4966d05f61b17dd5bdf59700788d4a913/Sources/App/Icon/Icon.swift#L154
279+
_dwd_icon_d2_grid = RegularLatLonGrid(
280+
lat_start=43.18,
281+
lat_steps=746,
282+
lat_step_size=0.02,
283+
lon_start=-3.94,
284+
lon_steps=1215,
285+
lon_step_size=0.02
286+
)
287+
288+
# ECMWF IQFS grid is a regular global lat/lon grid, nx: 1440, ny: 721 points
289+
# https://github.com/open-meteo/open-meteo/blob/1753ebb4966d05f61b17dd5bdf59700788d4a913/Sources/App/Ecmwf/EcmwfDomain.swift#L107
290+
_ecmwf_ifs025_grid = RegularLatLonGrid(
291+
lat_start=-90,
292+
lat_steps=721,
293+
lat_step_size=360/1440,
294+
lon_start=-180,
295+
lon_steps=1440,
296+
lon_step_size=180/(721-1)
297+
)
298+
299+
DOMAINS: dict[str, OmDomain] = {
300+
'dwd_icon_d2': OmDomain(
301+
name='dwd_icon_d2',
302+
grid=_dwd_icon_d2_grid,
303+
file_length=121,
304+
temporal_resolution_seconds=3600 # 1 hour
305+
),
306+
'ecmwf_ifs025': OmDomain(
307+
name='ecmwf_ifs025',
308+
grid=_ecmwf_ifs025_grid,
309+
file_length=104,
310+
temporal_resolution_seconds=3600 # 1 hour
311+
),
312+
# Additional domains can be added here
313+
}

python/omfiles/utils.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
def _modulo_positive(value: int, modulo: int) -> int:
2+
"""
3+
Calculate modulo that always returns positive value.
4+
5+
Parameters:
6+
-----------
7+
value : int
8+
Value to calculate modulo for
9+
modulo : int
10+
Modulo value
11+
12+
Returns:
13+
--------
14+
int
15+
Positive modulo result
16+
"""
17+
return ((value % modulo) + modulo) % modulo

0 commit comments

Comments
 (0)