Skip to content

Rotate utils

align_with_flow

align_with_flow(u1, u2, u3)

Computes the rotation angles that minimize the burst-averaged v and w velocities.

Parameters:

Name Type Description Default
u1 ndarray

Velocity components with shape (M, N), where M is the number of bursts and N is the number of samples per burst.

required
u2 ndarray

Velocity components with shape (M, N), where M is the number of bursts and N is the number of samples per burst.

required
u3 ndarray

Velocity components with shape (M, N), where M is the number of bursts and N is the number of samples per burst.

required

Returns:

Name Type Description
theta_h ndarray

Horizontal rotation angle in degrees, shape (M,)

theta_v ndarray

Vertical rotation angle in degrees, shape (M,)

Source code in src/pytoast/utils/rotate_utils.py
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
def align_with_flow(u1: np.ndarray, u2: np.ndarray, u3: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Computes the rotation angles that minimize the burst-averaged v and w velocities.

    Parameters
    ----------
    u1, u2, u3 : np.ndarray
        Velocity components with shape (M, N), where M is the number of bursts
        and N is the number of samples per burst.

    Returns
    -------
    theta_h : np.ndarray
        Horizontal rotation angle in degrees, shape (M,)
    theta_v : np.ndarray
        Vertical rotation angle in degrees, shape (M,)
    """
    u_bar = np.mean(u1, axis=1)
    v_bar = np.mean(u2, axis=1)
    w_bar = np.mean(u3, axis=1)
    U = np.sqrt(u_bar**2 + v_bar**2)
    theta_h = np.arctan2(v_bar, u_bar)
    theta_v = np.arctan2(w_bar, U)
    out = (np.rad2deg(theta_h), np.rad2deg(theta_v))
    return out

align_with_principal_axis

align_with_principal_axis(u1, u2, u3)

Calculates the direction of maximum variance from the u and v velocities (Thomson & Emery, 4.52b).

Parameters:

Name Type Description Default
u1 ndarray

Velocity components with shape (M, N), where M is the number of bursts and N is the number of samples per burst. Assumes u1 = eastward velocity, u2 = northward velocity, u3 = vertical velocity.

required
u2 ndarray

Velocity components with shape (M, N), where M is the number of bursts and N is the number of samples per burst. Assumes u1 = eastward velocity, u2 = northward velocity, u3 = vertical velocity.

required
u3 ndarray

Velocity components with shape (M, N), where M is the number of bursts and N is the number of samples per burst. Assumes u1 = eastward velocity, u2 = northward velocity, u3 = vertical velocity.

required

Returns:

Name Type Description
theta_h ndarray

Direction of maximum variance in degrees, CCW positive from east, shape (M,)

theta_v ndarray

Vertical rotation angle in degrees, shape (M,)

References

Thomson, R. E., & Emery, W. J. (2024). Data analysis methods in physical oceanography. Elsevier.

Source code in src/pytoast/utils/rotate_utils.py
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
def align_with_principal_axis(u1: np.ndarray, u2: np.ndarray, u3: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """
    Calculates the direction of maximum variance from the u and v velocities (Thomson & Emery, 4.52b).

    Parameters
    ----------
    u1, u2, u3 : np.ndarray
        Velocity components with shape (M, N), where M is the number of bursts
        and N is the number of samples per burst. Assumes u1 = eastward velocity,
        u2 = northward velocity, u3 = vertical velocity.

    Returns
    -------
    theta_h : np.ndarray
        Direction of maximum variance in degrees, CCW positive from east, shape (M,)
    theta_v : np.ndarray
        Vertical rotation angle in degrees, shape (M,)

    References
    ----------
    Thomson, R. E., & Emery, W. J. (2024). Data analysis methods in physical oceanography. Elsevier.
    """
    # (Co)variances
    u1_bar = np.mean(u1, axis=1, keepdims=True)
    u2_bar = np.mean(u2, axis=1, keepdims=True)
    u1_prime = u1 - u1_bar
    u2_prime = u2 - u2_bar
    u1_var = np.mean(u1_prime**2, axis=1)
    u2_var = np.mean(u2_prime**2, axis=1)
    cv = np.mean(u1_prime * u2_prime, axis=1)

    # Direction of maximum variance in xy-plane (heading)
    theta_h_radians = 0.5 * np.arctan2(2.0 * cv, (u1_var - u2_var))

    # Pitch angle
    u_rot = u1 * np.cos(theta_h_radians[:, np.newaxis]) + u2 * np.sin(theta_h_radians[:, np.newaxis])
    u_rot_bar = np.mean(u_rot, axis=1)
    u3_bar = np.mean(u3, axis=1)
    theta_v_radians = np.arctan2(u3_bar, u_rot_bar)

    out = (np.rad2deg(theta_h_radians), np.rad2deg(theta_v_radians))
    return out

apply_flow_rotation

apply_flow_rotation(burst_data, flow_rotation)

Rotate u1/u2/u3 to align with a particular flow direction.

Parameters:

Name Type Description Default
burst_data dict

Burst data dictionary. Must be in non-beam coordinates.

required
flow_rotation str or tuple

align_principal, align_streamwise, or (theta_h_deg, theta_v_deg).

required

Returns:

Type Description
dict

burst_data with u1/u2/u3 rotated and burst_data["rotation"] set.

Source code in src/pytoast/utils/rotate_utils.py
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
def apply_flow_rotation(burst_data: dict, flow_rotation: str | tuple) -> dict:
    """Rotate u1/u2/u3 to align with a particular flow direction.

    Parameters
    ----------
    burst_data : dict
        Burst data dictionary. Must be in non-beam coordinates.
    flow_rotation : str or tuple
        `align_principal`, `align_streamwise`, or (theta_h_deg, theta_v_deg).

    Returns
    -------
    dict
        burst_data with u1/u2/u3 rotated and burst_data["rotation"] set.
    """
    if isinstance(flow_rotation, str):
        if flow_rotation == "align_principal":
            theta_h, theta_v = align_with_principal_axis(burst_data["u1"], burst_data["u2"], burst_data["u3"])
        elif flow_rotation == "align_streamwise":
            theta_h, theta_v = align_with_flow(burst_data["u1"], burst_data["u2"], burst_data["u3"])
        else:
            raise ValueError(
                f"Invalid flow_rotation '{flow_rotation}'. Must be 'align_principal' or 'align_streamwise'."
            )
    elif isinstance(flow_rotation, tuple):
        theta_h, theta_v = flow_rotation
        if len(theta_h) != burst_data["u1"].shape[0] or len(theta_v) != burst_data["u1"].shape[0]:
            raise ValueError(
                f"flow_rotation must have the same length as u1, u2, and u3, got {len(theta_h)}, {len(theta_v)}, {len(burst_data['u1'])}"
            )
    else:
        raise TypeError(f"flow_rotation must be a str or tuple, got {type(flow_rotation)}")

    u1_new, u2_new, u3_new = rotate_velocity_by_theta(
        burst_data["u1"], burst_data["u2"], burst_data["u3"], theta_h, theta_v
    )
    burst_data["u1"] = u1_new
    burst_data["u2"] = u2_new
    burst_data["u3"] = u3_new
    burst_data["rotation"] = flow_rotation
    return burst_data

coord_transform_3_beam_nortek

coord_transform_3_beam_nortek(u1, u2, u3, heading, pitch, roll, transformation_matrix, declination=0.0, orientation='up', coords_in='beam', coords_out='xyz')

Implementation of Nortek's coordinate transformation for 3-beam instruments. https://support.nortekgroup.com/hc/en-us/articles/26828129966876-How-do-I-transform-a-coordinate-system-manually

Parameters:

Name Type Description Default
u1 array - like

Velocity components in the input coordinate system

required
u2 array - like

Velocity components in the input coordinate system

required
u3 array - like

Velocity components in the input coordinate system

required
heading float or array - like

Instrument orientation in degrees. If arrays, rotation is applied about the mean angles.

required
pitch float or array - like

Instrument orientation in degrees. If arrays, rotation is applied about the mean angles.

required
roll float or array - like

Instrument orientation in degrees. If arrays, rotation is applied about the mean angles.

required
transformation_matrix ndarray

3x3 beam-to-xyz transformation matrix from the instrument config

required
declination float

Magnetic declination in degrees

0.0
orientation str

up or down

'up'
coords_in str

One of {beam, xyz, enu}

'beam'
coords_out str

One of {beam, xyz, enu}

'beam'

Returns:

Type Description
tuple of three np.ndarray

Velocity components (u1, u2, u3) in the output coordinate system.

Source code in src/pytoast/utils/rotate_utils.py
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
def coord_transform_3_beam_nortek(
    u1: np.ndarray,
    u2: np.ndarray,
    u3: np.ndarray,
    heading: float | np.ndarray | None,
    pitch: float | np.ndarray | None,
    roll: float | np.ndarray | None,
    transformation_matrix: np.ndarray,
    declination: float = 0.0,
    orientation: str = "up",
    coords_in: str = "beam",
    coords_out: str = "xyz",
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """
    Implementation of Nortek's coordinate transformation for 3-beam instruments.
    https://support.nortekgroup.com/hc/en-us/articles/26828129966876-How-do-I-transform-a-coordinate-system-manually

    Parameters
    ----------
    u1, u2, u3 : array-like
        Velocity components in the input coordinate system
    heading, pitch, roll : float or array-like
        Instrument orientation in degrees. If arrays, rotation is applied about the mean angles.
    transformation_matrix : np.ndarray
        3x3 beam-to-xyz transformation matrix from the instrument config
    declination : float
        Magnetic declination in degrees
    orientation : str
        `up` or `down`
    coords_in, coords_out : str
        One of {`beam`, `xyz`, `enu`}

    Returns
    -------
    tuple of three np.ndarray
        Velocity components (u1, u2, u3) in the output coordinate system.
    """
    if coords_in == coords_out:
        return (u1, u2, u3)

    T = transformation_matrix.copy()
    if np.any(T > 1000):
        T /= 4096.0

    # Velocity array
    U = np.vstack((u1.reshape(1, -1), u2.reshape(1, -1), u3.reshape(1, -1)))

    if coords_in == "beam" and coords_out == "xyz":
        U_rot = T @ U
        u1_rot = U_rot[0, :]
        u2_rot = U_rot[1, :]
        u3_rot = U_rot[2, :]
        return (u1_rot, u2_rot, u3_rot)
    elif coords_in == "xyz" and coords_out == "beam":
        U_rot = np.linalg.inv(T) @ U
        u1_rot = U_rot[0, :]
        u2_rot = U_rot[1, :]
        u3_rot = U_rot[2, :]
        return (u1_rot, u2_rot, u3_rot)

    T_flip = T.copy()
    if orientation == "down":
        T_flip[1, :] = -T_flip[1, :]
        T_flip[2, :] = -T_flip[2, :]

    if heading is None or pitch is None or roll is None:
        raise ValueError("heading, pitch, and roll must be provided for ENU coordinate transforms")
    heading_plus_dec = (heading + declination) % 360
    h_rad = circmean(np.radians(heading_plus_dec - 90))
    p_rad = circmean(np.radians(pitch))
    r_rad = circmean(np.radians(roll))

    # Heading matrix
    H = np.array(
        [
            [np.cos(h_rad), np.sin(h_rad), 0],
            [-np.sin(h_rad), np.cos(h_rad), 0],
            [0, 0, 1],
        ]
    )

    # Pitch and Roll matrix
    P = np.array(
        [
            [
                np.cos(p_rad),
                -np.sin(p_rad) * np.sin(r_rad),
                -np.cos(r_rad) * np.sin(p_rad),
            ],
            [0, np.cos(r_rad), -np.sin(r_rad)],
            [
                np.sin(p_rad),
                np.sin(r_rad) * np.cos(p_rad),
                np.cos(p_rad) * np.cos(r_rad),
            ],
        ]
    )

    # XYZ to ENU matrix
    R = H @ P

    if coords_in == "beam" and coords_out == "enu":
        U_rot = R @ T_flip @ U
    elif coords_in == "enu" and coords_out == "beam":
        U_rot = np.linalg.inv(T_flip) @ np.linalg.inv(R) @ U
    elif coords_in == "enu" and coords_out == "xyz":
        U_rot = T @ np.linalg.inv(T_flip) @ np.linalg.inv(R) @ U
    elif coords_in == "xyz" and coords_out == "enu":
        U_rot = R @ T_flip @ np.linalg.inv(T) @ U
    else:
        raise ValueError("Invalid coordinate transformation.")

    u1_rot = U_rot[0, :]
    u2_rot = U_rot[1, :]
    u3_rot = U_rot[2, :]
    return (u1_rot, u2_rot, u3_rot)

coord_transform_4_beam_nortek

coord_transform_4_beam_nortek(u1, u2, u3, u4, heading, pitch, roll, transformation_matrix, declination=0.0, orientation='up', coords_in='beam', coords_out='xyz')

Implementation of Nortek's coordinate transformation for the 4-beam Signature instrument. Supports all combinations of beam, xyz, and enu coordinates.

https://support.nortekgroup.com/hc/en-us/articles/25924538648732-How-do-I-transform-a-coordinate-system-manually

Parameters:

Name Type Description Default
u1 array - like

Velocity components in the input coordinate system

required
u2 array - like

Velocity components in the input coordinate system

required
u3 array - like

Velocity components in the input coordinate system

required
u4 array - like

Velocity components in the input coordinate system

required
heading float or array - like

Instrument orientation in degrees. If arrays, rotation is applied about the mean angles.

required
pitch float or array - like

Instrument orientation in degrees. If arrays, rotation is applied about the mean angles.

required
roll float or array - like

Instrument orientation in degrees. If arrays, rotation is applied about the mean angles.

required
transformation_matrix ndarray

4x4 beam-to-xyz transformation matrix from the instrument config

required
declination float

Magnetic declination in degrees

0.0
orientation str

up or down

'up'
coords_in str

One of {beam, xyz, enu}

'beam'
coords_out str

One of {beam, xyz, enu}

'beam'

Returns:

Type Description
tuple of four np.ndarray
Source code in src/pytoast/utils/rotate_utils.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
def coord_transform_4_beam_nortek(
    u1: np.ndarray,
    u2: np.ndarray,
    u3: np.ndarray,
    u4: np.ndarray,
    heading: float | np.ndarray,
    pitch: float | np.ndarray,
    roll: float | np.ndarray,
    transformation_matrix: np.ndarray,
    declination: float = 0.0,
    orientation: str = "up",
    coords_in: str = "beam",
    coords_out: str = "xyz",
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """Implementation of Nortek's coordinate transformation for the 4-beam
    Signature instrument. Supports all combinations of beam, xyz, and enu
    coordinates.

    https://support.nortekgroup.com/hc/en-us/articles/25924538648732-How-do-I-transform-a-coordinate-system-manually

    Parameters
    ----------
    u1, u2, u3, u4 : array-like
        Velocity components in the input coordinate system
    heading, pitch, roll : float or array-like
        Instrument orientation in degrees. If arrays, rotation is applied about the mean angles.
    transformation_matrix : np.ndarray
        4x4 beam-to-xyz transformation matrix from the instrument config
    declination : float
        Magnetic declination in degrees
    orientation : str
        `up` or `down`
    coords_in, coords_out : str
        One of {`beam`, `xyz`, `enu`}

    Returns
    -------
    tuple of four np.ndarray
    """
    if coords_in == coords_out:
        return (u1, u2, u3, u4)

    T = transformation_matrix.copy()
    if np.any(T > 1000):
        T /= 4096.0

    U = np.vstack((u1.reshape(1, -1), u2.reshape(1, -1), u3.reshape(1, -1), u4.reshape(1, -1)))

    # beam <-> xyz: no orientation correction needed
    if coords_in == "beam" and coords_out == "xyz":
        U_rot = T @ U
    elif coords_in == "xyz" and coords_out == "beam":
        U_rot = np.linalg.inv(T) @ U
    else:
        # Build the 4x4 xyz->enu rotation matrix per Nortek Signature reference
        heading_plus_dec = (heading + declination) % 360
        h_rad = circmean(np.radians(heading_plus_dec - 90))
        p_rad = circmean(np.radians(pitch))
        r_rad = circmean(np.radians(roll))

        H = np.array(
            [
                [np.cos(h_rad), np.sin(h_rad), 0],
                [-np.sin(h_rad), np.cos(h_rad), 0],
                [0, 0, 1],
            ]
        )
        P = np.array(
            [
                [np.cos(p_rad), -np.sin(p_rad) * np.sin(r_rad), -np.cos(r_rad) * np.sin(p_rad)],
                [0, np.cos(r_rad), -np.sin(r_rad)],
                [np.sin(p_rad), np.sin(r_rad) * np.cos(p_rad), np.cos(p_rad) * np.cos(r_rad)],
            ]
        )

        R = np.zeros((4, 4))
        R[:3, :3] = H @ P
        # Nortek Signature twoZs modifications (from Nortek reference script)
        R[0, 2] /= 2
        R[0, 3] = R[0, 2]
        R[1, 2] /= 2
        R[1, 3] = R[1, 2]
        R[3, :] = R[2, :]
        R[2, 3] = 0
        R[3, 3] = R[2, 2]
        R[3, 2] = 0

        R_inv = np.linalg.inv(R)

        # For "down" orientation, flip sign of y, z1, z2 (not x) in xyz space before
        # applying the ENU rotation. sign^2 = 1, so the same array undoes itself on inverse.
        sign = np.array([[1], [-1], [-1], [-1]]) if orientation == "down" else np.ones((4, 1))

        if coords_in == "xyz" and coords_out == "enu":
            U_rot = R @ (sign * U)
        elif coords_in == "beam" and coords_out == "enu":
            U_xyz = T @ U
            U_rot = R @ (sign * U_xyz)
        elif coords_in == "enu" and coords_out == "xyz":
            # sign^2 = 1, so applying sign again inverts the orientation correction
            U_rot = sign * (R_inv @ U)
        elif coords_in == "enu" and coords_out == "beam":
            U_xyz = sign * (R_inv @ U)
            U_rot = np.linalg.inv(T) @ U_xyz
        else:
            raise ValueError("Invalid coordinate transformation.")

    return (U_rot[0, :], U_rot[1, :], U_rot[2, :], U_rot[3, :])

coord_transform_4_beam_rdi

coord_transform_4_beam_rdi(u1, u2, u3, u4, heading, pitch, roll, beam_angle=25.0, transformation_matrix=None, declination=0.0, orientation='up', coords_in='beam', coords_out='xyz')

Coordinate transformation for Teledyne RDI 4-beam ADCPs.

Supports all combinations of beam, xyz, and enu coordinates.

https://www.teledynemarine.com/en-us/support/SiteAssets/RDI/Manuals%20and%20Guides/General%20Interest/Coordinate_Transformation.pdf

Parameters:

Name Type Description Default
u1 array - like

Velocity components in the input coordinate system. For xyz inputs, u4 is the error velocity. For enu inputs, u4 is unused.

required
u2 array - like

Velocity components in the input coordinate system. For xyz inputs, u4 is the error velocity. For enu inputs, u4 is unused.

required
u3 array - like

Velocity components in the input coordinate system. For xyz inputs, u4 is the error velocity. For enu inputs, u4 is unused.

required
u4 array - like

Velocity components in the input coordinate system. For xyz inputs, u4 is the error velocity. For enu inputs, u4 is unused.

required
heading float or array - like

Instrument orientation in degrees. If arrays, rotation is applied about the mean angles.

required
pitch float or array - like

Instrument orientation in degrees. If arrays, rotation is applied about the mean angles.

required
roll float or array - like

Instrument orientation in degrees. If arrays, rotation is applied about the mean angles.

required
beam_angle float

Beam angle from vertical in degrees (used when transformation_matrix is None)

25.0
transformation_matrix ndarray

4x4 beam-to-xyz transformation matrix. Computed from beam_angle if not provided.

None
declination float

Magnetic declination in degrees

0.0
orientation str

up or down

'up'
coords_in str

One of {beam, xyz, enu}

'beam'
coords_out str

One of {beam, xyz, enu}

'beam'

Returns:

Type Description
tuple of four np.ndarray

For enu output: (E, N, U, error), where error is passed through from the xyz stage. For enu input (enu->xyz, enu->beam), the 4th return value is zero because the error velocity cannot be recovered from ENU.

Source code in src/pytoast/utils/rotate_utils.py
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
def coord_transform_4_beam_rdi(
    u1: np.ndarray,
    u2: np.ndarray,
    u3: np.ndarray,
    u4: np.ndarray,
    heading: float | np.ndarray,
    pitch: float | np.ndarray,
    roll: float | np.ndarray,
    beam_angle: float = 25.0,
    transformation_matrix: np.ndarray | None = None,
    declination: float = 0.0,
    orientation: str = "up",
    coords_in: str = "beam",
    coords_out: str = "xyz",
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """Coordinate transformation for Teledyne RDI 4-beam ADCPs.

    Supports all combinations of beam, xyz, and enu coordinates.

    https://www.teledynemarine.com/en-us/support/SiteAssets/RDI/Manuals%20and%20Guides/General%20Interest/Coordinate_Transformation.pdf

    Parameters
    ----------
    u1, u2, u3, u4 : array-like
        Velocity components in the input coordinate system. For xyz inputs,
        u4 is the error velocity. For enu inputs, u4 is unused.
    heading, pitch, roll : float or array-like
        Instrument orientation in degrees. If arrays, rotation is applied about the mean angles.
    beam_angle : float
        Beam angle from vertical in degrees (used when transformation_matrix is None)
    transformation_matrix : np.ndarray, optional
        4x4 beam-to-xyz transformation matrix. Computed from beam_angle if not provided.
    declination : float
        Magnetic declination in degrees
    orientation : str
        `up` or `down`
    coords_in, coords_out : str
        One of {`beam`, `xyz`, `enu`}

    Returns
    -------
    tuple of four np.ndarray
        For enu output: (E, N, U, error), where error is passed through from
        the xyz stage. For enu input (enu->xyz, enu->beam), the 4th return value
        is zero because the error velocity cannot be recovered from ENU.
    """
    if coords_in == coords_out:
        return (u1, u2, u3, u4)

    if transformation_matrix is None:
        beam_angle_rad = np.deg2rad(beam_angle)
        a = 1 / (2 * np.sin(beam_angle_rad))
        b = 1 / (4 * np.cos(beam_angle_rad))
        c = 1  # convex transducer head
        d = a / np.sqrt(2)
        T = np.array([[c * a, -c * a, 0, 0], [0, 0, -c * a, c * a], [b, b, b, b], [d, d, -d, -d]])
    else:
        T = transformation_matrix.copy()

    U = np.vstack((u1.reshape(1, -1), u2.reshape(1, -1), u3.reshape(1, -1), u4.reshape(1, -1)))

    # beam <-> xyz: T and its inverse (all 4 components, including error velocity)
    if coords_in == "beam" and coords_out == "xyz":
        U_rot = T @ U
        return (U_rot[0, :], U_rot[1, :], U_rot[2, :], U_rot[3, :])

    if coords_in == "xyz" and coords_out == "beam":
        U_rot = np.linalg.inv(T) @ U
        return (U_rot[0, :], U_rot[1, :], U_rot[2, :], U_rot[3, :])

    # All remaining cases involve ENU: build the 3x3 attitude matrix M
    heading_plus_dec = (heading + declination) % 360
    r_rad = circmean(np.deg2rad(roll))
    h_rad = circmean(np.deg2rad(heading_plus_dec))
    p_rad = circmean(np.deg2rad(pitch))

    # Modifications per RDI manual
    p_rad = np.arctan(np.tan(p_rad) * np.cos(r_rad))
    if orientation == "up":
        r_rad += np.pi

    ch, sh = np.cos(h_rad), np.sin(h_rad)
    cr, sr = np.cos(r_rad), np.sin(r_rad)
    cp, sp = np.cos(p_rad), np.sin(p_rad)

    M = np.array(
        [
            [(ch * cr + sh * sp * sr), (sh * cp), (ch * sr - sh * sp * cr)],
            [(-sh * cr + ch * sp * sr), (ch * cp), (-sh * sr + ch * sp * cr)],
            [(-cp * sr), (sp), (cp * cr)],
        ]
    )

    if coords_in == "xyz" and coords_out == "enu":
        # M acts on xyz (first 3 rows); error velocity (row 3) is passed through
        U_enu = M @ U[:3, :]
        return (U_enu[0, :], U_enu[1, :], U_enu[2, :], U[3, :])

    if coords_in == "beam" and coords_out == "enu":
        U_xyz = T @ U  # 4 components: x, y, z, error
        U_enu = M @ U_xyz[:3, :]
        return (U_enu[0, :], U_enu[1, :], U_enu[2, :], U_xyz[3, :])

    M_inv = np.linalg.inv(M)
    zeros = np.zeros((1, U.shape[1]))

    if coords_in == "enu" and coords_out == "xyz":
        # u4 (error velocity) cannot be recovered from ENU; returned as zero
        U_xyz = M_inv @ U[:3, :]
        return (U_xyz[0, :], U_xyz[1, :], U_xyz[2, :], zeros[0, :])

    if coords_in == "enu" and coords_out == "beam":
        # Reconstruct xyz with error=0 (unrecoverable), then invert T
        U_xyz4 = np.vstack([M_inv @ U[:3, :], zeros])
        U_beam = np.linalg.inv(T) @ U_xyz4
        return (U_beam[0, :], U_beam[1, :], U_beam[2, :], U_beam[3, :])

    raise ValueError(f"Invalid coordinate transformation: {coords_in!r} -> {coords_out!r}")

min_angle

min_angle(alpha)

Wraps angle(s) to the range [-180, 180) degrees.

Parameters:

Name Type Description Default
alpha float or ndarray

Angle(s) in degrees.

required

Returns:

Type Description
float or ndarray

Equivalent angle(s) wrapped to [-180, 180) degrees.

Source code in src/pytoast/utils/rotate_utils.py
461
462
463
464
465
466
467
468
469
470
471
472
473
474
def min_angle(alpha: float | np.ndarray) -> float | np.ndarray:
    """Wraps angle(s) to the range [-180, 180) degrees.

    Parameters
    ----------
    alpha : float or np.ndarray
        Angle(s) in degrees.

    Returns
    -------
    float or np.ndarray
        Equivalent angle(s) wrapped to [-180, 180) degrees.
    """
    return (alpha + 180) % 360 - 180

rotate_velocity_by_theta

rotate_velocity_by_theta(u1, u2, u3, theta_h, theta_v)

Rotates u, v, w velocities by directions defined by theta_h and theta_v.

Parameters:

Name Type Description Default
u1 ndarray

Velocity components with shape (M, N), where M is the number of bursts and N is the number of samples per burst.

required
u2 ndarray

Velocity components with shape (M, N), where M is the number of bursts and N is the number of samples per burst.

required
u3 ndarray

Velocity components with shape (M, N), where M is the number of bursts and N is the number of samples per burst.

required
theta_h float or ndarray

Horizontal rotation angle(s) in degrees, scalar or shape (M,)

required
theta_v float or ndarray

Vertical rotation angle(s) in degrees, scalar or shape (M,)

required

Returns:

Type Description
u_rot, v_rot, w_rot : np.ndarray

Rotated velocity components, each with shape (M, N)

Source code in src/pytoast/utils/rotate_utils.py
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
def rotate_velocity_by_theta(
    u1: np.ndarray,
    u2: np.ndarray,
    u3: np.ndarray,
    theta_h: float | np.ndarray,
    theta_v: float | np.ndarray,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Rotates u, v, w velocities by directions defined by theta_h and theta_v.

    Parameters
    ----------
    u1, u2, u3 : np.ndarray
        Velocity components with shape (M, N), where M is the number of bursts
        and N is the number of samples per burst.
    theta_h : float or np.ndarray
        Horizontal rotation angle(s) in degrees, scalar or shape (M,)
    theta_v : float or np.ndarray
        Vertical rotation angle(s) in degrees, scalar or shape (M,)

    Returns
    -------
    u_rot, v_rot, w_rot : np.ndarray
        Rotated velocity components, each with shape (M, N)
    """
    # (M,) or scalar -> (M, 1) for broadcasting against (M, N)
    th = np.deg2rad(np.atleast_1d(theta_h))[:, np.newaxis]
    tv = np.deg2rad(np.atleast_1d(theta_v))[:, np.newaxis]

    cos_h, sin_h = np.cos(th), np.sin(th)
    cos_v, sin_v = np.cos(tv), np.sin(tv)

    u_rot = u1 * cos_h * cos_v + u2 * sin_h * cos_v + u3 * sin_v
    v_rot = -u1 * sin_h + u2 * cos_h
    w_rot = -u1 * cos_h * sin_v - u2 * sin_h * sin_v + u3 * cos_v
    return u_rot, v_rot, w_rot