Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extract forecast reference time #118

Merged
merged 19 commits into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2a02719
Move fix_forecast_reference_time() to function.
truth-quark Sep 27, 2024
8b26728
Add forecast reference time constants.
truth-quark Sep 27, 2024
a874f0e
Modify DummyCube to allow coordinate updates post construction.
truth-quark Sep 27, 2024
e93b1e0
Add initial tests for forecast reference time fixes.
truth-quark Sep 27, 2024
48d383c
Move convert_proleptic() to gather time functionality.
truth-quark Sep 27, 2024
9215c89
Add basic reference time assertions.
truth-quark Sep 27, 2024
2240635
Update reference time test fixtures.
truth-quark Sep 27, 2024
5a69457
Update reference time constants.
truth-quark Sep 27, 2024
fd664c7
Add skeleton of forecast reference time testing.
truth-quark Sep 27, 2024
d711729
Improve forecast reference time test fixtures & comments.
truth-quark Oct 2, 2024
7ee59a2
Remove duplicate calendar constants & use cf_units.
truth-quark Oct 2, 2024
16a3768
Use cf_units calendar constants.
truth-quark Oct 2, 2024
2eaa205
Fix conflicts & merge.
truth-quark Oct 9, 2024
b3c0cf1
Add try/except refactoring comments from PR ideas.
truth-quark Oct 10, 2024
6e8b5e2
Add comments for forecast reference time fixes.
truth-quark Oct 10, 2024
0925960
Add forecast ref time fixture & use in test.
truth-quark Oct 10, 2024
43e3ba1
Flag tasks for forecast ref time fixes.
truth-quark Oct 10, 2024
d3164e3
Refactor DummyCube to delineate between the iris API & auxiliary help…
truth-quark Oct 10, 2024
3820528
Flag test fixtures for repair in future work.
truth-quark Oct 10, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 99 additions & 3 deletions test/test_um2netcdf.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import unittest.mock as mock
from dataclasses import dataclass
from collections import namedtuple

import cf_units
from iris.exceptions import CoordinateNotFoundError
import operator

