77from typing import Tuple , Union
88
99import numpy as np
10+ from iris .coords import DimCoord
1011from iris .cube import Cube , CubeList
1112
1213from 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
1416from 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+ )
1824from improver .psychrometric_calculations .psychrometric_calculations import (
1925 calculate_svp_in_air ,
2026)
2127from 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
2434class 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
375493class ContrailHeightExtractor (BasePlugin ):
0 commit comments