Coverage for src / CSET / operators / transect.py: 100%
62 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-15 15:46 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-15 15:46 +0000
1# © Crown copyright, Met Office (2022-2024) and CSET contributors.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
15"""Operators to extract a transect given a tuple of xy coords to start/finish."""
17import logging
19import iris
20import numpy as np
22from CSET.operators._utils import get_cube_yxcoordname
25def _check_within_bounds(point: tuple[float, float], lat_coord, lon_coord):
26 """Check if the point (lat, lon) is within the bounds of the data."""
27 lon_min = min(lon_coord.points)
28 lon_max = max(lon_coord.points)
29 lat_min = min(lat_coord.points)
30 lat_max = max(lat_coord.points)
31 if (
32 lat_min > point[0]
33 or lat_max < point[0]
34 or lon_min > point[1]
35 or lon_max < point[1]
36 ):
37 raise IndexError(
38 f"Point {point} not between {(lat_min, lon_min)} and {(lat_max, lon_max)}"
39 )
42def calc_transect(
43 input: iris.cube.Cube | iris.cube.CubeList, startcoords: tuple, endcoords: tuple
44):
45 """Compute transect between startcoords and endcoords.
47 Computes a transect for a given cube/cubelist containing at least latitude
48 and longitude coordinates, using an appropriate sampling interval along the
49 transect based on grid spacing. Also decides a suitable x coordinate to plot along
50 the transect - see notes for details on this.
52 Arguments
53 ---------
55 input: Cube | CubeList
56 An iris cube or cubelist containing latitude and longitude coordinate dimensions,
57 to compute the transect on.
58 startcoords: tuple
59 A tuple containing the start coordinates for the transect using model coordinates,
60 ordered (latitude,longitude).
61 endcoords: tuple
62 A tuple containing the end coordinates for the transect using model coordinates,
63 ordered (latitude,longitude).
65 Returns
66 -------
67 output: Cube | CubeList
68 A cube containing at least pressure and the coordinate specified by coord, for
69 the transect specified between startcoords and endcoords.
71 Notes
72 -----
73 This operator uses the iris.Nearest method to interpolate the specific point along
74 the transect.
75 Identification of an appropriate coordinate to plot along the x axis is done by
76 determining the largest distance out of latitude and longitude. For example, if
77 the transect is 90 degrees (west to east), then delta latitude is zero, so it will
78 always plot as a function of longitude. Vice versa if the transect is 180 degrees
79 (south to north). If a transect is 45 degrees, and delta latitude and longitude
80 are the same, it will default to plotting as a function of longitude. Note this
81 doesn't affect the transect plot, its purely for interpretation with some appropriate
82 x axis labelling/points.
83 """
84 # If function is passed a single cube, make this iterable.
85 if type(input) is iris.cube.Cube:
86 input = iris.cube.CubeList([input])
88 # To store final transects
89 output = iris.cube.CubeList()
91 for cube in input:
92 # Find out xy coord name
93 lat_name, lon_name = get_cube_yxcoordname(cube)
95 lon_coord = cube.coord(lon_name)
96 lat_coord = cube.coord(lat_name)
98 _check_within_bounds(startcoords, lat_coord, lon_coord)
99 _check_within_bounds(endcoords, lat_coord, lon_coord)
101 # Compute vector distance between start and end points in degrees.
102 dist_deg = np.sqrt(
103 (startcoords[0] - endcoords[0]) ** 2 + (startcoords[1] - endcoords[1]) ** 2
104 )
106 # Compute minimum gap between x/y spatial coords.
107 lon_min = np.abs(np.min(lon_coord.points[1:] - lon_coord.points[:-1]))
108 lat_min = np.abs(np.min(lat_coord.points[1:] - lat_coord.points[:-1]))
110 # For scenarios where coord is at 90 degree to the grid (i.e. no
111 # latitude/longitude change). Only xmin or ymin will be zero, not both
112 # (otherwise startcoords and endcoords the same).
113 if startcoords[1] == endcoords[1]:
114 # Along latitude.
115 lon_pnts = np.repeat(startcoords[1], int(dist_deg / lat_min))
116 lat_pnts = np.linspace(
117 startcoords[0], endcoords[0], int(dist_deg / lat_min)
118 )
119 transect_coord = "latitude"
120 elif startcoords[0] == endcoords[0]:
121 # Along longitude.
122 lon_pnts = np.linspace(
123 startcoords[1], endcoords[1], int(dist_deg / lon_min)
124 )
125 lat_pnts = np.repeat(startcoords[0], int(dist_deg / lon_min))
126 transect_coord = "longitude"
127 else:
128 # Else use the smallest grid space in x or y direction.
129 number_of_points = int(dist_deg / np.min([lon_min, lat_min]))
130 lon_pnts = np.linspace(startcoords[1], endcoords[1], number_of_points)
131 lat_pnts = np.linspace(startcoords[0], endcoords[0], number_of_points)
133 # If change in latitude larger than change in longitude:
134 if abs(startcoords[0] - endcoords[0]) > abs(startcoords[1] - endcoords[1]):
135 transect_coord = "latitude"
136 else:
137 transect_coord = "longitude"
139 # Create cubelist to store interpolated points along transect.
140 interpolated_cubes = iris.cube.CubeList()
142 # Iterate over all points along transect, lon_pnts will be the same shape as
143 # lat_pnts so we can use either to iterate over.
144 for i in range(0, lon_pnts.shape[0]):
145 logging.info("%s/%s", i + 1, lon_pnts.shape[0])
147 # Get point along transect.
148 cube_slice = cube.interpolate(
149 [(lon_name, lon_pnts[i]), (lat_name, lat_pnts[i])],
150 iris.analysis.Nearest(),
151 )
153 # Remove existing coordinates ready to add one single map coordinate
154 # Note latitude/longitude cubes may have additional AuxCoord to remove
155 for coord_name in [
156 "latitude",
157 "longitude",
158 "grid_latitude",
159 "grid_longitude",
160 ]:
161 if cube_slice.coords(coord_name):
162 cube_slice.remove_coord(coord_name)
164 if transect_coord == "latitude":
165 dist_coord = iris.coords.DimCoord(
166 lat_pnts[i], long_name="latitude", units="degrees"
167 )
168 cube_slice.add_aux_coord(dist_coord)
169 cube_slice = iris.util.new_axis(cube_slice, scalar_coord="latitude")
170 else:
171 dist_coord = iris.coords.DimCoord(
172 lon_pnts[i], long_name="longitude", units="degrees"
173 )
174 cube_slice.add_aux_coord(dist_coord)
175 cube_slice = iris.util.new_axis(cube_slice, scalar_coord="longitude")
177 interpolated_cubes.append(cube_slice)
179 # Concatenate into single cube.
180 interpolated_cubes = interpolated_cubes.concatenate()
182 # Add metadata to interpolated cubes showing coordinates.
183 interpolated_cubes[0].attributes["transect_coords"] = (
184 f"{startcoords[0]}_{startcoords[1]}_{endcoords[0]}_{endcoords[1]}"
185 )
187 # If concatenation successful, should be CubeList with one cube left.
188 assert len(interpolated_cubes) == 1, (
189 f"len(interpolated_cubes) = {len(interpolated_cubes)}"
190 )
192 # Carry over useful attributes to transect, like model identifier.
193 for key, value in cube.attributes.items():
194 interpolated_cubes[0].attributes.setdefault(key, value)
196 output.append(interpolated_cubes[0])
198 if len(output) == 1:
199 return output[0]
200 else:
201 return output