Expand Down Expand Up @@ -406,10 +408,10 @@ def __init__(self, item_code, var_name=None, attributes=None,
self.standard_name = None
self.long_name = None
self.data = None
self._coordinates = {}

# Mimic a coordinate dictionary keys for iris coordinate names. This
# ensures the access key for coord() matches the coordinate's name
self._coordinates = {c.name(): c for c in coords} if coords else {}
if coords:
self.update_coords(coords)

def name(self):
return self.var_name
Expand All @@ -421,6 +423,14 @@ def coord(self, name):
msg = f"{self.__class__}: lacks coord for '{name}'"
raise CoordinateNotFoundError(msg)

def update_coords(self, coords):
# Mimic a coordinate dictionary keys for iris coordinate names. This
# ensures the access key for coord() matches the coordinate's name
self._coordinates = {c.name(): c for c in coords} if coords else {}

def remove_coord(self, coord):
del self._coordinates[coord]


def test_set_item_codes_avoid_overwrite():
item_code = 1007
Expand Down Expand Up @@ -1177,3 +1187,89 @@ def test_convert_32_bit_with_float64(ua_plev_cube):
ua_plev_cube.data = array
um2nc.convert_32_bit(ua_plev_cube)
assert ua_plev_cube.data.dtype == np.float32


# fix forecast reference time tests
@pytest.fixture
def forecast_cube():
# NB: using a non-existent item code for fake forecast cube
return DummyCube(item_code=999)


@pytest.fixture
def time_points():
"""Use for cube.coord('time').points attribute."""
return [-16382964.]


@pytest.fixture
def forecast_ref_time_coord(time_points):
# units & point data ripped from aiihca.paa1jan data file:
truth-quark marked this conversation as resolved.
Show resolved Hide resolved
# cubes = iris.load("aiihca.paa1jan")
# cubes[0].long_name --> 'atmosphere_optical_thickness_due_to_sulphate_ambient_aerosol'
# cubes[0].coord("time").points --> array([-16382964.])
unit = cf_units.Unit(unit="hours since 1970-01-01 00:00:00")
assert unit.calendar == um2nc.STANDARD

return iris.coords.DimCoord(time_points,
standard_name=um2nc.FORECAST_REFERENCE_TIME,
units=unit)


@pytest.fixture
def time_coord(time_points):
# units data ripped from aiihca data file
unit = cf_units.Unit(unit="hours since 1970-01-01 00:00:00",
calendar=um2nc.GREGORIAN)
assert unit.calendar == um2nc.STANDARD

return iris.coords.DimCoord(time_points,
standard_name=um2nc.TIME,
units=unit)


def test_fix_forecast_reference_time_exit_on_missing_ref_time(forecast_cube):
with pytest.raises(iris.exceptions.CoordinateNotFoundError):
forecast_cube.coord(um2nc.FORECAST_REFERENCE_TIME)

assert um2nc.fix_forecast_reference_time(forecast_cube) is None
truth-quark marked this conversation as resolved.
Show resolved Hide resolved


def test_fix_forecast_reference_time_exit_on_missing_time(forecast_cube,
forecast_ref_time_coord):
truth-quark marked this conversation as resolved.
Show resolved Hide resolved
forecast_cube.update_coords([forecast_ref_time_coord])

with pytest.raises(iris.exceptions.CoordinateNotFoundError):
forecast_cube.coord(um2nc.TIME)

assert um2nc.fix_forecast_reference_time(forecast_cube) is None


def test_fix_forecast_reference_time_standard(forecast_cube,
forecast_ref_time_coord,
time_coord):

forecast_period = iris.coords.DimCoord([372.0],
standard_name=um2nc.FORECAST_PERIOD)

forecast_cube.update_coords([forecast_ref_time_coord,
time_coord,
forecast_period])

assert um2nc.fix_forecast_reference_time(forecast_cube) is None


@pytest.mark.skip
def test_fix_forecast_reference_time_gregorian(forecast_cube,
forecast_ref_time_coord,
time_coord):
msg = "Is time.units.calendar == 'gregorian' branch & testing required?"
raise NotImplementedError(msg)


@pytest.mark.skip
def test_fix_forecast_reference_time_proleptic_gregorian(forecast_cube,
forecast_ref_time_coord,
time_coord):
msg = "Is time.units.calendar == 'proleptic_gregorian' branch & testing required?"
raise NotImplementedError(msg)
122 changes: 67 additions & 55 deletions umpost/um2netcdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@
LEVEL_HEIGHT = "level_height"
SIGMA = "sigma"

# forecast reference time constants
FORECAST_REFERENCE_TIME = "forecast_reference_time"
TIME = "time"
PROLEPTIC_GREGORIAN = "proleptic_gregorian"
GREGORIAN = "gregorian"
FORECAST_PERIOD = "forecast_period"
STANDARD = "standard"


blimlim marked this conversation as resolved.
Show resolved Hide resolved
class PostProcessingError(Exception):
"""Generic class for um2nc specific errors."""
Expand Down Expand Up @@ -102,34 +110,6 @@ def pg_calendar(self):
PPField.calendar = pg_calendar


# TODO: rename time to avoid clash with builtin time module
def convert_proleptic(time):
# Convert units from hours to days and shift origin from 1970 to 0001
newunits = cf_units.Unit("days since 0001-01-01 00:00", calendar='proleptic_gregorian')
tvals = np.array(time.points) # Need a copy because can't assign to time.points[i]
tbnds = np.array(time.bounds) if time.bounds is not None else None

for i in range(len(time.points)):
date = time.units.num2date(tvals[i])
newdate = cftime.DatetimeProlepticGregorian(date.year, date.month, date.day,
date.hour, date.minute, date.second)
tvals[i] = newunits.date2num(newdate)

if tbnds is not None: # Fields with instantaneous data don't have bounds
for j in range(2):
date = time.units.num2date(tbnds[i][j])
newdate = cftime.DatetimeProlepticGregorian(date.year, date.month, date.day,
date.hour, date.minute, date.second)
tbnds[i][j] = newunits.date2num(newdate)

time.points = tvals

if tbnds is not None:
time.bounds = tbnds

time.units = newunits


def fix_lat_coord_name(lat_coordinate, grid_type, dlat):
"""
Add a 'var_name' attribute to a latitude coordinate object
Expand Down Expand Up @@ -338,33 +318,7 @@ def cubewrite(cube, sman, compression, use64bit, verbose):

cube.attributes['missing_value'] = np.array([fill_value], cube.data.dtype)

# If reference date is before 1600 use proleptic gregorian
# calendar and change units from hours to days
try:
reftime = cube.coord('forecast_reference_time')
time = cube.coord('time')
refdate = reftime.units.num2date(reftime.points[0])
assert time.units.origin == 'hours since 1970-01-01 00:00:00'

if time.units.calendar == 'proleptic_gregorian' and refdate.year < 1600:
convert_proleptic(time)
else:
if time.units.calendar == 'gregorian':
new_calendar = 'proleptic_gregorian'
else:
new_calendar = time.units.calendar

time.units = cf_units.Unit("days since 1970-01-01 00:00", calendar=new_calendar)
time.points = time.points/24.

if time.bounds is not None:
time.bounds = time.bounds/24.

cube.remove_coord('forecast_period')
cube.remove_coord('forecast_reference_time')
except iris.exceptions.CoordinateNotFoundError:
# Dump files don't have forecast_reference_time
pass
fix_forecast_reference_time(cube)

# Check whether any of the coordinates is a pseudo-dimension with integer values and
# if so, reset to int32 to prevent problems with possible later conversion to netCDF3
Expand Down Expand Up @@ -406,6 +360,64 @@ def cubewrite(cube, sman, compression, use64bit, verbose):
sman.write(cube, zlib=True, complevel=compression, fill_value=fill_value)


aidanheerdegen marked this conversation as resolved.
Show resolved Hide resolved
def fix_forecast_reference_time(cube):
# If reference date is before 1600 use proleptic gregorian
aidanheerdegen marked this conversation as resolved.
Show resolved Hide resolved
# calendar and change units from hours to days
try:
reftime = cube.coord('forecast_reference_time')
time = cube.coord('time')
refdate = reftime.units.num2date(reftime.points[0])
assert time.units.origin == 'hours since 1970-01-01 00:00:00'

if time.units.calendar == 'proleptic_gregorian' and refdate.year < 1600:
truth-quark marked this conversation as resolved.
Show resolved Hide resolved
convert_proleptic(time)
else:
if time.units.calendar == 'gregorian':
new_calendar = 'proleptic_gregorian'
else:
blimlim marked this conversation as resolved.
Show resolved Hide resolved
new_calendar = time.units.calendar

time.units = cf_units.Unit("days since 1970-01-01 00:00", calendar=new_calendar)
time.points = time.points / 24.

if time.bounds is not None:
aidanheerdegen marked this conversation as resolved.
Show resolved Hide resolved
time.bounds = time.bounds / 24.

cube.remove_coord('forecast_period')
cube.remove_coord('forecast_reference_time')
except iris.exceptions.CoordinateNotFoundError:
# Dump files don't have forecast_reference_time
pass


# TODO: rename time to avoid clash with builtin time module
def convert_proleptic(time):
# Convert units from hours to days and shift origin from 1970 to 0001
newunits = cf_units.Unit("days since 0001-01-01 00:00", calendar='proleptic_gregorian')
tvals = np.array(time.points) # Need a copy because can't assign to time.points[i]
tbnds = np.array(time.bounds) if time.bounds is not None else None

for i in range(len(time.points)):
date = time.units.num2date(tvals[i])
newdate = cftime.DatetimeProlepticGregorian(date.year, date.month, date.day,
date.hour, date.minute, date.second)
tvals[i] = newunits.date2num(newdate)

if tbnds is not None: # Fields with instantaneous data don't have bounds
for j in range(2):
date = time.units.num2date(tbnds[i][j])
newdate = cftime.DatetimeProlepticGregorian(date.year, date.month, date.day,
date.hour, date.minute, date.second)
tbnds[i][j] = newunits.date2num(newdate)

time.points = tvals

if tbnds is not None:
time.bounds = tbnds

time.units = newunits


def fix_cell_methods(cell_methods):
"""
Removes misleading 'hour' from interval naming, leaving other names intact.
Expand Down
Loading