From 95f5ee97fa387845b2b0c2f7f6895dc555143ee6 Mon Sep 17 00:00:00 2001 From: Florian Pfaff <6773539+FlorianPfaff@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:30:36 +0800 Subject: [PATCH 1/5] Fix symmetric mixture handling in hemisphere updates --- .../filters/hyperhemispherical_grid_filter.py | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/pyrecest/filters/hyperhemispherical_grid_filter.py b/src/pyrecest/filters/hyperhemispherical_grid_filter.py index 44ee345f6..5df92e122 100644 --- a/src/pyrecest/filters/hyperhemispherical_grid_filter.py +++ b/src/pyrecest/filters/hyperhemispherical_grid_filter.py @@ -196,7 +196,8 @@ def update_identity(self, meas_noise, z): Supported noise: :class:`HyperhemisphericalWatsonDistribution`, :class:`WatsonDistribution`, :class:`VonMisesFisherDistribution` - for measurements on the equator, symmetric :class:`HypersphericalMixture`. + for measurements on the equator, symmetric two-component + :class:`HypersphericalMixture` of VMF distributions. Parameters ---------- @@ -233,9 +234,22 @@ def update_identity(self, meas_noise, z): isinstance(meas_noise, HypersphericalMixture) and len(meas_noise.dists) == 2 and all(abs(w - 0.5) < 1e-12 for w in meas_noise.w) + and all( + isinstance(dist, VonMisesFisherDistribution) + for dist in meas_noise.dists + ) + and allclose( + meas_noise.dists[0].mu, -meas_noise.dists[1].mu, atol=1e-12 + ) + and meas_noise.dists[0].kappa == meas_noise.dists[1].kappa ): - meas_noise.dists[0].mu = z - meas_noise.dists[1].mu = -z + meas_noise = HypersphericalMixture( + [ + meas_noise.dists[0].set_mode(z), + meas_noise.dists[1].set_mode(-z), + ], + meas_noise.w, + ) else: raise ValueError( "UpdateIdentity:UnsupportedNoise: unsupported measurement noise type." @@ -333,4 +347,4 @@ def trans_cp(grid, _grid): fun_does_cartesian_product=True, grid_type="leopardi_symm", dim=product_dim, - ) + ) \ No newline at end of file From f488b9ba4c0e16b05df4fba92e9d959387ba1e9b Mon Sep 17 00:00:00 2001 From: Florian Pfaff <6773539+FlorianPfaff@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:30:53 +0800 Subject: [PATCH 2/5] Add regressions for symmetric hemisphere mixture updates --- ...emispherical_grid_filter_mixture_update.py | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/filters/test_hyperhemispherical_grid_filter_mixture_update.py diff --git a/tests/filters/test_hyperhemispherical_grid_filter_mixture_update.py b/tests/filters/test_hyperhemispherical_grid_filter_mixture_update.py new file mode 100644 index 000000000..b73fc2cac --- /dev/null +++ b/tests/filters/test_hyperhemispherical_grid_filter_mixture_update.py @@ -0,0 +1,78 @@ +import unittest + +import pyrecest.backend +from pyrecest.backend import allclose, array +from pyrecest.distributions.hypersphere_subset.hyperspherical_mixture import ( + HypersphericalMixture, +) +from pyrecest.distributions.hypersphere_subset.von_mises_fisher_distribution import ( + VonMisesFisherDistribution, +) +from pyrecest.filters.hyperhemispherical_grid_filter import ( + HyperhemisphericalGridFilter, +) + + +@unittest.skipIf( + pyrecest.backend.__backend_name__ == "jax", # pylint: disable=no-member + reason="HyperhemisphericalGridFilter is not supported on the JAX backend", +) +class TestHyperhemisphericalGridFilterMixtureUpdate(unittest.TestCase): + def setUp(self): + self.pole = array([0.0, 0.0, 1.0]) + self.measurement = array([1.0, 0.0, 0.0]) + + def _symmetric_vmf_mixture(self, kappa=3.0): + return HypersphericalMixture( + [ + VonMisesFisherDistribution(self.pole, kappa), + VonMisesFisherDistribution(-self.pole, kappa), + ], + array([0.5, 0.5]), + ) + + def test_update_identity_does_not_mutate_measurement_noise(self): + filt = HyperhemisphericalGridFilter(20, 2) + meas_noise = self._symmetric_vmf_mixture() + + filt.update_identity(meas_noise, self.measurement) + + self.assertTrue(bool(allclose(meas_noise.dists[0].mu, self.pole))) + self.assertTrue(bool(allclose(meas_noise.dists[1].mu, -self.pole))) + + def test_update_identity_rejects_unequal_vmf_concentrations(self): + filt = HyperhemisphericalGridFilter(20, 2) + meas_noise = HypersphericalMixture( + [ + VonMisesFisherDistribution(self.pole, 2.0), + VonMisesFisherDistribution(-self.pole, 3.0), + ], + array([0.5, 0.5]), + ) + + with self.assertRaisesRegex(ValueError, "UnsupportedNoise"): + filt.update_identity(meas_noise, self.measurement) + + def test_update_identity_rejects_nonantipodal_vmf_components(self): + filt = HyperhemisphericalGridFilter(20, 2) + meas_noise = HypersphericalMixture( + [ + VonMisesFisherDistribution(self.pole, 3.0), + VonMisesFisherDistribution(array([1.0, 0.0, 0.0]), 3.0), + ], + array([0.5, 0.5]), + ) + + with self.assertRaisesRegex(ValueError, "UnsupportedNoise"): + filt.update_identity(meas_noise, self.measurement) + + def test_update_identity_rejects_nonunit_measurement_for_mixture(self): + filt = HyperhemisphericalGridFilter(20, 2) + meas_noise = self._symmetric_vmf_mixture() + + with self.assertRaisesRegex(ValueError, "normalized"): + filt.update_identity(meas_noise, array([2.0, 0.0, 0.0])) + + +if __name__ == "__main__": + unittest.main() From 04df204856befcccdca3dd4e256241e3da38efdc Mon Sep 17 00:00:00 2001 From: Florian Pfaff <6773539+FlorianPfaff@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:31:51 +0800 Subject: [PATCH 3/5] Reject non-vMF mixtures in hemisphere prediction --- src/pyrecest/filters/hyperhemispherical_grid_filter.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pyrecest/filters/hyperhemispherical_grid_filter.py b/src/pyrecest/filters/hyperhemispherical_grid_filter.py index 5df92e122..ddc58b520 100644 --- a/src/pyrecest/filters/hyperhemispherical_grid_filter.py +++ b/src/pyrecest/filters/hyperhemispherical_grid_filter.py @@ -297,7 +297,8 @@ def sys_noise_to_transition_density(d_sys, no_grid_points): ---------- d_sys : AbstractDistribution Supported: :class:`HyperhemisphericalWatsonDistribution`, - :class:`WatsonDistribution`, symmetric :class:`HypersphericalMixture`. + :class:`WatsonDistribution`, symmetric two-component + :class:`HypersphericalMixture` of VMF distributions. no_grid_points : int Number of grid points on the hemisphere. @@ -323,6 +324,9 @@ def trans_cp(grid, _grid): isinstance(d_sys, HypersphericalMixture) and len(d_sys.dists) == 2 and all(abs(w - 0.5) < 1e-12 for w in d_sys.w) + and all( + isinstance(dist, VonMisesFisherDistribution) for dist in d_sys.dists + ) and allclose(d_sys.dists[0].mu, -d_sys.dists[1].mu, atol=1e-12) and d_sys.dists[0].kappa == d_sys.dists[1].kappa ): From 35ebfa517266fb50464d3807ee152fe20ef2290b Mon Sep 17 00:00:00 2001 From: Florian Pfaff <6773539+FlorianPfaff@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:32:11 +0800 Subject: [PATCH 4/5] Cover non-vMF hemisphere prediction mixtures --- ...perhemispherical_grid_filter_mixture_update.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/filters/test_hyperhemispherical_grid_filter_mixture_update.py b/tests/filters/test_hyperhemispherical_grid_filter_mixture_update.py index b73fc2cac..006cd101c 100644 --- a/tests/filters/test_hyperhemispherical_grid_filter_mixture_update.py +++ b/tests/filters/test_hyperhemispherical_grid_filter_mixture_update.py @@ -8,6 +8,9 @@ from pyrecest.distributions.hypersphere_subset.von_mises_fisher_distribution import ( VonMisesFisherDistribution, ) +from pyrecest.distributions.hypersphere_subset.watson_distribution import ( + WatsonDistribution, +) from pyrecest.filters.hyperhemispherical_grid_filter import ( HyperhemisphericalGridFilter, ) @@ -73,6 +76,18 @@ def test_update_identity_rejects_nonunit_measurement_for_mixture(self): with self.assertRaisesRegex(ValueError, "normalized"): filt.update_identity(meas_noise, array([2.0, 0.0, 0.0])) + def test_prediction_rejects_non_vmf_symmetric_mixture(self): + d_sys = HypersphericalMixture( + [ + WatsonDistribution(self.pole, 3.0), + WatsonDistribution(-self.pole, 3.0), + ], + array([0.5, 0.5]), + ) + + with self.assertRaisesRegex(ValueError, "unsupported distribution"): + HyperhemisphericalGridFilter.sys_noise_to_transition_density(d_sys, 20) + if __name__ == "__main__": unittest.main() From 720f8e4e376065d67d60bb703ec5d626fbc13ad8 Mon Sep 17 00:00:00 2001 From: Florian Pfaff <6773539+FlorianPfaff@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:33:04 +0800 Subject: [PATCH 5/5] Format hemisphere mixture guards --- src/pyrecest/filters/hyperhemispherical_grid_filter.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pyrecest/filters/hyperhemispherical_grid_filter.py b/src/pyrecest/filters/hyperhemispherical_grid_filter.py index ddc58b520..9f45ad215 100644 --- a/src/pyrecest/filters/hyperhemispherical_grid_filter.py +++ b/src/pyrecest/filters/hyperhemispherical_grid_filter.py @@ -325,7 +325,8 @@ def trans_cp(grid, _grid): and len(d_sys.dists) == 2 and all(abs(w - 0.5) < 1e-12 for w in d_sys.w) and all( - isinstance(dist, VonMisesFisherDistribution) for dist in d_sys.dists + isinstance(dist, VonMisesFisherDistribution) + for dist in d_sys.dists ) and allclose(d_sys.dists[0].mu, -d_sys.dists[1].mu, atol=1e-12) and d_sys.dists[0].kappa == d_sys.dists[1].kappa @@ -351,4 +352,4 @@ def trans_cp(grid, _grid): fun_does_cartesian_product=True, grid_type="leopardi_symm", dim=product_dim, - ) \ No newline at end of file + )