pycontrails 0.51.1__cp311-cp311-macosx_11_0_arm64.whl → 0.52.0__cp311-cp311-macosx_11_0_arm64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pycontrails might be problematic. Click here for more details.

Files changed (40) hide show
  1. pycontrails/__init__.py +1 -1
  2. pycontrails/_version.py +2 -2
  3. pycontrails/core/__init__.py +1 -1
  4. pycontrails/core/cache.py +1 -1
  5. pycontrails/core/flight.py +32 -28
  6. pycontrails/core/polygon.py +1 -1
  7. pycontrails/core/rgi_cython.cpython-311-darwin.so +0 -0
  8. pycontrails/datalib/__init__.py +4 -1
  9. pycontrails/datalib/_leo_utils/search.py +250 -0
  10. pycontrails/datalib/_leo_utils/static/bq_roi_query.sql +6 -0
  11. pycontrails/datalib/_leo_utils/vis.py +60 -0
  12. pycontrails/{core/datalib.py → datalib/_met_utils/metsource.py} +1 -1
  13. pycontrails/datalib/ecmwf/arco_era5.py +8 -7
  14. pycontrails/datalib/ecmwf/common.py +3 -2
  15. pycontrails/datalib/ecmwf/era5.py +12 -11
  16. pycontrails/datalib/ecmwf/era5_model_level.py +12 -11
  17. pycontrails/datalib/ecmwf/hres.py +14 -13
  18. pycontrails/datalib/ecmwf/hres_model_level.py +15 -14
  19. pycontrails/datalib/ecmwf/ifs.py +14 -13
  20. pycontrails/datalib/gfs/gfs.py +15 -14
  21. pycontrails/datalib/goes.py +2 -2
  22. pycontrails/datalib/landsat.py +567 -0
  23. pycontrails/datalib/sentinel.py +512 -0
  24. pycontrails/models/apcemm/__init__.py +8 -0
  25. pycontrails/models/apcemm/apcemm.py +983 -0
  26. pycontrails/models/apcemm/inputs.py +226 -0
  27. pycontrails/models/apcemm/static/apcemm_yaml_template.yaml +183 -0
  28. pycontrails/models/apcemm/utils.py +437 -0
  29. pycontrails/models/cocip/__init__.py +2 -0
  30. pycontrails/models/cocip/output_formats.py +165 -0
  31. pycontrails/models/cocipgrid/cocip_grid.py +7 -6
  32. pycontrails/models/dry_advection.py +14 -5
  33. {pycontrails-0.51.1.dist-info → pycontrails-0.52.0.dist-info}/METADATA +20 -11
  34. {pycontrails-0.51.1.dist-info → pycontrails-0.52.0.dist-info}/RECORD +39 -30
  35. pycontrails/datalib/spire/__init__.py +0 -19
  36. /pycontrails/datalib/{spire/spire.py → spire.py} +0 -0
  37. {pycontrails-0.51.1.dist-info → pycontrails-0.52.0.dist-info}/LICENSE +0 -0
  38. {pycontrails-0.51.1.dist-info → pycontrails-0.52.0.dist-info}/NOTICE +0 -0
  39. {pycontrails-0.51.1.dist-info → pycontrails-0.52.0.dist-info}/WHEEL +0 -0
  40. {pycontrails-0.51.1.dist-info → pycontrails-0.52.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,437 @@
1
+ """APCEMM interface utility functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pathlib
6
+ import subprocess
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ import xarray as xr
11
+
12
+ from pycontrails.core import GeoVectorDataset, MetDataset, met_var, models
13
+ from pycontrails.models.apcemm.inputs import APCEMMInput
14
+ from pycontrails.models.humidity_scaling import HumidityScaling
15
+ from pycontrails.physics import constants, thermo, units
16
+ from pycontrails.utils.types import ArrayScalarLike
17
+
18
+ _path_to_static = pathlib.Path(__file__).parent / "static"
19
+ YAML_TEMPLATE = _path_to_static / "apcemm_yaml_template.yaml"
20
+
21
+
22
+ def generate_apcemm_input_yaml(params: APCEMMInput) -> str:
23
+ """Generate YAML file from APCEMM input parameters.
24
+
25
+ Parameters
26
+ ----------
27
+ params : APCEMMInput
28
+ :class:`APCEMMInput` instance with parameters for input YAML file.
29
+
30
+ Return
31
+ ------
32
+ str
33
+ Contents of input YAML file generated from :param:`params`.
34
+ """
35
+
36
+ with open(YAML_TEMPLATE) as f:
37
+ template = f.read()
38
+
39
+ return template.format(
40
+ n_threads_int=params.n_threads,
41
+ output_folder_str=params.output_directory,
42
+ overwrite_output_bool=_yaml_bool(params.overwrite_output),
43
+ input_background_conditions_str=params.input_background_conditions,
44
+ input_engine_emissions_str=params.input_engine_emissions,
45
+ max_age_hr=params.max_age / np.timedelta64(1, "h"),
46
+ temperature_k=params.air_temperature,
47
+ rhw_percent=params.rhw * 100,
48
+ horiz_diff_coeff_m2_per_s=params.horiz_diff,
49
+ vert_diff_coeff_m2_per_s=params.vert_diff,
50
+ pressure_hpa=params.air_pressure / 100,
51
+ wind_shear_per_s=params.normal_shear,
52
+ brunt_vaisala_frequency_per_s=params.brunt_vaisala_frequency,
53
+ longitude_deg=params.longitude,
54
+ latitude_deg=params.latitude,
55
+ emission_day_dayofyear=params.day_of_year,
56
+ emission_time_hourofday=params.hour_of_day,
57
+ nox_ppt=params.nox_vmr * 1e12,
58
+ hno3_ppt=params.hno3_vmr * 1e12,
59
+ o3_ppb=params.o3_vmr * 1e9,
60
+ co_ppb=params.co_vmr * 1e9,
61
+ ch4_ppm=params.ch4_vmr * 1e6,
62
+ so2_ppt=params.so2_vmr * 1e12,
63
+ nox_g_per_kg=params.nox_ei * 1000,
64
+ co_g_per_kg=params.co_ei * 1000,
65
+ uhc_g_per_kg=params.hc_ei * 1000,
66
+ so2_g_per_kg=params.so2_ei * 1000,
67
+ so2_to_so4_conv_percent=params.so2_to_so4_conversion * 100,
68
+ soot_g_per_kg=params.nvpm_ei_m * 1000,
69
+ soot_radius_m=params.soot_radius,
70
+ total_fuel_flow_kg_per_s=params.fuel_flow,
71
+ aircraft_mass_kg=params.aircraft_mass,
72
+ flight_speed_m_per_s=params.true_airspeed,
73
+ num_of_engines_int=params.n_engine,
74
+ wingspan_m=params.wingspan,
75
+ core_exit_temp_k=params.core_exit_temp,
76
+ exit_bypass_area_m2=params.core_exit_area,
77
+ transport_timestep_min=params.dt_apcemm_transport / np.timedelta64(1, "m"),
78
+ gravitational_settling_bool=_yaml_bool(params.do_gravitational_setting),
79
+ solid_coagulation_bool=_yaml_bool(params.do_solid_coagulation),
80
+ liquid_coagulation_bool=_yaml_bool(params.do_liquid_coagulation),
81
+ ice_growth_bool=_yaml_bool(params.do_ice_growth),
82
+ coag_timestep_min=params.dt_apcemm_coagulation / np.timedelta64(1, "m"),
83
+ ice_growth_timestep_min=params.dt_apcemm_ice_growth / np.timedelta64(1, "m"),
84
+ met_input_file_path_str=params.input_met_file,
85
+ time_series_data_timestep_hr=params.dt_input_met / np.timedelta64(1, "h"),
86
+ save_aerosol_timeseries_bool=_yaml_bool(params.do_apcemm_nc_output),
87
+ aerosol_indices_list_int=",".join(str(i) for i in params.apcemm_nc_output_species),
88
+ save_frequency_min=params.dt_apcemm_nc_output / np.timedelta64(1, "m"),
89
+ nx_pos_int=params.nx,
90
+ ny_pos_int=params.ny,
91
+ xlim_right_pos_m=params.xlim_right,
92
+ xlim_left_pos_m=params.xlim_left,
93
+ ylim_up_pos_m=params.ylim_up,
94
+ ylim_down_pos_m=params.ylim_down,
95
+ base_contrail_depth_m=params.initial_contrail_depth_offset,
96
+ contrail_depth_scaling_factor_nondim=params.initial_contrail_depth_scale_factor,
97
+ base_contrail_width_m=params.initial_contrail_width_offset,
98
+ contrail_width_scaling_factor_nondim=params.initial_contrail_width_scale_factor,
99
+ )
100
+
101
+
102
+ def _yaml_bool(param: bool) -> str:
103
+ """Convert boolean to T/F for YAML file."""
104
+ return "T" if param else "F"
105
+
106
+
107
+ def generate_apcemm_input_met(
108
+ time: np.ndarray,
109
+ longitude: np.ndarray,
110
+ latitude: np.ndarray,
111
+ azimuth: np.ndarray,
112
+ altitude: np.ndarray,
113
+ met: MetDataset,
114
+ humidity_scaling: HumidityScaling,
115
+ dz_m: float,
116
+ interp_kwargs: dict[str, Any],
117
+ ) -> xr.Dataset:
118
+ r"""Create xarray Dataset for APCEMM meteorology netCDF file.
119
+
120
+ This dataset contains a sequence of atmospheric profiles along the
121
+ Lagrangian trajectory of an advected flight segment. The along-trajectory
122
+ dimension is parameterized by time (rather than latitude and longitude),
123
+ so the dataset coordinates are air pressure and time.
124
+
125
+ Parameters
126
+ ----------
127
+ time : np.ndarray
128
+ Time coordinates along the Lagrangian trajectory of the advected flight segment.
129
+ Values must be coercible to ``np.datetime64`` by :class:`GeoVectorDataset`.
130
+ Will be flattened before use if not 1-dimensional.
131
+ longitude : np.ndarray
132
+ Longitude [WGS84] along the Lagrangian trajectory of the advected flight segment.
133
+ Defines the longitude of the trajectory at each time and should have the
134
+ same shape as :param:`time`
135
+ Will be flattened before use if not 1-dimensional.
136
+ latitude : np.ndarray
137
+ Latitude [WGS84] along the Lagrangian trajectory of the advected flight segment.
138
+ Defines the longitude of the trajectory at each time and should have the
139
+ same shape as :param:`time`
140
+ Will be flattened before use if not 1-dimensional.
141
+ azimuth : np.ndarray
142
+ Azimuth [:math:`\deg`] of the advected flight segment at each point along its
143
+ Lagrangian trajectory. Note that the azimuth defines the orientation of the
144
+ advected segment itself, and not the direction in which advection is transporting
145
+ the segment. The azimuth is used to convert horizontal winds into segment-normal
146
+ wind shear. Must have the same shape as :param:`time`.
147
+ Will be flattened before use if not 1-dimensional.
148
+ altitude : np.ndarray
149
+ Defines altitudes [:math:`m`] on which atmospheric profiles are computed.
150
+ Profiles are defined using the same set of altitudes at every point
151
+ along the Lagrangian trajectory of the advected flight segment. Note that
152
+ this parameter does not have to have the same shape as :param:`time`.
153
+ met : MetDataset
154
+ Meteorology used to generate the sequence of atmospheric profiles. Must contain:
155
+ - air temperature [:math:`K`]
156
+ - specific humidity [:math:`kg/kg`]
157
+ - geopotential height [:math:`m`]
158
+ - eastward wind [:math:`m/s`]
159
+ - northward wind [:math:`m/s`]
160
+ - vertical velocity [:math:`Pa/s`]
161
+ humidity_scaling : HumidityScaling
162
+ Humidity scaling applied to specific humidity in :param:`met` before
163
+ generating atmospheric profiles.
164
+ dz_m : float
165
+ Altitude difference [:math:`m`] used to approximate vertical derivatives
166
+ when computing wind shear.
167
+
168
+ Returns
169
+ -------
170
+ xr.Dataset
171
+ Meteorology dataset in required format for APCEMM input.
172
+ """
173
+
174
+ # Ensure that altitudes are sorted ascending
175
+ altitude = np.sort(altitude)
176
+
177
+ # Check for required fields in met
178
+ vars = (
179
+ met_var.AirTemperature,
180
+ met_var.SpecificHumidity,
181
+ met_var.GeopotentialHeight,
182
+ met_var.EastwardWind,
183
+ met_var.NorthwardWind,
184
+ met_var.VerticalVelocity,
185
+ )
186
+ met.ensure_vars(vars)
187
+ met.standardize_variables(vars)
188
+
189
+ # Flatten input arrays
190
+ time = time.ravel()
191
+ longitude = longitude.ravel()
192
+ latitude = latitude.ravel()
193
+ azimuth = azimuth.ravel()
194
+ altitude = altitude.ravel()
195
+
196
+ # Estimate pressure levels close to target altitudes
197
+ # (not exact because this assumes the ISA temperature profile)
198
+ pressure = units.m_to_pl(altitude) * 1e2
199
+
200
+ # Broadcast to required shape and create vector for initial interpolation
201
+ # onto original pressure levels at target horizontal location.
202
+ shape = (time.size, altitude.size)
203
+ time = np.broadcast_to(time[:, np.newaxis], shape).ravel()
204
+ longitude = np.broadcast_to(longitude[:, np.newaxis], shape).ravel()
205
+ latitude = np.broadcast_to(latitude[:, np.newaxis], shape).ravel()
206
+ azimuth = np.broadcast_to(azimuth[:, np.newaxis], shape).ravel()
207
+ level = np.broadcast_to(pressure[np.newaxis, :] / 1e2, shape).ravel()
208
+ vector = GeoVectorDataset(
209
+ data={"azimuth": azimuth},
210
+ longitude=longitude,
211
+ latitude=latitude,
212
+ level=level,
213
+ time=time,
214
+ )
215
+
216
+ # Downselect met before interpolation
217
+ met = vector.downselect_met(met, copy=False)
218
+
219
+ # Interpolate meteorology data onto vector
220
+ scale_humidity = humidity_scaling is not None and "specific_humidity" not in vector
221
+ for met_key in (
222
+ "air_temperature",
223
+ "eastward_wind",
224
+ "geopotential_height",
225
+ "northward_wind",
226
+ "specific_humidity",
227
+ "lagrangian_tendency_of_air_pressure",
228
+ ):
229
+ models.interpolate_met(met, vector, met_key, **interp_kwargs)
230
+
231
+ # Interpolate winds at lower level for shear calculation
232
+ air_pressure_lower = thermo.pressure_dz(vector["air_temperature"], vector.air_pressure, dz_m)
233
+ lower_level = air_pressure_lower / 100.0
234
+ for met_key in ("eastward_wind", "northward_wind"):
235
+ vector_key = f"{met_key}_lower"
236
+ models.interpolate_met(met, vector, met_key, vector_key, **interp_kwargs, level=lower_level)
237
+
238
+ # Apply humidity scaling
239
+ if scale_humidity and humidity_scaling is not None:
240
+ humidity_scaling.eval(vector, copy_source=False)
241
+
242
+ # Compute RHi and segment-normal shear
243
+ vector.setdefault(
244
+ "rhi",
245
+ thermo.rhi(vector["specific_humidity"], vector["air_temperature"], vector.air_pressure),
246
+ )
247
+ vector.setdefault(
248
+ "normal_shear",
249
+ normal_wind_shear(
250
+ vector["eastward_wind"],
251
+ vector["eastward_wind_lower"],
252
+ vector["northward_wind"],
253
+ vector["northward_wind_lower"],
254
+ vector["azimuth"],
255
+ dz_m,
256
+ ),
257
+ )
258
+
259
+ # Reshape interpolated fields to (time, level).
260
+ nlev = altitude.size
261
+ ntime = len(vector) // nlev
262
+ shape = (ntime, nlev)
263
+ time = np.unique(vector["time"])
264
+ time = (time - time[0]) / np.timedelta64(1, "h")
265
+ temperature = vector["air_temperature"].reshape(shape)
266
+ qv = vector["specific_humidity"].reshape(shape)
267
+ z = vector["geopotential_height"].reshape(shape)
268
+ rhi = vector["rhi"].reshape(shape)
269
+ shear = vector["normal_shear"].reshape(shape)
270
+ shear[:, -1] = shear[:, -2] # lowest level will be nan
271
+ omega = vector["lagrangian_tendency_of_air_pressure"].reshape(shape)
272
+ virtual_temperature = temperature * (1 + qv / constants.epsilon) / (1 + qv)
273
+ density = pressure[np.newaxis, :] / (constants.R_d * virtual_temperature)
274
+ w = -omega / (density * constants.g)
275
+
276
+ # Interpolate fields to target altitudes profile-by-profile
277
+ # to obtain 2D arrays with dimensions (time, altitude).
278
+ temperature_on_z = np.zeros(shape, dtype=temperature.dtype)
279
+ rhi_on_z = np.zeros(shape, dtype=rhi.dtype)
280
+ shear_on_z = np.zeros(shape, dtype=shear.dtype)
281
+ w_on_z = np.zeros(shape, dtype=w.dtype)
282
+
283
+ # Fields should already be on pressure levels close to target
284
+ # altitudes, so this just uses linear interpolation and constant
285
+ # extrapolation on fields expected by APCEMM.
286
+ # NaNs are preserved at the start and end of interpolated profiles
287
+ # but removed in interiors.
288
+ def interp(z: np.ndarray, z0: np.ndarray, f0: np.ndarray) -> np.ndarray:
289
+ # mask nans
290
+ mask = np.isnan(z0) | np.isnan(f0)
291
+ if np.all(mask):
292
+ msg = (
293
+ "Found all-NaN profile during APCEMM meterology input file creation. "
294
+ "MetDataset may have insufficient spatiotemporal coverage."
295
+ )
296
+ raise ValueError(msg)
297
+ z0 = z0[~mask]
298
+ f0 = f0[~mask]
299
+
300
+ # interpolate
301
+ assert np.all(np.diff(z0) > 0) # expect increasing altitudes
302
+ fi = np.interp(z, z0, f0, left=f0[0], right=f0[-1])
303
+
304
+ # restore nans at start and end of profile
305
+ if mask[0]: # nans at top of profile
306
+ fi[z > z0.max()] = np.nan
307
+ if mask[-1]: # nans at end of profile
308
+ fi[z < z0.min()] = np.nan
309
+ return fi
310
+
311
+ # The manual for loop is unlikely to be a bottleneck since a
312
+ # substantial amount of work is done within each iteration.
313
+ for i in range(ntime):
314
+ temperature_on_z[i, :] = interp(altitude, z[i, :], temperature[i, :])
315
+ rhi_on_z[i, :] = interp(altitude, z[i, :], rhi[i, :])
316
+ shear_on_z[i, :] = interp(altitude, z[i, :], shear[i, :])
317
+ w_on_z[i, :] = interp(altitude, z[i, :], w[i, :])
318
+
319
+ # APCEMM also requires initial pressure profile
320
+ pressure_on_z = interp(altitude, z[0, :], pressure)
321
+
322
+ # Create APCEMM input dataset.
323
+ # Transpose require because APCEMM expects (altitude, time) arrays.
324
+ return xr.Dataset(
325
+ data_vars={
326
+ "pressure": (("altitude",), pressure_on_z.astype("float32") / 1e2, {"units": "hPa"}),
327
+ "temperature": (
328
+ ("altitude", "time"),
329
+ temperature_on_z.astype("float32").T,
330
+ {"units": "K"},
331
+ ),
332
+ "relative_humidity_ice": (
333
+ ("altitude", "time"),
334
+ 1e2 * rhi_on_z.astype("float32").T,
335
+ {"units": "percent"},
336
+ ),
337
+ "shear": (("altitude", "time"), shear_on_z.astype("float32").T, {"units": "s**-1"}),
338
+ "w": (("altitude", "time"), w_on_z.astype("float32").T, {"units": "m s**-1"}),
339
+ },
340
+ coords={
341
+ "altitude": ("altitude", altitude.astype("float32") / 1e3, {"units": "km"}),
342
+ "time": ("time", time, {"units": "hours"}),
343
+ },
344
+ )
345
+
346
+
347
+ def run(
348
+ apcemm_path: pathlib.Path | str, input_yaml: str, rundir: str, stdout_log: str, stderr_log: str
349
+ ) -> None:
350
+ """
351
+ Run APCEMM executable.
352
+
353
+ Parameters
354
+ ----------
355
+ apcemm_path : pathlib.Path | str
356
+ Path to APCEMM executable.
357
+ input_yaml : str
358
+ Path to APCEMM input yaml file.
359
+ rundir : str
360
+ Path to APCEMM simulation directory.
361
+ stdout_log : str
362
+ Path to file used to log APCEMM stdout
363
+ stderr_log : str
364
+ Path to file used to log APCEMM stderr
365
+
366
+ Raises
367
+ ------
368
+ ChildProcessError
369
+ APCEMM exits with a non-zero return code.
370
+ """
371
+
372
+ with open(stdout_log, "w") as stdout, open(stderr_log, "w") as stderr:
373
+ result = subprocess.run(
374
+ [apcemm_path, input_yaml], stdout=stdout, stderr=stderr, cwd=rundir, check=False
375
+ )
376
+ if result.returncode != 0:
377
+ msg = (
378
+ f"APCEMM simulation in {rundir} "
379
+ f"exited with return code {result.returncode}. "
380
+ f"Check logs at {stdout_log} and {stderr_log}."
381
+ )
382
+ raise ChildProcessError(msg)
383
+
384
+
385
+ def normal_wind_shear(
386
+ u_hi: ArrayScalarLike,
387
+ u_lo: ArrayScalarLike,
388
+ v_hi: ArrayScalarLike,
389
+ v_lo: ArrayScalarLike,
390
+ azimuth: ArrayScalarLike,
391
+ dz: float,
392
+ ) -> ArrayScalarLike:
393
+ r"""Compute segment-normal wind shear from wind speeds at lower and upper levels.
394
+
395
+ Parameters
396
+ ----------
397
+ u_hi : ArrayScalarLike
398
+ Eastward wind at upper level [:math:`m/s`]
399
+ u_lo : ArrayScalarLike
400
+ Eastward wind at lower level [:math:`m/s`]
401
+ v_hi : ArrayScalarLike
402
+ Northward wind at upper level [:math:`m/s`]
403
+ v_lo : ArrayScalarLike
404
+ Northward wind at lower level [:math:`m/s`]
405
+ azimuth : ArrayScalarLike
406
+ Segment azimuth [:math:`\deg`]
407
+ dz : float
408
+ Distance between upper and lower level [:math:`m`]
409
+
410
+ Returns
411
+ -------
412
+ ArrayScalarLike
413
+ Segment-normal wind shear [:math:`1/s`]
414
+ """
415
+ du_dz = (u_hi - u_lo) / dz
416
+ dv_dz = (v_hi - v_lo) / dz
417
+ az_radians = units.degrees_to_radians(azimuth)
418
+ sin_az = np.sin(az_radians)
419
+ cos_az = np.cos(az_radians)
420
+ return sin_az * dv_dz - cos_az * du_dz
421
+
422
+
423
+ def soot_radius(
424
+ nvpm_ei_m: ArrayScalarLike, nvpm_ei_n: ArrayScalarLike, rho_bc: float = 1770.0
425
+ ) -> ArrayScalarLike:
426
+ """Calculate mean soot radius from mass and number emissions indices.
427
+
428
+ Parameters
429
+ ----------
430
+ nvpm_ei_m : ArrayScalarLike
431
+ Soot mass emissions index [:math:`kg/kg`]
432
+ nvpm_ei_n : ArrayScalarLike
433
+ Soot number emissions index [:math:`1/kg`]
434
+ rho_bc : float, optional
435
+ Density of black carbon [:math:`kg/m^3`]. By default, 1770.
436
+ """
437
+ return ((3.0 * nvpm_ei_m) / (4.0 * np.pi * rho_bc * nvpm_ei_n)) ** (1.0 / 3.0)
@@ -4,6 +4,7 @@ from pycontrails.models.cocip.cocip import Cocip
4
4
  from pycontrails.models.cocip.cocip_params import CocipFlightParams, CocipParams
5
5
  from pycontrails.models.cocip.cocip_uncertainty import CocipUncertaintyParams, habit_dirichlet
6
6
  from pycontrails.models.cocip.output_formats import (
7
+ compare_cocip_with_goes,
7
8
  contrail_flight_summary_statistics,
8
9
  contrails_to_hi_res_grid,
9
10
  flight_waypoint_summary_statistics,
@@ -24,4 +25,5 @@ __all__ = [
24
25
  "longitude_latitude_grid",
25
26
  "natural_cirrus_properties_to_hi_res_grid",
26
27
  "time_slice_statistics",
28
+ "compare_cocip_with_goes",
27
29
  ]
@@ -14,13 +14,17 @@ This module includes functions to produce additional output formats, including t
14
14
  (6) Increase spatial resolution of natural cirrus properties, required to estimate the
15
15
  high-resolution contrail cirrus coverage for (5).
16
16
  See :func:`natural_cirrus_properties_to_hi_res_grid`.
17
+ (7) Comparing simulated contrails from CoCiP with GOES satellite imagery.
18
+ See :func:`compare_cocip_with_goes`.
17
19
  """
18
20
 
19
21
  from __future__ import annotations
20
22
 
23
+ import pathlib
21
24
  import warnings
22
25
  from collections.abc import Hashable
23
26
 
27
+ import matplotlib.pyplot as plt
24
28
  import numpy as np
25
29
  import numpy.typing as npt
26
30
  import pandas as pd
@@ -28,6 +32,7 @@ import xarray as xr
28
32
 
29
33
  from pycontrails.core.met import MetDataArray, MetDataset
30
34
  from pycontrails.core.vector import GeoVectorDataset, vector_to_lon_lat_grid
35
+ from pycontrails.datalib.goes import GOES, extract_goes_visualization
31
36
  from pycontrails.models.cocip.contrail_properties import contrail_edges, plume_mass_per_distance
32
37
  from pycontrails.models.cocip.radiative_forcing import albedo
33
38
  from pycontrails.models.humidity_scaling import HumidityScaling
@@ -2077,3 +2082,163 @@ def _repeat_rows_and_columns(
2077
2082
 
2078
2083
  # Do not repeat final row and column as they are on the edge
2079
2084
  return array_2d_rep[: -(n_reps - 1), : -(n_reps - 1)]
2085
+
2086
+
2087
+ # -----------------------------------------
2088
+ # Compare CoCiP outputs with GOES satellite
2089
+ # -----------------------------------------
2090
+
2091
+
2092
+ def compare_cocip_with_goes(
2093
+ time: np.timedelta64 | pd.Timestamp,
2094
+ flight: GeoVectorDataset | pd.DataFrame,
2095
+ contrail: GeoVectorDataset | pd.DataFrame,
2096
+ *,
2097
+ spatial_bbox: tuple[float, float, float, float] = (-160.0, -80.0, 10.0, 80.0),
2098
+ region: str = "F",
2099
+ path_write_img: pathlib.Path | None = None,
2100
+ ) -> None | pathlib.Path:
2101
+ r"""
2102
+ Compare simulated persistent contrails from CoCiP with GOES satellite imagery.
2103
+
2104
+ Parameters
2105
+ ----------
2106
+ time : np.timedelta64 | pd.Timestamp
2107
+ Time of GOES satellite image.
2108
+ flight : GeoVectorDataset | pd.DataFrame
2109
+ Flight waypoints.
2110
+ Best to use the returned output :class:`Flight` from
2111
+ :meth:`pycontrails.models.cocip.Cocip.eval`.
2112
+ contrail : GeoVectorDataset | pd.DataFrame,
2113
+ Contrail evolution outputs (:attr:`pycontrails.models.cocip.Cocip.contrail`)
2114
+ set during :meth:`pycontrails.models.cocip.Cocip.eval`.
2115
+ spatial_bbox : tuple[float, float, float, float]
2116
+ Spatial bounding box, ``(lon_min, lat_min, lon_max, lat_max)``, [:math:`\deg`]
2117
+ region : str
2118
+ 'F' for full disk (image provided every 10 m), and 'C' for CONUS (image provided every 5 m)
2119
+ path_write_img : None | pathlib.Path
2120
+ File path to save the CoCiP-GOES image.
2121
+
2122
+ Returns
2123
+ -------
2124
+ None | pathlib.Path
2125
+ File path of saved CoCiP-GOES image if ``path_write_img`` is provided.
2126
+ """
2127
+
2128
+ try:
2129
+ import cartopy.crs as ccrs
2130
+ from cartopy.mpl.ticker import LatitudeFormatter, LongitudeFormatter
2131
+ except ModuleNotFoundError as e:
2132
+ dependencies.raise_module_not_found_error(
2133
+ name="compare_cocip_with_goes function",
2134
+ package_name="cartopy",
2135
+ module_not_found_error=e,
2136
+ pycontrails_optional_package="goes",
2137
+ )
2138
+
2139
+ # Round `time` to nearest GOES image time slice
2140
+ if isinstance(time, np.timedelta64):
2141
+ time = pd.to_datetime(time)
2142
+
2143
+ if region == "F":
2144
+ time = time.round("10min")
2145
+ elif region == "C":
2146
+ time = time.round("5min")
2147
+ else:
2148
+ raise AssertionError("`region` only accepts inputs of `F` (full disk) or `C` (CONUS)")
2149
+
2150
+ _flight = GeoVectorDataset(flight)
2151
+ _contrail = GeoVectorDataset(contrail)
2152
+
2153
+ # Ensure the required columns are included in `flight_waypoints` and `contrails`
2154
+ _flight.ensure_vars(["flight_id", "waypoint"])
2155
+ _contrail.ensure_vars(
2156
+ ["flight_id", "waypoint", "sin_a", "cos_a", "width", "tau_contrail", "age_hours"]
2157
+ )
2158
+
2159
+ # Downselect `_flight` only to spatial domain covered by GOES full disk
2160
+ is_in_lon = _flight.dataframe["longitude"].between(spatial_bbox[0], spatial_bbox[2])
2161
+ is_in_lat = _flight.dataframe["latitude"].between(spatial_bbox[1], spatial_bbox[3])
2162
+ is_in_lon_lat = is_in_lon & is_in_lat
2163
+
2164
+ if not np.any(is_in_lon_lat):
2165
+ warnings.warn(
2166
+ "Flight trajectory does not intersect with the defined spatial bounding box or spatial "
2167
+ "domain covered by GOES."
2168
+ )
2169
+
2170
+ _flight = _flight.filter(is_in_lon_lat)
2171
+
2172
+ # Filter `_flight` if time bounds were previously defined.
2173
+ is_before_time = _flight["time"] < time
2174
+
2175
+ if not np.any(is_before_time):
2176
+ warnings.warn("No flight waypoints were recorded before the specified `time`.")
2177
+
2178
+ _flight = _flight.filter(is_before_time)
2179
+
2180
+ # Downselect `_contrail` only to include the filtered flight waypoints
2181
+ is_in_domain = _contrail.dataframe["waypoint"].isin(_flight["waypoint"])
2182
+
2183
+ if not np.any(is_in_domain):
2184
+ warnings.warn(
2185
+ "No persistent contrails were formed within the defined spatial bounding box."
2186
+ )
2187
+
2188
+ _contrail = _contrail.filter(is_in_domain)
2189
+
2190
+ # Download GOES image at `time`
2191
+ goes = GOES(region=region)
2192
+ da = goes.get(time)
2193
+ rgb, transform, extent = extract_goes_visualization(da)
2194
+ bbox = spatial_bbox[0], spatial_bbox[2], spatial_bbox[1], spatial_bbox[3]
2195
+
2196
+ # Calculate optimal figure dimensions
2197
+ d_lon = spatial_bbox[2] - spatial_bbox[0]
2198
+ d_lat = spatial_bbox[3] - spatial_bbox[1]
2199
+ x_dim = 9.99
2200
+ y_dim = x_dim * (d_lat / d_lon)
2201
+
2202
+ # Plot data
2203
+ fig = plt.figure(figsize=(1.2 * x_dim, y_dim))
2204
+ pc = ccrs.PlateCarree()
2205
+ ax = fig.add_subplot(projection=pc, extent=bbox)
2206
+ ax.coastlines() # type: ignore[attr-defined]
2207
+ ax.imshow(rgb, extent=extent, transform=transform)
2208
+
2209
+ ax.set_xticks([spatial_bbox[0], spatial_bbox[2]], crs=ccrs.PlateCarree())
2210
+ ax.set_yticks([spatial_bbox[1], spatial_bbox[3]], crs=ccrs.PlateCarree())
2211
+ lon_formatter = LongitudeFormatter(zero_direction_label=True)
2212
+ lat_formatter = LatitudeFormatter()
2213
+ ax.xaxis.set_major_formatter(lon_formatter)
2214
+ ax.yaxis.set_major_formatter(lat_formatter)
2215
+
2216
+ # Plot flight trajectory up to `time`
2217
+ ax.plot(_flight["longitude"], _flight["latitude"], c="k", linewidth=2.5)
2218
+ plt.legend(["Flight trajectory"])
2219
+
2220
+ # Plot persistent contrails at `time`
2221
+ is_time = (_contrail["time"] == time) & (~np.isnan(_contrail["age_hours"]))
2222
+ im = ax.scatter(
2223
+ _contrail["longitude"][is_time],
2224
+ _contrail["latitude"][is_time],
2225
+ c=_contrail["tau_contrail"][is_time],
2226
+ s=4,
2227
+ cmap="YlOrRd_r",
2228
+ vmin=0,
2229
+ vmax=0.2,
2230
+ )
2231
+ cbar = plt.colorbar(im)
2232
+ cbar.set_label(r"$\tau_{\rm contrail}$")
2233
+ ax.set_title(f"{time}")
2234
+ plt.tight_layout()
2235
+
2236
+ # return output path if `path_write_img` is not None
2237
+ if path_write_img is not None:
2238
+ t_str = time.strftime("%Y%m%d_%H%M%S")
2239
+ file_name = f"goes_{t_str}.png"
2240
+ output_path = path_write_img.joinpath(file_name)
2241
+ plt.savefig(output_path, dpi=150, bbox_inches="tight")
2242
+ plt.close()
2243
+
2244
+ return output_path
@@ -749,12 +749,13 @@ class CocipGrid(models.Model):
749
749
 
750
750
  # These should probably not be included in model input ... so
751
751
  # we'll get a warning if they get overwritten
752
- vector["longitude_head"], vector["latitude_head"] = geo.forward_azimuth(
753
- lons=lons, lats=lats, az=azimuth, dist=dist
754
- )
755
- vector["longitude_tail"], vector["latitude_tail"] = geo.forward_azimuth(
756
- lons=lons, lats=lats, az=azimuth, dist=-dist
757
- )
752
+ lon_head, lat_head = geo.forward_azimuth(lons=lons, lats=lats, az=azimuth, dist=dist)
753
+ vector["longitude_head"] = lon_head.astype(self._target_dtype, copy=False)
754
+ vector["latitude_head"] = lat_head.astype(self._target_dtype, copy=False)
755
+
756
+ lon_tail, lat_tail = geo.forward_azimuth(lons=lons, lats=lats, az=azimuth, dist=-dist)
757
+ vector["longitude_tail"] = lon_tail.astype(self._target_dtype, copy=False)
758
+ vector["latitude_tail"] = lat_tail.astype(self._target_dtype, copy=False)
758
759
 
759
760
  return vector
760
761