Skip to content

Commit 0a2bc96

Browse files
EPPT-2721 contrails output categorical data (#2209)
* Function to output categorical (integer) array from input boolean arrays * Unit test * Initial function to generate categorical cube * Initial test for categorical cube output * Categorical cube test returns expected shape and data * Check cube contents and metadata in test * todo * Refactoring of class to reduce argument lists, increase use of self, and tidy up docstrings * Changes to unit tests to account for function signature changes. Because SVP ice table is now used within the persistency function, this required changing the parametrisation of the combination test. Every unique combination (16 total) now cannot be tested easily, so we have settled for most of the combinations. * Array shape checking in process_from_arrays * Cube input error checks. Minor refactoring of input array checks. * minor edit * Update categorical metadata * Category check added to cube output unit test * remove print * Cube input check test * Initial version of test to check that pressure, temperature and relative humidity inputs can produce the expected result from an Appleman diagram. * Modify test to show in-progress. To be completed once work from contrail persistency fix has been merged. * Added contrail class to API * Changed categorical output dtype to int32 * End-to-end test that checks values of output categorical cube, from input temperature and humidity cubes * Changed hard-coded freezing temperature to use absolute zero constant
1 parent 862873d commit 0a2bc96

File tree

3 files changed

+579
-82
lines changed

3 files changed

+579
-82
lines changed

improver/api/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"CloudCondensationLevel": "improver.psychrometric_calculations.cloud_condensation_level",
3838
"CloudTopTemperature": "improver.psychrometric_calculations.cloud_top_temperature",
3939
"Combine": "improver.cube_combiner",
40+
"CondensationTrailFormation": "improver.psychrometric_calculations.condensation_trails",
4041
"ConstructReliabilityCalibrationTables": "improver.calibration.reliability_calibration",
4142
"ContinuousRankedProbabilityScoreMinimisers": "improver.calibration.emos_calibration",
4243
"ContrailHeightExtractor": "improver.psychrometric_calculations.condensation_trails",

improver/psychrometric_calculations/condensation_trails.py

Lines changed: 156 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,28 @@
77
from typing import Tuple, Union
88

99
import numpy as np
10+
from iris.coords import DimCoord
1011
from iris.cube import Cube, CubeList
1112

1213
from improver import BasePlugin
13-
from improver.constants import EARTH_REPSILON
14+
from improver.categorical.utilities import categorical_attributes
15+
from improver.constants import ABSOLUTE_ZERO, EARTH_REPSILON
1416
from improver.generate_ancillaries.generate_svp_derivative_table import (
1517
SaturatedVapourPressureDerivativeTable,
1618
SaturatedVapourPressureTable,
1719
)
20+
from improver.metadata.utilities import (
21+
create_new_diagnostic_cube,
22+
generate_mandatory_attributes,
23+
)
1824
from improver.psychrometric_calculations.psychrometric_calculations import (
1925
calculate_svp_in_air,
2026
)
2127
from improver.utilities.common_input_handle import as_cubelist
28+
from improver.utilities.cube_manipulation import (
29+
add_coordinate_to_cube,
30+
get_dim_coord_names,
31+
)
2232

2333

2434
class CondensationTrailFormation(BasePlugin):
@@ -48,6 +58,8 @@ class CondensationTrailFormation(BasePlugin):
4858
engine_mixing_ratios = None
4959
critical_temperatures = None
5060
critical_intercepts = None
61+
nonpersistent_contrails = None
62+
persistent_contrails = None
5163

5264
def __init__(self, engine_contrail_factors: list = [3e-5, 3.4e-5, 3.9e-5]):
5365
"""Initialises the Class
@@ -208,7 +220,7 @@ def _critical_temperatures_and_intercepts_for_given_contrail_factor(
208220
)
209221
return critical_temperature, critical_intercept
210222

211-
def _calculate_critical_temperatures_and_intercepts(self):
223+
def _calculate_critical_temperatures_and_intercepts(self) -> None:
212224
"""Calculate the critical temperatures and intercepts on pressure levels for all engine contrail factors."""
213225
self.critical_temperatures = np.zeros(
214226
self.engine_mixing_ratios.shape[:2] + self.relative_humidity.shape[1:],
@@ -218,11 +230,9 @@ def _calculate_critical_temperatures_and_intercepts(self):
218230
self.engine_mixing_ratios.shape[:2], dtype=np.float32
219231
)
220232

221-
svp_table = SaturatedVapourPressureTable(
222-
183.15, 253.15, water_only=True
223-
).process()
233+
svp_table = SaturatedVapourPressureTable(water_only=True).process()
224234
svp_derivative_table = SaturatedVapourPressureDerivativeTable(
225-
183.15, 253.15, water_only=True
235+
water_only=True
226236
).process()
227237

228238
for i, engine_mixing_ratio_for_contrail_factor in enumerate(
@@ -236,22 +246,11 @@ def _calculate_critical_temperatures_and_intercepts(self):
236246
)
237247
)
238248

239-
def _calculate_contrail_persistency(
240-
self,
241-
saturated_vapour_pressure_ice: np.ndarray,
242-
) -> Tuple[np.ndarray, np.ndarray]:
249+
def _calculate_contrail_persistency(self) -> None:
243250
"""
244251
Apply four conditions to determine whether non-persistent or persistent contrails will form.
245252
246253
.. include:: extended_documentation/psychrometric_calculations/condensation_trails/formation_conditions.rst
247-
248-
Args:
249-
saturated_vapour_pressure_ice: The saturated vapour pressure with respect to ice, on pressure
250-
levels. Pressure is the leading axis (Pa).
251-
252-
Returns:
253-
Two boolean arrays that state whether 'non-persistent' or 'persistent' contrails will form, respectively.
254-
Array axes are [contrail factor, pressure level, latitude, longitude].
255254
"""
256255

257256
def reshape_and_broadcast(arr, target_shape):
@@ -271,6 +270,12 @@ def reshape_and_broadcast(arr, target_shape):
271270
self.critical_intercepts, self.critical_temperatures.shape
272271
)
273272

273+
# saturated vapour pressure with respect to ice, on pressure levels
274+
svp_table = SaturatedVapourPressureTable(ice_only=True).process()
275+
saturated_vapour_pressure_ice = np.interp(
276+
self.temperature, svp_table.coord("air_temperature").points, svp_table.data
277+
)
278+
274279
# Condition 1
275280
vapour_pressure_above_threshold = (
276281
self.local_vapour_pressure[np.newaxis]
@@ -284,20 +289,79 @@ def reshape_and_broadcast(arr, target_shape):
284289
# Condition 3
285290
air_is_saturated = self.local_vapour_pressure > saturated_vapour_pressure_ice
286291
# Condition 4
287-
temperature_below_freezing = self.temperature < 273.15
292+
temperature_below_freezing = self.temperature < abs(ABSOLUTE_ZERO)
288293

289-
nonpersistent_contrails = (
294+
# Boolean arrays that are true when the specific contrail type will form.
295+
# Array axes are [contrail factor, pressure level, latitude, longitude].
296+
self.nonpersistent_contrails = (
290297
vapour_pressure_above_threshold
291298
& temperature_below_threshold
292299
& ~(air_is_saturated & temperature_below_freezing)
293300
)
294-
persistent_contrails = (
301+
self.persistent_contrails = (
295302
vapour_pressure_above_threshold
296303
& temperature_below_threshold
297304
& air_is_saturated
298305
& temperature_below_freezing
299306
)
300-
return nonpersistent_contrails, persistent_contrails
307+
308+
def _boolean_to_categorical(self) -> np.ndarray:
309+
"""
310+
Combine two boolean arrays of contrail persistency into a single categorical array of contrail formation.
311+
312+
Returns:
313+
Array of categorical (integer) data, where 0 = no contrails, 1 = non-persistent contrails and 2 = persistent
314+
contrails.
315+
"""
316+
categorical = np.where(
317+
self.nonpersistent_contrails & ~self.persistent_contrails, 1, 0
318+
)
319+
categorical = np.where(
320+
~self.nonpersistent_contrails & self.persistent_contrails, 2, categorical
321+
)
322+
return categorical
323+
324+
def _create_contrail_formation_cube(
325+
self, categorical_data: np.ndarray, template_cube: Cube
326+
) -> Cube:
327+
"""
328+
Create a contrail formation cube, populated with categorical data.
329+
330+
Args:
331+
categorical_data: Categorical (integer) data of contrail formation. Leading axes are [contrail factor,
332+
pressure level].
333+
template_cube: Cube from which to derive dimensions, coordinates and mandatory attributes.
334+
335+
Returns:
336+
Categorical cube of contrail formation, where 0 = no contrails, 1 = non-persistent contrails and
337+
2 = persistent contrails. Has the same shape as categorical_data.
338+
"""
339+
contrail_factor_coord = DimCoord(
340+
points=self._engine_contrail_factors,
341+
var_name="engine_contrail_factor",
342+
units="kg kg-1 K-1",
343+
)
344+
template_cube = add_coordinate_to_cube(
345+
template_cube, new_coord=contrail_factor_coord
346+
)
347+
mandatory_attributes = generate_mandatory_attributes([template_cube])
348+
349+
decision_tree = {
350+
"meta": {"name": "contrail_type"},
351+
"None": {"leaf": 0},
352+
"Non-persistent": {"leaf": 1},
353+
"Persistent": {"leaf": 2},
354+
}
355+
optional_attributes = categorical_attributes(decision_tree, "contrail_type")
356+
357+
return create_new_diagnostic_cube(
358+
name="contrail_type",
359+
units="1",
360+
template_cube=template_cube,
361+
mandatory_attributes=mandatory_attributes,
362+
optional_attributes=optional_attributes,
363+
data=categorical_data.astype(np.int32),
364+
)
301365

302366
def process_from_arrays(
303367
self,
@@ -306,7 +370,7 @@ def process_from_arrays(
306370
pressure_levels: np.ndarray,
307371
) -> np.ndarray:
308372
"""
309-
Main entry point of this class for data as Numpy arrays
373+
Main entry point of this class for data as Numpy arrays.
310374
311375
Process the temperature, humidity and pressure data to calculate the
312376
contrails data.
@@ -317,9 +381,29 @@ def process_from_arrays(
317381
pressure_levels (np.ndarray): Pressure levels (Pa).
318382
319383
Returns:
320-
np.ndarray: The calculated engine mixing ratios on pressure levels (Pa/K).
321-
This is a placeholder until the full contrail formation logic is implemented.
384+
Categorical (integer) array of contrail formation
385+
386+
- 0 = no contrails
387+
- 1 = non-persistent contrails
388+
- 2 = persistent contrails
389+
390+
Array axes are [contrail factor, pressure level, latitude, longitude], where latitude and longitude are
391+
only included if present in the temperature and relative humidity input arrays.
322392
"""
393+
arrays = (temperature, relative_humidity, pressure_levels)
394+
if arrays[2].ndim != 1:
395+
raise ValueError(f"Expected 1D pressure array, got {arrays[2].ndim}D.")
396+
if arrays[0].shape != arrays[1].shape:
397+
raise ValueError(
398+
f"Temperature and relative humidity arrays must have same shape:"
399+
f" {arrays[0].shape}\n {arrays[1].shape}"
400+
)
401+
if arrays[0].shape[0] != arrays[2].size or arrays[1].shape[0] != arrays[2].size:
402+
raise ValueError(
403+
f"Leading axes of arrays must match:"
404+
f" {arrays[0].shape}\n {arrays[1].shape}\n {arrays[2].shape}"
405+
)
406+
323407
self.temperature = temperature
324408
self.relative_humidity = relative_humidity
325409
self.pressure_levels = pressure_levels
@@ -330,7 +414,8 @@ def process_from_arrays(
330414
self.pressure_levels
331415
)
332416
self._calculate_critical_temperatures_and_intercepts()
333-
return self.engine_mixing_ratios
417+
self._calculate_contrail_persistency()
418+
return self._boolean_to_categorical()
334419

335420
def process(self, *cubes: Union[Cube, CubeList]) -> Cube:
336421
"""
@@ -344,32 +429,65 @@ def process(self, *cubes: Union[Cube, CubeList]) -> Cube:
344429
Cube of the relative humidity on pressure levels.
345430
346431
Returns:
347-
Cube of heights above sea level at which contrails will form.
432+
Categorical (integer) cube of contrail formation
433+
434+
- 0 = no contrails
435+
- 1 = non-persistent contrails
436+
- 2 = persistent contrails
437+
438+
Cube dimensions are [contrail factor, pressure level, latitude, longitude], where latitude and longitude are
439+
only included if present in the input cubes.
348440
"""
441+
# Extract input cubes
349442
cubes = as_cubelist(*cubes)
350-
(temperature_cube, humidity_cube) = CubeList(cubes).extract(
351-
["air_temperature", "relative_humidity"]
352-
)
443+
names_to_extract = ["air_temperature", "relative_humidity"]
444+
if len(cubes) != len(names_to_extract):
445+
raise ValueError(
446+
f"Expected {len(names_to_extract)} cubes, got {len(cubes)}."
447+
)
448+
try:
449+
(temperature_cube, humidity_cube) = CubeList(cubes).extract(
450+
names_to_extract
451+
)
452+
except Exception as e:
453+
raise ValueError(
454+
f"Could not extract names '{names_to_extract}' from cubelist."
455+
) from e
456+
457+
# Check cube dimensions are equal
458+
if (
459+
get_dim_coord_names(temperature_cube) != get_dim_coord_names(humidity_cube)
460+
or temperature_cube.shape != humidity_cube.shape
461+
):
462+
raise ValueError(
463+
f"Cube dimensional coordinates must match:"
464+
f" {temperature_cube.summary(True, 25)}"
465+
f" {humidity_cube.summary(True, 25)}"
466+
)
467+
353468
temperature_cube.convert_units("K")
354469
humidity_cube.convert_units("kg kg-1")
355470

356-
# Get the pressure levels from the first cube
471+
# Get pressure levels
357472
pressure_coord = temperature_cube.coord("pressure")
358473
pressure_coord.convert_units("Pa")
359474

475+
if "pressure".casefold() != get_dim_coord_names(temperature_cube)[0].casefold():
476+
raise ValueError(
477+
f"Pressure must be the leading axis (got '{get_dim_coord_names(temperature_cube)}')."
478+
)
479+
360480
# Calculate contrail formation using numpy arrays
361-
_ = self.process_from_arrays(
481+
contrail_formation_data = self.process_from_arrays(
362482
temperature_cube.data, humidity_cube.data, pressure_coord.points
363483
)
364484

365-
# Placeholder return to silence my type checker
366-
return_cube = Cube(
367-
self.engine_mixing_ratios,
368-
long_name="engine_mixing_ratios",
369-
units="Pa K-1",
485+
# Create output cube using contrail formation data
486+
contrail_formation_cube = self._create_contrail_formation_cube(
487+
contrail_formation_data, temperature_cube
370488
)
371489

372-
return return_cube
490+
return contrail_formation_cube
373491

374492

375493
class ContrailHeightExtractor(BasePlugin):

0 commit comments

Comments
 (0)