From 0d1328160b7464f527e22c3657893214dd8bc037 Mon Sep 17 00:00:00 2001 From: Christian Hellum Bye Date: Wed, 7 Oct 2026 09:04:46 -0700 Subject: [PATCH 1/3] deps: bump croissant-sim to v5.3.0.dev4 Moves the pin from v5.3.0.dev3 to v5.3.0.dev4. Prepared by croissant's scripts/bump_consumers.py; this passed against the new pin: uv run --all-extras pytest -q tests/test_beam.py tests/test_horizon.py tests/test_map.py tests/test_rotation_investigation.py tests/test_sim.py tests/test_sky.py --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 41ce0b3..d7c9d8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ "Topic :: Scientific/Engineering :: Astronomy", ] dependencies = [ - "croissant-sim @ git+https://github.com/christianhbye/croissant.git@v5.3.0.dev3", + "croissant-sim @ git+https://github.com/christianhbye/croissant.git@v5.3.0.dev4", "equinox", "jax", "numpy", diff --git a/uv.lock b/uv.lock index 8e844b1..921f8d4 100644 --- a/uv.lock +++ b/uv.lock @@ -562,7 +562,7 @@ toml = [ [[package]] name = "croissant-sim" version = "5.2.1" -source = { git = "https://github.com/christianhbye/croissant.git?rev=v5.3.0.dev3#58bf56e8af77a5a07fd0669ec6ba38900549e50c" } +source = { git = "https://github.com/christianhbye/croissant.git?rev=v5.3.0.dev4#a83a9864162b64466a85654fc1a4a2072f92e743" } dependencies = [ { name = "astropy" }, { name = "equinox" }, @@ -2030,7 +2030,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "croissant-sim", git = "https://github.com/christianhbye/croissant.git?rev=v5.3.0.dev3" }, + { name = "croissant-sim", git = "https://github.com/christianhbye/croissant.git?rev=v5.3.0.dev4" }, { name = "equinox" }, { name = "jax" }, { name = "numpy" }, From 6ac2a2fd3b53aaccbc861cce0f2336a2050ac417 Mon Sep 17 00:00:00 2001 From: Christian Hellum Bye Date: Wed, 7 Oct 2026 10:50:21 -0700 Subject: [PATCH 2/3] beam: forward croissant's horizon_frame through mistsim Beam croissant v5.3.0.dev4 adds horizon_frame="topocentric" so a terrain mask stays fixed to the ground while the beam rotates (croissant#156). mistsim's Beam had no way to pass it, so terrain users had to counter-rotate (and mirror) their masks by hand. - Beam takes horizon_frame (default "beam", the existing behaviour) and forwards it to croissant.Beam. - Docstring: the beam-grid convention (A = beam_az_rot - phi, so phi = 90 deg is West at beam_az_rot = 0), fractional horizon weights and croissant's fractional default, and the topocentric ground grid (phi = 0 East, phi = 90 deg North, phi = 90 - A), with the interpolation caveat for off-column rotations. - Tests: default is "beam"; unknown values raise; a ground-fixed sector lands at the same compass azimuth for beam_az_rot = 0, 40 and 233 deg; the default frame keeps the mask on the beam; and for an asymmetric beam, fgnd with the topocentric mask equals fgnd with a hand counter-rotated beam-frame mask. Dropping the forward fails 5 of these. Validation: 83 passed on PR #16's test set (its 76 plus 7 new). Co-Authored-By: Claude Opus 5.5 --- src/mistsim/beam.py | 41 +++++++++++++--- tests/test_beam.py | 112 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 7 deletions(-) diff --git a/src/mistsim/beam.py b/src/mistsim/beam.py index 6d335fc..0b0cab8 100644 --- a/src/mistsim/beam.py +++ b/src/mistsim/beam.py @@ -15,12 +15,19 @@ def __init__( beam_az_rot=0.0, beam_tilt=0.0, lmax=None, + horizon_frame="beam", ): """ Beam pattern object. Holds the beam pattern in local antenna coordinates and associated metadata. The beam must be defined on the grid specified by the `sampling` scheme. + Theta is colatitude from zenith and phi is right-handed about + the zenith, from the beam's x axis towards its y axis. The x + axis points to compass azimuth `beam_az_rot`, so a beam-grid + direction has compass azimuth ``A = beam_az_rot - degrees(phi)`` + (mod 360): with ``beam_az_rot = 0``, phi = 90 deg is West. + Note that the `lmax` parameter is no longer used. The `lmax` is automatically determined from the shape of the input data and the sampling scheme. To change the `lmax` the simulation runs @@ -42,13 +49,15 @@ def __init__( "mwss", which is a 1 deg equiangular sampling in theta and phi and includes the poles. horizon : array_like or None - The horizon mask: a boolean array specified for each - (theta, phi) direction (or pixel), with the same shape as - the last two (one for healpix) axes of data. It is an array - with True values for directions that are above the horizon - and False for directions that are below the horizon. - If None, it is assumed that the horizon is at - theta = 90 degrees. + Visible fractions in [0, 1] for each (theta, phi) direction + (or pixel), broadcastable to the spatial axes of data. Zero + blocks a sample, one keeps it, and fractional values weight + partially visible cells; boolean masks are accepted. Which + grid the array lives on is set by `horizon_frame`. If None, + the horizon is at theta = 90 degrees with fractional + boundary cells (croissant's default). For a horizon given + as a function of azimuth, ``croissant.horizon_weights`` + builds fractional boundary weights on regular grids. beam_az_rot : float Azimuthal rotation of the beam in degrees. The rotation is defined in the astronomy convention, i.e., the angle @@ -62,11 +71,28 @@ def __init__( lmax : int or None Removed. Will be ignored if provided and raise a FutureWarning. + horizon_frame : {"beam", "topocentric"} + Forwarded to ``croissant.Beam``. The default ``"beam"`` + keeps `horizon` on the beam grid, so it rotates with + `beam_az_rot`: right for obstructions attached to the + antenna, or for masks the caller has already + counter-rotated. Use ``"topocentric"`` for terrain: the + weights then live on croissant's fixed ground grid, whose + phi = 0 is East and phi = 90 deg is North, so a horizon + given in compass azimuth ``A`` goes at ``phi = 90 - A`` + (deg), independent of `beam_az_rot`. croissant + counter-rotates it into the beam frame by periodic linear + interpolation in phi, which is exact for rotations by whole + grid columns and softens edges in between; + ``horizon_in_beam_frame`` gives the weights applied. Raises ------ FutureWarning If `lmax` is not None. + ValueError + If `horizon_frame` is not "beam" or "topocentric" (raised + by croissant). """ if lmax is not None: @@ -86,4 +112,5 @@ def __init__( horizon=horizon, beam_rot=beam_rot, beam_tilt=beam_tilt, + horizon_frame=horizon_frame, ) diff --git a/tests/test_beam.py b/tests/test_beam.py index adfc7ba..720f07c 100644 --- a/tests/test_beam.py +++ b/tests/test_beam.py @@ -57,3 +57,115 @@ def test_beam_az_rot_matches_croissant(): np.array(cro_beam.compute_alm()), atol=1e-12, ) + + +def _mwss_grid(): + """Degrees of colatitude and longitude on the 1-deg MWSS grid.""" + theta = np.linspace(0.0, 180.0, 181) + phi = np.arange(360.0) + return theta, phi + + +def _ground_sector_mask(lo=80.0, hi=100.0, theta_h=60.0): + """Ground-grid mask blocking compass azimuth [lo, hi] below theta_h. + + On croissant's ground grid phi = 0 is East and phi = 90 deg is + North, so compass azimuth A sits at phi = 90 - A. + """ + theta, phi = _mwss_grid() + azimuth = np.mod(90.0 - phi, 360.0) + in_sector = (azimuth >= lo) & (azimuth <= hi) + return np.where((theta[:, None] > theta_h) & in_sector[None, :], 0.0, 1.0) + + +def test_horizon_frame_defaults_to_beam(): + data = jnp.ones((1, 181, 360)) + beam = Beam(data, jnp.array([50.0])) + assert beam.horizon_frame == "beam" + + +def test_horizon_frame_rejects_unknown_value(): + data = jnp.ones((1, 181, 360)) + with pytest.raises(ValueError, match="horizon_frame"): + Beam(data, jnp.array([50.0]), horizon_frame="ground") + + +@pytest.mark.parametrize("beam_az_rot", [0.0, 40.0, 233.0]) +def test_topocentric_mask_stays_on_the_ground(beam_az_rot): + """A ground-fixed sector lands at the same compass azimuth. + + A beam-grid column phi_b points to compass azimuth + A = beam_az_rot - phi_b, so the weights applied in the beam frame + must equal the ground mask read at that azimuth. + """ + _, phi = _mwss_grid() + mask = _ground_sector_mask() + beam = Beam( + jnp.ones((1, 181, 360)), + jnp.array([50.0]), + horizon=jnp.asarray(mask), + beam_az_rot=beam_az_rot, + horizon_frame="topocentric", + ) + applied = np.asarray(beam.horizon_in_beam_frame) + azimuth = np.mod(beam_az_rot - phi, 360.0) + ground_col = np.mod(90.0 - azimuth, 360.0).astype(int) + np.testing.assert_array_equal(applied, mask[:, ground_col]) + # the blocked columns are the East sector, whatever the rotation + blocked = azimuth[(applied == 0).any(axis=0)] + assert blocked.min() >= 80.0 and blocked.max() <= 100.0 + assert blocked.size == 21 + + +def test_beam_frame_mask_rotates_with_the_beam(): + """The default frame applies the mask as given, at any rotation.""" + mask = _ground_sector_mask() + for beam_az_rot in (0.0, 40.0): + beam = Beam( + jnp.ones((1, 181, 360)), + jnp.array([50.0]), + horizon=jnp.asarray(mask), + beam_az_rot=beam_az_rot, + ) + np.testing.assert_array_equal( + np.asarray(beam.horizon_in_beam_frame), mask + ) + + +def test_topocentric_fgnd_matches_manual_counter_rotation(): + """An asymmetric beam sees the ground mask the caller would build. + + With a lobe on the beam's x axis, the blocked fraction depends on + where the lobe points relative to the ground-fixed sector. The + topocentric result must equal the beam-frame result with a mask + counter-rotated by hand. + """ + theta, phi = _mwss_grid() + cos_phi = np.cos(np.deg2rad(phi))[None, :] + sin_theta = np.sin(np.deg2rad(theta))[:, None] + lobe = 1.0 + 0.8 * cos_phi * sin_theta + data = jnp.asarray(lobe[None]) + mask = _ground_sector_mask() + fgnd = [] + for beam_az_rot in (0.0, 90.0): + azimuth = np.mod(beam_az_rot - phi, 360.0) + manual = mask[:, np.mod(90.0 - azimuth, 360.0).astype(int)] + topo = Beam( + data, + jnp.array([50.0]), + horizon=jnp.asarray(mask), + beam_az_rot=beam_az_rot, + horizon_frame="topocentric", + ) + by_hand = Beam( + data, + jnp.array([50.0]), + horizon=jnp.asarray(manual), + beam_az_rot=beam_az_rot, + ) + f_topo = float(np.asarray(topo.compute_fgnd()).ravel()[0]) + f_hand = float(np.asarray(by_hand.compute_fgnd()).ravel()[0]) + np.testing.assert_allclose(f_topo, f_hand, rtol=1e-12) + fgnd.append(f_topo) + # the lobe points East at beam_az_rot = 90, into the sector + assert fgnd[1] > fgnd[0] From 15bedbc7509e10b610a2244afcdb5358616c7009 Mon Sep 17 00:00:00 2001 From: Christian Hellum Bye Date: Wed, 7 Oct 2026 11:08:50 -0700 Subject: [PATCH 3/3] beam: make the ground-fixed horizon the default (horizon_frame="topocentric") A horizon mask describes what blocks the sky from where the antenna stands (terrain, buildings, the ground), all fixed to the ground. Until now mistsim applied it on the beam grid, so it turned with beam_az_rot and terrain was misplaced whenever the beam was rotated, unless the caller counter-rotated the mask by hand. Antenna-attached structures belong in the EM beam pattern, never in a mask. The catch, and how it is handled: croissant's ground grid has phi = 0 at East (A = 90 - phi), while mistsim's beam grid at beam_az_rot = 0 has phi = 0 at North and phi = 90 deg at West (A = -phi). Flipping croissant's default through would turn every phi-dependent mask by 90 deg even with no rotation. So mistsim's ground grid is defined as the beam grid at beam_az_rot = 0 (the grid users already write masks on), and mistsim shifts the mask by 90 deg onto croissant's ground grid before passing it on, with croissant's own periodic shift. The shift is exact when 90 deg is whole grid columns (1-deg MWSS, every HEALPix ring) and interpolated otherwise. Consequences: - beam_az_rot = 0: identical weights and fgnd to the old behaviour (tested on MWSS and HEALPix). - theta-only masks (what the pipeline builds): identical at any rotation (tested). - phi-dependent masks with beam_az_rot != 0: now stay on the ground. Callers who counter-rotated by hand must drop that step or pass horizon_frame="beam", now documented as only for such masks. - The horizon attribute holds croissant's ground-grid weights; horizon_in_beam_frame gives the weights applied. Uses croissant.horizon._horizon_in_beam_frame (private; mistsim pins croissant exactly). Tests: 11 horizon-frame tests in tests/test_beam.py. Removing the conversion fails 6; flipping the offset sign fails 6. Validation: 87 passed on PR #16's test set. Co-Authored-By: Claude Opus 5.5 --- src/mistsim/beam.py | 89 +++++++++++++++++++++++++++++--------- tests/test_beam.py | 102 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 148 insertions(+), 43 deletions(-) diff --git a/src/mistsim/beam.py b/src/mistsim/beam.py index 0b0cab8..4df488e 100644 --- a/src/mistsim/beam.py +++ b/src/mistsim/beam.py @@ -1,6 +1,35 @@ import warnings import croissant as cro +import jax.numpy as jnp +from croissant.horizon import _horizon_in_beam_frame + +# mistsim's ground grid is the beam grid as it is at beam_az_rot = 0 +# (phi = 0 North, phi = 90 deg West, compass azimuth A = -phi). Croissant's +# ground grid has phi = 0 East (A = 90 - phi). The same direction therefore +# sits 90 deg further round on croissant's grid. +_GROUND_GRID_OFFSET_DEG = 90.0 + + +def _to_croissant_ground_grid(horizon, sampling, spatial_shape): + """Move a mask from mistsim's ground grid onto croissant's. + + Croissant's own periodic shift does the work: it is exact when 90 deg + is a whole number of grid columns (the 1-deg MWSS grid, and every + HEALPix ring) and linear interpolation otherwise. Scalars and + theta-only masks come back unchanged. + """ + nside = None + if sampling == "healpix": + nside = int(round((spatial_shape[0] / 12) ** 0.5)) + return _horizon_in_beam_frame( + jnp.asarray(horizon), + "topocentric", + _GROUND_GRID_OFFSET_DEG, + sampling, + spatial_shape, + nside, + ) class Beam(cro.Beam): @@ -15,7 +44,7 @@ def __init__( beam_az_rot=0.0, beam_tilt=0.0, lmax=None, - horizon_frame="beam", + horizon_frame="topocentric", ): """ Beam pattern object. Holds the beam pattern in local antenna @@ -52,12 +81,16 @@ def __init__( Visible fractions in [0, 1] for each (theta, phi) direction (or pixel), broadcastable to the spatial axes of data. Zero blocks a sample, one keeps it, and fractional values weight - partially visible cells; boolean masks are accepted. Which - grid the array lives on is set by `horizon_frame`. If None, - the horizon is at theta = 90 degrees with fractional - boundary cells (croissant's default). For a horizon given - as a function of azimuth, ``croissant.horizon_weights`` - builds fractional boundary weights on regular grids. + partially visible cells; boolean masks are accepted. With the + default ``horizon_frame="topocentric"`` the mask is fixed to + the ground. It is given on the beam grid as it is at + ``beam_az_rot = 0`` (phi = 0 North, phi = 90 deg West), so a + direction at compass azimuth ``A`` sits at ``phi = -A`` + (mod 360 deg), and it stays there whatever `beam_az_rot` is. + If None, the horizon is at theta = 90 degrees with fractional + boundary cells (croissant's default). For a horizon given as + a function of azimuth, ``croissant.horizon_weights`` builds + fractional boundary weights on regular grids. beam_az_rot : float Azimuthal rotation of the beam in degrees. The rotation is defined in the astronomy convention, i.e., the angle @@ -71,20 +104,30 @@ def __init__( lmax : int or None Removed. Will be ignored if provided and raise a FutureWarning. - horizon_frame : {"beam", "topocentric"} - Forwarded to ``croissant.Beam``. The default ``"beam"`` - keeps `horizon` on the beam grid, so it rotates with - `beam_az_rot`: right for obstructions attached to the - antenna, or for masks the caller has already - counter-rotated. Use ``"topocentric"`` for terrain: the - weights then live on croissant's fixed ground grid, whose - phi = 0 is East and phi = 90 deg is North, so a horizon - given in compass azimuth ``A`` goes at ``phi = 90 - A`` - (deg), independent of `beam_az_rot`. croissant - counter-rotates it into the beam frame by periodic linear - interpolation in phi, which is exact for rotations by whole - grid columns and softens edges in between; - ``horizon_in_beam_frame`` gives the weights applied. + horizon_frame : {"topocentric", "beam"} + Which frame `horizon` is fixed to. **Use the default, + ``"topocentric"``.** A horizon mask describes what blocks the + sky from where the antenna stands: terrain, buildings, the + ground itself. All of these are fixed to the ground, so the + mask must not turn when the beam does. Structures attached + to the antenna are not a horizon mask: they belong in the + beam pattern itself, from the EM simulation. + + ``"beam"`` applies the mask on the rotated beam grid, so it + turns with `beam_az_rot`. It exists only for masks a caller + has already counter-rotated into the beam frame by hand (the + only correct way to handle terrain before this option + existed). It is the same as ``"topocentric"`` when + ``beam_az_rot = 0`` and for theta-only masks. + + mistsim moves a topocentric mask onto croissant's ground grid + (phi = 0 East) before passing it on; croissant then rotates + it into the beam frame by periodic linear interpolation in + phi. Both steps are exact when the shifts are whole grid + columns (e.g. the 1-deg MWSS grid with whole-degree + `beam_az_rot`) and soften sharp edges otherwise. The `horizon` + attribute holds croissant's ground-grid weights; + ``horizon_in_beam_frame`` gives the weights actually applied. Raises ------ @@ -105,6 +148,10 @@ def __init__( ) # croissant expects X-axis along East beam_rot = beam_az_rot - 90 + if horizon is not None and horizon_frame == "topocentric": + horizon = _to_croissant_ground_grid( + horizon, sampling, jnp.shape(data)[1:] + ) super().__init__( data, freqs, diff --git a/tests/test_beam.py b/tests/test_beam.py index 720f07c..246e9de 100644 --- a/tests/test_beam.py +++ b/tests/test_beam.py @@ -67,21 +67,29 @@ def _mwss_grid(): def _ground_sector_mask(lo=80.0, hi=100.0, theta_h=60.0): - """Ground-grid mask blocking compass azimuth [lo, hi] below theta_h. + """Ground mask blocking compass azimuth [lo, hi] below theta_h. - On croissant's ground grid phi = 0 is East and phi = 90 deg is - North, so compass azimuth A sits at phi = 90 - A. + mistsim's ground grid is the beam grid at beam_az_rot = 0, so + compass azimuth A sits at phi = -A. """ theta, phi = _mwss_grid() - azimuth = np.mod(90.0 - phi, 360.0) + azimuth = np.mod(-phi, 360.0) in_sector = (azimuth >= lo) & (azimuth <= hi) return np.where((theta[:, None] > theta_h) & in_sector[None, :], 0.0, 1.0) -def test_horizon_frame_defaults_to_beam(): +def _asymmetric_lobe(): + """A beam with a lobe along its x axis (phi = 0).""" + theta, phi = _mwss_grid() + cos_phi = np.cos(np.deg2rad(phi))[None, :] + sin_theta = np.sin(np.deg2rad(theta))[:, None] + return jnp.asarray((1.0 + 0.8 * cos_phi * sin_theta)[None]) + + +def test_horizon_frame_defaults_to_topocentric(): data = jnp.ones((1, 181, 360)) beam = Beam(data, jnp.array([50.0])) - assert beam.horizon_frame == "beam" + assert beam.horizon_frame == "topocentric" def test_horizon_frame_rejects_unknown_value(): @@ -91,12 +99,12 @@ def test_horizon_frame_rejects_unknown_value(): @pytest.mark.parametrize("beam_az_rot", [0.0, 40.0, 233.0]) -def test_topocentric_mask_stays_on_the_ground(beam_az_rot): +def test_default_mask_stays_on_the_ground(beam_az_rot): """A ground-fixed sector lands at the same compass azimuth. A beam-grid column phi_b points to compass azimuth - A = beam_az_rot - phi_b, so the weights applied in the beam frame - must equal the ground mask read at that azimuth. + A = beam_az_rot - phi_b, which is ground column -A, so the weights + applied in the beam frame must equal the ground mask read there. """ _, phi = _mwss_grid() mask = _ground_sector_mask() @@ -105,11 +113,10 @@ def test_topocentric_mask_stays_on_the_ground(beam_az_rot): jnp.array([50.0]), horizon=jnp.asarray(mask), beam_az_rot=beam_az_rot, - horizon_frame="topocentric", ) applied = np.asarray(beam.horizon_in_beam_frame) azimuth = np.mod(beam_az_rot - phi, 360.0) - ground_col = np.mod(90.0 - azimuth, 360.0).astype(int) + ground_col = np.mod(-azimuth, 360.0).astype(int) np.testing.assert_array_equal(applied, mask[:, ground_col]) # the blocked columns are the East sector, whatever the rotation blocked = azimuth[(applied == 0).any(axis=0)] @@ -118,7 +125,7 @@ def test_topocentric_mask_stays_on_the_ground(beam_az_rot): def test_beam_frame_mask_rotates_with_the_beam(): - """The default frame applies the mask as given, at any rotation.""" + """horizon_frame="beam" applies the mask as given, at any rotation.""" mask = _ground_sector_mask() for beam_az_rot in (0.0, 40.0): beam = Beam( @@ -126,42 +133,93 @@ def test_beam_frame_mask_rotates_with_the_beam(): jnp.array([50.0]), horizon=jnp.asarray(mask), beam_az_rot=beam_az_rot, + horizon_frame="beam", ) np.testing.assert_array_equal( np.asarray(beam.horizon_in_beam_frame), mask ) +def test_frames_agree_at_zero_rotation_mwss(): + """At beam_az_rot = 0 the new default changes nothing.""" + data = _asymmetric_lobe() + mask = jnp.asarray(_ground_sector_mask()) + topo = Beam(data, jnp.array([50.0]), horizon=mask) + old = Beam(data, jnp.array([50.0]), horizon=mask, horizon_frame="beam") + np.testing.assert_array_equal( + np.asarray(topo.horizon_in_beam_frame), + np.asarray(old.horizon_in_beam_frame), + ) + np.testing.assert_array_equal( + np.asarray(topo.compute_fgnd()), np.asarray(old.compute_fgnd()) + ) + + +def test_frames_agree_at_zero_rotation_healpix(): + """The 90-deg grid shift is exact on every HEALPix ring.""" + nside = 8 + npix = 12 * nside**2 + rng = np.random.default_rng(0) + mask = jnp.asarray(rng.uniform(size=npix)) + data = jnp.ones((1, npix)) + topo = Beam(data, jnp.array([50.0]), sampling="healpix", horizon=mask) + old = Beam( + data, + jnp.array([50.0]), + sampling="healpix", + horizon=mask, + horizon_frame="beam", + ) + np.testing.assert_allclose( + np.asarray(topo.horizon_in_beam_frame), + np.asarray(old.horizon_in_beam_frame), + rtol=0, + atol=1e-15, + ) + + +@pytest.mark.parametrize("beam_az_rot", [0.0, 40.0]) +def test_theta_only_mask_is_frame_independent(beam_az_rot): + """The pipeline's theta-only masks behave the same in both frames.""" + theta, _ = _mwss_grid() + mask = jnp.asarray((theta <= 80.0)[:, None].astype(float)) + kw = dict(horizon=mask, beam_az_rot=beam_az_rot) + topo = Beam(_asymmetric_lobe(), jnp.array([50.0]), **kw) + old = Beam( + _asymmetric_lobe(), jnp.array([50.0]), horizon_frame="beam", **kw + ) + np.testing.assert_array_equal( + np.asarray(topo.compute_fgnd()), np.asarray(old.compute_fgnd()) + ) + + def test_topocentric_fgnd_matches_manual_counter_rotation(): - """An asymmetric beam sees the ground mask the caller would build. + """An asymmetric beam sees the ground mask a caller would build. With a lobe on the beam's x axis, the blocked fraction depends on where the lobe points relative to the ground-fixed sector. The - topocentric result must equal the beam-frame result with a mask - counter-rotated by hand. + default (topocentric) result must equal the beam-frame result with + the mask counter-rotated by hand. """ - theta, phi = _mwss_grid() - cos_phi = np.cos(np.deg2rad(phi))[None, :] - sin_theta = np.sin(np.deg2rad(theta))[:, None] - lobe = 1.0 + 0.8 * cos_phi * sin_theta - data = jnp.asarray(lobe[None]) + _, phi = _mwss_grid() + data = _asymmetric_lobe() mask = _ground_sector_mask() fgnd = [] for beam_az_rot in (0.0, 90.0): azimuth = np.mod(beam_az_rot - phi, 360.0) - manual = mask[:, np.mod(90.0 - azimuth, 360.0).astype(int)] + manual = mask[:, np.mod(-azimuth, 360.0).astype(int)] topo = Beam( data, jnp.array([50.0]), horizon=jnp.asarray(mask), beam_az_rot=beam_az_rot, - horizon_frame="topocentric", ) by_hand = Beam( data, jnp.array([50.0]), horizon=jnp.asarray(manual), beam_az_rot=beam_az_rot, + horizon_frame="beam", ) f_topo = float(np.asarray(topo.compute_fgnd()).ravel()[0]) f_hand = float(np.asarray(by_hand.compute_fgnd()).ravel()[0])