Skip to content

Commit d53d203

Browse files
authored
Merge pull request #89 from NatLabRockies/bnb/gk2a
Bespoke data model for GK2A data. NASA and GK2A data models now subclass base model. Eventually this should be refactored to rely more on nsrdb_vars.csv for variable definitions.
2 parents c8858fe + f3c4f96 commit d53d203

9 files changed

Lines changed: 1029 additions & 213 deletions

File tree

nsrdb/data_model/variable_factory.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class VarFactory:
2424
"""Factory pattern to retrieve ancillary variable helper objects."""
2525

2626
# mapping of NSRDB variable names to helper objects
27+
# TODO: split this up into psm and mlcloud variables
2728
MAPPING: ClassVar = {
2829
'asymmetry': AsymVar,
2930
'air_temperature': MerraVar,
Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
"""Shared utilities for converting satellite data to UWISC format."""
2+
3+
import logging
4+
import os
5+
import re
6+
from concurrent.futures import ThreadPoolExecutor, as_completed
7+
from functools import cached_property
8+
from glob import glob
9+
10+
import numpy as np
11+
import pandas as pd
12+
from rex.utilities.solar_position import SolarPosition
13+
14+
DROP_VARS = ['relative_time']
15+
16+
UWISC_CLOUD_TYPE = {
17+
'N/A': -15,
18+
'Clear': 0,
19+
'Probably Clear': 1,
20+
'Fog': 2,
21+
'Water': 3,
22+
'Super-Cooled Water': 4,
23+
'Mixed': 5,
24+
'Opaque Ice': 6,
25+
'Cirrus': 7,
26+
'Overlapping': 8,
27+
'Overshooting': 9,
28+
'Unknown': 10,
29+
'Dust': 11,
30+
'Smoke': 12,
31+
}
32+
33+
34+
def expand_input_patterns(input_pattern):
35+
"""Expand one or more glob patterns into a de-duplicated file list."""
36+
patterns = (
37+
[input_pattern]
38+
if isinstance(input_pattern, str)
39+
else list(input_pattern)
40+
)
41+
files = []
42+
for pattern in patterns:
43+
files.extend(glob(pattern, recursive=True))
44+
return list(dict.fromkeys(files))
45+
46+
47+
def run_data_model_jobs(
48+
data_model_class,
49+
input_pattern,
50+
output_pattern,
51+
*,
52+
max_workers=None,
53+
group_inputs=None,
54+
logger=None,
55+
):
56+
"""Run a data-model conversion job over expanded input patterns."""
57+
logger = logger or logging.getLogger(data_model_class.__module__)
58+
files = expand_input_patterns(input_pattern)
59+
job_inputs = group_inputs(files) if group_inputs is not None else files
60+
61+
if max_workers == 1:
62+
for input_data in job_inputs:
63+
data_model_class.run(input_data, output_pattern)
64+
else:
65+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
66+
futures = {}
67+
for input_data in job_inputs:
68+
future = executor.submit(
69+
data_model_class.run,
70+
input_data,
71+
output_pattern,
72+
)
73+
futures[future] = input_data
74+
75+
for future in as_completed(futures):
76+
try:
77+
future.result()
78+
except Exception as error:
79+
logger.error(
80+
'Error processing file(s): %s',
81+
futures[future],
82+
)
83+
logger.exception(error)
84+
85+
logger.info('Finished converting %s files.', len(files))
86+
87+
88+
class BaseUwiscDataModel:
89+
"""Shared preprocessing pipeline for UWISC-format conversion."""
90+
91+
NAME_MAP = {}
92+
CLOUD_TYPE_MAP = {}
93+
CLOUD_TYPE_SOURCE_VAR = None
94+
TIMESTAMP_PATTERN = r'.*_([0-9]+).([0-9]+).([0-9]+).\w+'
95+
96+
def __init__(self, input_data, output_pattern):
97+
self.input_data = input_data
98+
self.output_pattern = output_pattern
99+
100+
@staticmethod
101+
def _as_list(input_data):
102+
"""Normalize a single input or grouped inputs into a list."""
103+
if isinstance(input_data, (list, tuple)):
104+
return list(input_data)
105+
return [input_data]
106+
107+
@cached_property
108+
def input_files(self):
109+
"""Get the normalized list of input files."""
110+
return self._as_list(self.input_data)
111+
112+
@classmethod
113+
def get_primary_input_file(cls, input_files):
114+
"""Get the primary input file used for naming outputs."""
115+
return input_files[0]
116+
117+
@cached_property
118+
def primary_input_file(self):
119+
"""Get the primary input file used for naming outputs."""
120+
return self.get_primary_input_file(self.input_files)
121+
122+
@classmethod
123+
def parse_timestamp(cls, input_file):
124+
"""Parse a timestamp tuple from an input file path."""
125+
ts = re.match(cls.TIMESTAMP_PATTERN, input_file).groups()
126+
year, doy, hour = ts
127+
minute = hour[2:] if len(hour) > 2 else '00'
128+
hour = hour[:2]
129+
secs = '000'
130+
return year, doy, hour, minute, secs
131+
132+
@classmethod
133+
def parse_timestamp_string(cls, input_file):
134+
"""Parse the output timestamp string from an input file path."""
135+
return f's{"".join(cls.parse_timestamp(input_file))}'
136+
137+
@cached_property
138+
def timestamp(self):
139+
"""Get the parsed timestamp tuple for the primary input file."""
140+
return self.parse_timestamp(self.primary_input_file)
141+
142+
@cached_property
143+
def timestamp_string(self):
144+
"""Get the parsed output timestamp string for the primary input."""
145+
return self.parse_timestamp_string(self.primary_input_file)
146+
147+
@cached_property
148+
def time_index(self):
149+
"""Get a single-step time index for the primary input file."""
150+
year, doy, hour, minute, _ = self.timestamp
151+
timestamp = pd.to_datetime(
152+
f'{year}{doy}{hour}{minute}00', format='%Y%j%H%M%S'
153+
)
154+
return pd.DatetimeIndex([timestamp])
155+
156+
@cached_property
157+
def output_file(self):
158+
"""Get output file name for the configured output pattern."""
159+
year, doy, *_ = self.timestamp
160+
return self.output_pattern.format(
161+
year=year,
162+
doy=doy,
163+
timestamp=self.timestamp_string,
164+
)
165+
166+
@classmethod
167+
def open_dataset(cls, input_data):
168+
"""Open the raw input data as an xarray dataset."""
169+
raise NotImplementedError
170+
171+
@cached_property
172+
def ds(self):
173+
"""Get xarray dataset for raw input data."""
174+
return self.open_dataset(self.input_data)
175+
176+
@classmethod
177+
def transform_raw_data(cls, ds):
178+
"""Apply any subclass-specific preprocessing to the raw dataset."""
179+
return ds
180+
181+
def get_solar_zenith(self, ds):
182+
"""Derive the solar zenith angle for the dataset."""
183+
lats = ds['latitude'].values
184+
lons = ds['longitude'].values
185+
solar_pos = SolarPosition(
186+
self.time_index,
187+
np.column_stack((lats.ravel(), lons.ravel())),
188+
)
189+
return np.asarray(solar_pos.zenith).reshape(lats.shape)
190+
191+
def get_solar_azimuth(self, ds):
192+
"""Derive the solar azimuth angle for the dataset."""
193+
lats = ds['latitude'].values
194+
lons = ds['longitude'].values
195+
solar_pos = SolarPosition(
196+
self.time_index,
197+
np.column_stack((lats.ravel(), lons.ravel())),
198+
)
199+
return np.asarray(solar_pos.azimuth).reshape(lats.shape)
200+
201+
@classmethod
202+
def rename_vars(cls, ds):
203+
"""Rename variables to uwisc conventions."""
204+
for current_name, target_name in cls.NAME_MAP.items():
205+
if current_name in ds.data_vars:
206+
ds = ds.rename({current_name: target_name})
207+
return ds
208+
209+
@classmethod
210+
def drop_vars(cls, ds):
211+
"""Drop variables that are not part of the UWISC output schema."""
212+
for var_name in DROP_VARS:
213+
if var_name in ds.data_vars:
214+
ds = ds.drop_vars(var_name)
215+
return ds
216+
217+
@staticmethod
218+
def _rename_spatial_dims(ds):
219+
"""Rename alternative spatial dimensions to the shared convention."""
220+
rename_map = {}
221+
if 'Lines' in ds.dims:
222+
rename_map['Lines'] = 'south_north'
223+
if 'Pixels' in ds.dims:
224+
rename_map['Pixels'] = 'west_east'
225+
if 'dim_y' in ds.dims:
226+
rename_map['dim_y'] = 'south_north'
227+
if 'dim_x' in ds.dims:
228+
rename_map['dim_x'] = 'west_east'
229+
if rename_map:
230+
ds = ds.rename(rename_map)
231+
return ds
232+
233+
@staticmethod
234+
def _promote_lat_lon_coords(ds):
235+
"""Ensure latitude and longitude are stored as coordinates."""
236+
sdims = ('south_north', 'west_east')
237+
if ('lat' in ds.coords or 'lat' in ds.data_vars) and (
238+
'latitude' not in ds.coords and 'latitude' not in ds.data_vars
239+
):
240+
ds = ds.rename({'lat': 'latitude', 'lon': 'longitude'})
241+
242+
if ds['latitude'].ndim == 1 and ds['longitude'].ndim == 1:
243+
ds['south_north'] = ds['latitude']
244+
ds['west_east'] = ds['longitude']
245+
246+
lons, lats = np.meshgrid(ds['longitude'], ds['latitude'])
247+
ds = ds.assign_coords({
248+
'latitude': (sdims, lats),
249+
'longitude': (sdims, lons),
250+
})
251+
252+
coord_names = [
253+
name for name in ('latitude', 'longitude') if name in ds.data_vars
254+
]
255+
if coord_names:
256+
ds = ds.set_coords(coord_names)
257+
return ds
258+
259+
def remap_dims(self, ds):
260+
"""Rename dims and coords to standards and build 2D lat/lon grids."""
261+
for var_name in ds.data_vars:
262+
single_ts = (
263+
'time' in ds[var_name].dims
264+
and ds[var_name].transpose('time', ...).shape[0] == 1
265+
)
266+
if single_ts and var_name != 'reference_time':
267+
ds[var_name] = (
268+
('south_north', 'west_east'),
269+
ds[var_name].isel(time=0).data,
270+
)
271+
272+
ref_time = ds.attrs.get('reference_time', None)
273+
if ref_time is not None:
274+
time_index = pd.DatetimeIndex([ref_time]).values
275+
else:
276+
time_index = self.time_index.values
277+
ds = self._rename_spatial_dims(ds)
278+
ds = self._promote_lat_lon_coords(ds)
279+
ds = ds.assign_coords({'time': ('time', time_index)})
280+
return ds
281+
282+
@classmethod
283+
def fill_missing_vars(cls, ds):
284+
"""Fill any missing variables with NaN arrays."""
285+
for var_name in cls.NAME_MAP:
286+
if var_name not in ds.data_vars:
287+
ds[var_name] = (
288+
('south_north', 'west_east'),
289+
np.full(
290+
(ds.sizes['south_north'], ds.sizes['west_east']),
291+
np.nan,
292+
),
293+
)
294+
return ds
295+
296+
def derive_solar_angles(self, ds):
297+
"""Derive solar angles if not already present in the dataset."""
298+
if 'solar_zenith_angle' not in ds.data_vars:
299+
ds['solar_zenith_angle'] = (
300+
('south_north', 'west_east'),
301+
self.get_solar_zenith(ds),
302+
)
303+
if 'solar_azimuth_angle' not in ds.data_vars:
304+
ds['solar_azimuth_angle'] = (
305+
('south_north', 'west_east'),
306+
self.get_solar_azimuth(ds),
307+
)
308+
return ds
309+
310+
@classmethod
311+
def remap_cloud_phase(cls, ds):
312+
"""Map source cloud phase flags to UWISC cloud types."""
313+
if cls.CLOUD_TYPE_SOURCE_VAR is None:
314+
return ds
315+
316+
cloud_type_name = cls.NAME_MAP[cls.CLOUD_TYPE_SOURCE_VAR]
317+
cloud_type = ds[cloud_type_name].values.copy()
318+
for value, cloud_source in cls.CLOUD_TYPE_MAP.items():
319+
cloud_type = np.where(
320+
ds[cloud_type_name].values.astype(int) == int(value),
321+
UWISC_CLOUD_TYPE[cloud_source],
322+
cloud_type,
323+
)
324+
ds[cloud_type_name] = (ds[cloud_type_name].dims, cloud_type)
325+
return ds
326+
327+
@classmethod
328+
def derive_stdevs(cls, ds):
329+
"""Derive standard deviations used as training features."""
330+
for var_name in ('refl_0_65um_nom', 'temp_11_0um_nom'):
331+
stddev = (
332+
ds[var_name]
333+
.rolling(
334+
south_north=3,
335+
west_east=3,
336+
center=True,
337+
min_periods=1,
338+
)
339+
.std()
340+
)
341+
ds[f'{var_name}_stddev_3x3'] = stddev
342+
return ds
343+
344+
def process_dataset(self, ds):
345+
"""Run the shared UWISC preprocessing pipeline on a dataset."""
346+
ds = self.remap_dims(ds)
347+
ds = self.fill_missing_vars(ds)
348+
ds = self.transform_raw_data(ds)
349+
ds = self.rename_vars(ds)
350+
ds = self.drop_vars(ds)
351+
ds = self.remap_cloud_phase(ds)
352+
ds = self.derive_stdevs(ds)
353+
ds = self.derive_solar_angles(ds)
354+
return ds
355+
356+
@classmethod
357+
def write_output(cls, ds, output_file):
358+
"""Write converted dataset to the final output file."""
359+
os.makedirs(os.path.dirname(output_file), exist_ok=True)
360+
ds = ds.transpose('south_north', 'west_east', ...)
361+
ds.load().to_netcdf(output_file, format='NETCDF4', engine='h5netcdf')
362+
363+
@classmethod
364+
def run(cls, input_data, output_pattern):
365+
"""Run the conversion routine and write the converted dataset."""
366+
logger = logging.getLogger(cls.__module__)
367+
data_model = cls(input_data, output_pattern)
368+
369+
if os.path.exists(data_model.output_file):
370+
logger.info(
371+
'%s already exists. Skipping conversion.',
372+
data_model.output_file,
373+
)
374+
return
375+
376+
logger.info('Getting xarray dataset for %s', input_data)
377+
ds = data_model.process_dataset(data_model.ds)
378+
379+
logger.info('Writing converted file to %s', data_model.output_file)
380+
cls.write_output(ds, data_model.output_file)

0 commit comments

Comments
 (0)