Skip to content

Commit b3af008

Browse files
authored
Added zonal fast basis generator (#8)
* Added zonal fast basis generator * fixes for ci
1 parent ccd6726 commit b3af008

6 files changed

Lines changed: 287 additions & 14 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
runs-on: ubuntu-latest
1212
strategy:
1313
matrix:
14-
python-version: ["3.8","3.9", "3.10", "3.11", "3.12"]
14+
python-version: ["3.8","3.9", "3.10", "3.11", "3.12", "3.13"]
1515

1616
steps:
1717
- uses: actions/checkout@v3

README.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ A Python package for generating various modal basis sets for Adaptive Optics (AO
99
- **Zernike Polynomials**: Standard optical aberration modes (Noll indexing).
1010
- **Fourier Modes**: Sinusoidal basis sets.
1111
- **Zonal Basis**: Single actuator pokes (Identity).
12+
- **Zonal Fast Basis**: Distance-constrained grouped actuator pokes for faster calibration sweeps.
1213
- **Hadamard Basis**: Orthogonal binary patterns for calibration.
1314
- **Flexible Geometry**: Works with arbitrary actuator positions (defaulting to circular grids).
1415
- **Piston Removal**: Option to exclude piston/DC modes from generation.
@@ -107,6 +108,27 @@ kl_gen.plot(count=6, title_prefix="KL Mode")
107108
kl_gen.save("my_kl_basis.npz")
108109
```
109110

111+
## Zonal Fast Basis
112+
113+
`ZonalFastBasisGenerator` groups actuators into binary poke patterns such that no two actuators in the same mode are closer than a user-defined distance `D`. This is useful when you want a compact calibration basis that reduces the number of measurements compared with pure zonal pokes.
114+
115+
```python
116+
from aobasis import ZonalFastBasisGenerator, make_circular_actuator_grid
117+
118+
positions = make_circular_actuator_grid(telescope_diameter=10.0, grid_size=20)
119+
120+
# Distance threshold in the same units as the actuator coordinates.
121+
zonal_fast_gen = ZonalFastBasisGenerator(positions, min_distance=0.8)
122+
123+
# Omitting n_modes returns the full grouped basis.
124+
zonal_fast_modes = zonal_fast_gen.generate()
125+
print(zonal_fast_modes.shape)
126+
127+
zonal_fast_gen.plot(count=min(12, zonal_fast_modes.shape[1]), title_prefix="Zonal Fast")
128+
```
129+
130+
The returned matrix still has the standard `(n_actuators, n_modes)` layout, but each column is now a sparse binary pattern rather than a single-actuator poke. Every actuator appears in exactly one column of the full basis.
131+
110132
## Performance
111133

112134
Generation times for 100 modes benchmarked on the following system:
@@ -121,6 +143,7 @@ Generation times for 100 modes benchmarked on the following system:
121143
| **Zernike** | 0.001s | 0.002s | 0.005s |
122144
| **Fourier** | <0.001s | 0.001s | 0.003s |
123145
| **Zonal** | <0.001s | <0.001s | 0.003s |
146+
| **Zonal Fast** | depends on spacing threshold | depends on spacing threshold | depends on spacing threshold |
124147
| **Hadamard** | <0.001s | 0.001s | 0.031s |
125148

126149
*Note: KL basis generation is computationally intensive ($O(N^3)$) due to the dense covariance matrix diagonalization. GPU acceleration provides significant speedup (8-15x) for larger grids.*
@@ -129,7 +152,7 @@ Generation times for 100 modes benchmarked on the following system:
129152

130153
We provide Jupyter notebooks to help you get started.
131154

132-
1. **Getting Started**: `tutorials/getting_started.ipynb` covers all supported basis types and features.
155+
1. **Getting Started**: `tutorials/getting_started.ipynb` covers all supported basis types, including zonal fast grouped pokes.
133156

134157
To run the tutorials:
135158
```bash
@@ -171,7 +194,7 @@ If you encounter any bugs or have feature requests, please file an issue on the
171194
For questions or support, please contact:
172195

173196
**User Name**
174-
Email: jtaylor@keck.hawaii.edu
197+
Email: jacobataylor7@gmail.com
175198

176199
## License
177200

src/aobasis/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from .kl import KLBasisGenerator
1010
from .zernike import ZernikeBasisGenerator
1111
from .fourier import FourierBasisGenerator
12-
from .zonal import ZonalBasisGenerator
12+
from .zonal import ZonalBasisGenerator, ZonalFastBasisGenerator
1313
from .hadamard import HadamardBasisGenerator
1414
from .utils import make_circular_actuator_grid, make_concentric_actuator_grid, plot_basis_modes
1515

@@ -19,6 +19,7 @@
1919
"ZernikeBasisGenerator",
2020
"FourierBasisGenerator",
2121
"ZonalBasisGenerator",
22+
"ZonalFastBasisGenerator",
2223
"HadamardBasisGenerator",
2324
"make_circular_actuator_grid",
2425
"make_concentric_actuator_grid",

src/aobasis/zonal.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1+
from typing import List, Optional, Set
2+
13
import numpy as np
4+
from scipy.spatial import cKDTree
5+
26
from .base import BasisGenerator
37

48
class ZonalBasisGenerator(BasisGenerator):
@@ -24,3 +28,124 @@ def generate(self, n_modes: int, **kwargs) -> np.ndarray:
2428

2529
self.modes = full_basis[:, :n_modes]
2630
return self.modes
31+
32+
33+
def _build_conflict_graph(positions: np.ndarray, min_distance: float) -> List[Set[int]]:
34+
n_actuators = positions.shape[0]
35+
adjacency = [set() for _ in range(n_actuators)]
36+
37+
if n_actuators == 0 or min_distance <= 0:
38+
return adjacency
39+
40+
tree = cKDTree(positions)
41+
search_radius = np.nextafter(min_distance, 0.0)
42+
for first, second in tree.query_pairs(r=search_radius, output_type="ndarray"):
43+
adjacency[int(first)].add(int(second))
44+
adjacency[int(second)].add(int(first))
45+
46+
return adjacency
47+
48+
49+
def _dsatur_coloring(adjacency: List[Set[int]]) -> np.ndarray:
50+
n_vertices = len(adjacency)
51+
colors = np.full(n_vertices, -1, dtype=int)
52+
neighbor_colors = [set() for _ in range(n_vertices)]
53+
degrees = np.array([len(neighbors) for neighbors in adjacency], dtype=int)
54+
55+
for _ in range(n_vertices):
56+
uncolored = np.flatnonzero(colors < 0)
57+
if uncolored.size == 0:
58+
break
59+
60+
saturation = np.array([len(neighbor_colors[index]) for index in uncolored], dtype=int)
61+
candidate_order = np.lexsort((-degrees[uncolored], -saturation))
62+
vertex = int(uncolored[candidate_order[0]])
63+
64+
used = neighbor_colors[vertex]
65+
color = 0
66+
while color in used:
67+
color += 1
68+
69+
colors[vertex] = color
70+
for neighbor in adjacency[vertex]:
71+
if colors[neighbor] < 0:
72+
neighbor_colors[neighbor].add(color)
73+
74+
return colors
75+
76+
77+
def compute_zonal_fast_basis(positions: np.ndarray, min_distance: float) -> np.ndarray:
78+
"""
79+
Compute a distance-constrained zonal basis.
80+
81+
Each returned mode is a binary poke pattern. Actuators that are closer than
82+
``min_distance`` cannot appear in the same mode, so the basis is built by
83+
coloring the actuator conflict graph and turning each color into one column
84+
of the returned matrix.
85+
86+
Args:
87+
positions: ``(n_actuators, 2)`` array of actuator coordinates.
88+
min_distance: Minimum allowed pairwise distance within a mode.
89+
90+
Returns:
91+
``(n_actuators, n_modes)`` matrix of binary zonal-fast modes.
92+
"""
93+
positions = np.asarray(positions, dtype=float)
94+
if positions.ndim != 2 or positions.shape[1] != 2:
95+
raise ValueError("positions must have shape (n_actuators, 2).")
96+
if min_distance < 0:
97+
raise ValueError("min_distance must be non-negative.")
98+
if positions.shape[0] == 0:
99+
return np.zeros((0, 0), dtype=float)
100+
101+
adjacency = _build_conflict_graph(positions, min_distance)
102+
colors = _dsatur_coloring(adjacency)
103+
n_modes = int(colors.max()) + 1
104+
105+
basis = np.zeros((positions.shape[0], n_modes), dtype=float)
106+
basis[np.arange(positions.shape[0]), colors] = 1.0
107+
return basis
108+
109+
110+
class ZonalFastBasisGenerator(BasisGenerator):
111+
"""
112+
Generate grouped zonal poke patterns separated by a minimum distance.
113+
114+
A full zonal-fast basis covers every actuator exactly once while using a
115+
compact coloring of the actuator conflict graph.
116+
"""
117+
118+
def __init__(self, positions: np.ndarray, min_distance: float):
119+
super().__init__(positions)
120+
if min_distance < 0:
121+
raise ValueError("min_distance must be non-negative.")
122+
self.min_distance = float(min_distance)
123+
self.full_modes: Optional[np.ndarray] = None
124+
125+
def generate(self, n_modes: Optional[int] = None, **kwargs) -> np.ndarray:
126+
"""
127+
Generate zonal-fast modes.
128+
129+
Args:
130+
n_modes: Number of grouped poke modes to return. If omitted, return
131+
the full distance-constrained basis.
132+
133+
Returns:
134+
``(n_actuators, n_modes)`` matrix of binary grouped poke patterns.
135+
"""
136+
full_basis = compute_zonal_fast_basis(self.positions, self.min_distance)
137+
self.full_modes = full_basis
138+
139+
if n_modes is None:
140+
self.modes = full_basis
141+
return self.modes
142+
143+
if n_modes < 0:
144+
raise ValueError("n_modes must be non-negative.")
145+
if n_modes > full_basis.shape[1]:
146+
raise ValueError(
147+
f"Cannot generate {n_modes} zonal-fast modes; full basis only contains {full_basis.shape[1]} modes."
148+
)
149+
150+
self.modes = full_basis[:, :n_modes]
151+
return self.modes

tests/test_generators.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,22 @@
55
ZernikeBasisGenerator,
66
FourierBasisGenerator,
77
ZonalBasisGenerator,
8+
ZonalFastBasisGenerator,
89
HadamardBasisGenerator,
910
make_circular_actuator_grid
1011
)
1112

13+
14+
def assert_min_spacing_within_modes(positions, modes, min_distance):
15+
for mode_index in range(modes.shape[1]):
16+
active = positions[modes[:, mode_index] > 0.5]
17+
if active.shape[0] < 2:
18+
continue
19+
deltas = active[:, None, :] - active[None, :, :]
20+
distances = np.linalg.norm(deltas, axis=-1)
21+
upper_triangle = distances[np.triu_indices(active.shape[0], k=1)]
22+
assert np.all(upper_triangle >= min_distance - 1e-12)
23+
1224
@pytest.fixture
1325
def grid():
1426
return make_circular_actuator_grid(telescope_diameter=10.0, grid_size=10)
@@ -184,6 +196,65 @@ def test_zonal_all_modes(small_grid):
184196
assert modes.shape == (small_grid.shape[0], small_grid.shape[0])
185197
assert np.allclose(modes, np.eye(small_grid.shape[0]))
186198

199+
def test_zonal_fast_generation_path_graph():
200+
positions = np.array([
201+
[0.0, 0.0],
202+
[1.0, 0.0],
203+
[2.0, 0.0],
204+
])
205+
206+
gen = ZonalFastBasisGenerator(positions, min_distance=1.1)
207+
modes = gen.generate()
208+
209+
assert modes.shape == (3, 2)
210+
assert np.allclose(modes.sum(axis=1), 1.0)
211+
assert np.allclose(modes.T @ modes, np.diag(np.diag(modes.T @ modes)))
212+
assert_min_spacing_within_modes(positions, modes, min_distance=1.1)
213+
214+
def test_zonal_fast_generation_clique():
215+
positions = np.array([
216+
[0.0, 0.0],
217+
[1.0, 0.0],
218+
[0.5, np.sqrt(3.0) / 2.0],
219+
])
220+
221+
gen = ZonalFastBasisGenerator(positions, min_distance=1.01)
222+
modes = gen.generate()
223+
224+
assert modes.shape == (3, 3)
225+
assert np.allclose(modes, np.eye(3))
226+
227+
def test_zonal_fast_single_mode_when_distance_is_small():
228+
positions = np.array([
229+
[0.0, 0.0],
230+
[1.0, 0.0],
231+
[2.0, 0.0],
232+
[3.0, 0.0],
233+
])
234+
235+
gen = ZonalFastBasisGenerator(positions, min_distance=0.5)
236+
modes = gen.generate()
237+
238+
assert modes.shape == (4, 1)
239+
assert np.allclose(modes[:, 0], np.ones(4))
240+
241+
def test_zonal_fast_mode_subset_and_errors():
242+
positions = np.array([
243+
[0.0, 0.0],
244+
[1.0, 0.0],
245+
[2.0, 0.0],
246+
])
247+
248+
gen = ZonalFastBasisGenerator(positions, min_distance=1.1)
249+
subset = gen.generate(n_modes=1)
250+
assert subset.shape == (3, 1)
251+
252+
with pytest.raises(ValueError, match="full basis only contains"):
253+
gen.generate(n_modes=3)
254+
255+
with pytest.raises(ValueError, match="non-negative"):
256+
ZonalFastBasisGenerator(positions, min_distance=-0.1)
257+
187258
def test_hadamard_generation(grid):
188259
gen = HadamardBasisGenerator(grid)
189260
modes = gen.generate(n_modes=8)

tutorials/getting_started.ipynb

Lines changed: 63 additions & 10 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)