Skip to content

Commit 3ff41c8

Browse files
author
nrodlin
committed
Merge branch 'develop'
2 parents 56d4242 + 213233f commit 3ff41c8

67 files changed

Lines changed: 1974 additions & 441 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/gh-pages.yml‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: Deploy GitHub Pages
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
deploy:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- name: Configure Git Credentials
18+
run: |
19+
git config user.name github-actions[bot]
20+
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
21+
22+
- uses: actions/setup-python@v5
23+
with:
24+
# Use standard (non-free-threaded) Python — cffi is incompatible with 3.13t
25+
python-version: '3.12'
26+
27+
- name: Cache pip dependencies
28+
uses: actions/cache@v4
29+
with:
30+
key: mkdocs-${{ hashFiles('requirements.txt') }}
31+
path: ~/.cache/pip
32+
restore-keys: |
33+
mkdocs-
34+
35+
- name: Install documentation dependencies
36+
run: |
37+
python -m pip install --upgrade pip
38+
pip install mkdocs mkdocs-material mkdocs-jupyter mkdocstrings[python] ruff
39+
40+
- name: Install SAOS and its dependencies
41+
run: |
42+
# Install numpy, scipy, torch (CPU-only, lightweight) so mkdocstrings can import SAOS
43+
pip install numpy scipy opencv-contrib-python joblib
44+
pip install torch --index-url https://download.pytorch.org/whl/cpu
45+
pip install -e . --no-deps
46+
47+
- name: Deploy to GitHub Pages
48+
run: mkdocs gh-deploy --force
49+

‎README.md‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,31 @@
44

55
SAOS modifies the internal architecture of OOPAO, which was heavily managed through the telescope class, to offer a framework centered on lines of sight, implemented via the **LightPath** class. This architectural shift enables easier and more powerful parallelization, essential for solar AO simulations where the Field of View (FoV) is divided into multiple sub-directions to physically introduce extended field effects into the simulation.
66

7-
SAOS is currently in the testing phase of the first release. All development is ongoing in the **tech-development** branch. We recommend waiting a little longer before using SAOS in your work!
7+
A key feature of SAOS is that it is a **Python-only** repository. All parallelization is managed via **joblib** and threads, leveraging the new **free-threaded Python 3.13** to enable extensive and straightforward parallelization. Pytorch is extensively used in the code for costly operations, being ready for an upgrade to a GPU version.
88

9-
A key feature of SAOS is that it is a **Python-only** repository. All parallelization is managed via **joblib** and threads, leveraging the new **free-threaded Python 3.13** to enable extensive and straightforward parallelization. Pytorch is extensively used in the code for costly operations, being ready to an upgrade to a GPU version.
9+
---
1010

11-
# Guidelines
11+
## 📖 Documentation
1212

13-
- If you are primarily a night-time AO user and do not require multi-conjugate AO (MCAO) or you need a pyramid WFS simulations, we recommend using **OOPAO** instead. See: [OOPAO AO4ELT7 Proceedings (Héritier et al. 2023)](https://hal.science/AO4ELT7/hal-04402878v1).
13+
Full documentation — installation guides, pedagogical tutorials, and API reference — is available at:
14+
15+
**➡️ [https://nrodlin.github.io/SAOS/](https://nrodlin.github.io/SAOS/)**
16+
17+
To build the documentation locally:
18+
```bash
19+
pip install mkdocs mkdocs-material mkdocs-jupyter mkdocstrings[python]
20+
mkdocs serve
21+
```
22+
23+
## ⚙️ Installation
24+
25+
For optimal performance, we strongly recommend using a free-threaded Python environment (e.g., Python 3.13.2t).
26+
A quick installation snippet is provided below. For detailed instructions, please check the [Installation Guide](docs/installation.md).
27+
28+
```bash
29+
git clone https://github.com/nrodlin/SAOS.git
30+
cd SAOS
31+
git checkout develop
32+
pip install -e .
33+
```
1434

15-
- If you need to simulate multiple lines of sight or perform Solar AO simulations, **SAOS** is the repository for you.
16-
We have not yet published a full article describing SAOS, but it is in preparation —please keep an eye on the repository for updates!

‎SAOS/Atmosphere.py‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from joblib import Parallel, delayed
2020

2121
import numpy as np
22-
import math
22+
2323
import torch
2424

2525
# Self dependencies
@@ -135,13 +135,11 @@ def __init__(self,
135135

136136
def initializeAtmosphere(self, randomState=None):
137137
"""
138-
Initialize the atmosphere layers and associate them with a telescope.
138+
Initialize the atmosphere layers using the telescope linked at construction time.
139139
140140
Parameters
141141
----------
142-
telescope : Telescope or None
143-
Telescope object to derive spatial and temporal resolution.
144-
randomState : int or None
142+
randomState : int or None, optional
145143
Seed for reproducible random number generation, by default None.
146144
147145
Returns

‎SAOS/Controller.py‎

Lines changed: 103 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import numpy as np
22
import torch
33

4-
import h5py
4+
55
import time
66

77
import logging
@@ -66,9 +66,12 @@ def __init__(self,
6666
# Default will change to list of size nDMs once the IM is scanned
6767
self.rcond = kwargs.get('rcond', 0.025)
6868
self.beta = kwargs.get('beta', 1e-4) # adim, adjusted through trial-error
69+
70+
# Mask provided by the user to select specific WFS-DM links
71+
self.control_mask = kwargs.get('control_mask', None)
6972

7073
# Run the initialization of the reconstructor
71-
self.reconstructor, self.modal_basis, self.mask, self.altitude = self.initializeReconstructor(self.reconstructionMethod, interactionMatrix)
74+
self.reconstructor, self.modal_basis, self.mask, self.discarded_modes = self.initializeReconstructor(self.reconstructionMethod, interactionMatrix)
7275

7376
# Setup the controller
7477

@@ -103,6 +106,27 @@ def __init__(self,
103106
self.initializeController(self.controllerType, self.reconstructor)
104107

105108
def initializeReconstructor(self, reconstructionMethod, interactionMatrix):
109+
"""
110+
Initialize the reconstructor matrix from the measured interaction matrices.
111+
112+
Parameters
113+
----------
114+
reconstructionMethod : str
115+
Type of reconstructor ('inversion' or 'tikhonov').
116+
interactionMatrix : InteractionMatrixHandler
117+
Object containing the measured interaction matrices and modal basis.
118+
119+
Returns
120+
-------
121+
reconstructor : list
122+
List of reconstructor matrices per DM.
123+
modal_basis : list
124+
List of modal basis per DM.
125+
mask : np.ndarray
126+
Boolean mask indicating interactions between DMs and light paths.
127+
discarded_modes : list
128+
List of number of discarded modes per DM.
129+
"""
106130
self.logger.info('Controller::initializeReconstructor - Computing the reconstructor.')
107131
t0 = time.time()
108132

@@ -127,6 +151,20 @@ def initializeReconstructor(self, reconstructionMethod, interactionMatrix):
127151
if interactionMatrix.interaction_matrix_warehouse[i][j]['IM'] is not None:
128152
mask[i, j] = True
129153

154+
if hasattr(self, 'control_mask') and self.control_mask is not None:
155+
# Check dimensions
156+
control_mask_arr = np.array(self.control_mask, dtype=bool)
157+
if control_mask_arr.shape != (nDMs, nLPs):
158+
self.logger.error(f'Controller - control_mask shape must be ({nDMs}, {nLPs})')
159+
raise ValueError(f'control_mask shape mismatch. Expected ({nDMs}, {nLPs}), got {control_mask_arr.shape}')
160+
161+
# Warn if user requests control where no IM exists
162+
invalid_requests = control_mask_arr & (~mask)
163+
if np.any(invalid_requests):
164+
self.logger.warning('Controller - control_mask requests control for DM/LP pairs without an interaction matrix. These will be ignored.')
165+
166+
mask = mask & control_mask_arr
167+
130168
# Check the reconstructor parameters
131169
if reconstructionMethod == 'inversion':
132170
if isinstance(self.rcond, list):
@@ -154,10 +192,15 @@ def initializeReconstructor(self, reconstructionMethod, interactionMatrix):
154192
modal_basis_type = interactionMatrix.interaction_matrix_warehouse[i][j]['modalBasis']
155193
modal_basis.append(torch.as_tensor(interactionMatrix.modal_basis[i][modal_basis_type], dtype=torch.float64, device=self.device))
156194
break
157-
# Get altitudes:
158-
altitude = []
159-
for i in range(len(interactionMatrix.dm_scanned_list)):
160-
altitude.append(interactionMatrix.dm_scanned_list[i].altitude)
195+
# Get discarded modes metadata:
196+
discarded_modes = []
197+
for i in range(nDMs):
198+
found_discarded_modes = 0
199+
for j in range(nLPs):
200+
if interactionMatrix.interaction_matrix_warehouse[i][j]['IM'] is not None:
201+
found_discarded_modes = interactionMatrix.interaction_matrix_warehouse[i][j].get('discarded_modes', 0)
202+
break
203+
discarded_modes.append(found_discarded_modes)
161204

162205
# Now, define the reconstruction matrices for each DM
163206

@@ -170,26 +213,48 @@ def initializeReconstructor(self, reconstructionMethod, interactionMatrix):
170213
# Append the IMs to shape one large matrix of size nValidAct x nSignals
171214
interaction_matrix_per_DM.append(interactionMatrix.interaction_matrix_warehouse[i][j]['IM'])
172215
# Compute the reconstructor
173-
interaction_matrix_per_DM = torch.as_tensor(np.vstack(interaction_matrix_per_DM), dtype=torch.float64, device=self.device).squeeze()
174-
if reconstructionMethod == 'inversion':
175-
temp_reconstructor = torch.linalg.pinv(interaction_matrix_per_DM, self.rcond[i])
176-
elif reconstructionMethod == 'tikhonov':
177-
# (D.T@D + alfa*I)@D.T --> implemented through SVD to improve the stability of the inversion and the automation of alfa
178-
H = interaction_matrix_per_DM
179-
U, S, Vh = torch.linalg.svd(H, full_matrices=False)
180-
alfa = self.beta[i] * torch.max(S)**2
181-
S_reg = S / (S**2 + alfa)
182-
temp_reconstructor = Vh.T @ torch.diag(S_reg) @ U.T
216+
if len(interaction_matrix_per_DM) == 0:
217+
self.logger.warning(f'Controller - DM {i} has no associated WFS in the control mask. Setting reconstructor to zero.')
218+
nModes = modal_basis[i].shape[1]
219+
temp_reconstructor = torch.zeros((nModes, 0), dtype=torch.float64, device=self.device)
183220
else:
184-
self.logger.error('Controller::initializeReconstructor - Unknown reconstructor')
185-
raise ValueError('Unknown reconstructor method.')
221+
interaction_matrix_per_DM = torch.as_tensor(np.vstack(interaction_matrix_per_DM), dtype=torch.float64, device=self.device).squeeze()
222+
if interaction_matrix_per_DM.ndim == 1:
223+
interaction_matrix_per_DM = interaction_matrix_per_DM.unsqueeze(0)
224+
if reconstructionMethod == 'inversion':
225+
temp_reconstructor = torch.linalg.pinv(interaction_matrix_per_DM, self.rcond[i])
226+
elif reconstructionMethod == 'tikhonov':
227+
# (D.T@D + alfa*I)@D.T --> implemented through SVD to improve the stability of the inversion and the automation of alfa
228+
H = interaction_matrix_per_DM
229+
U, S, Vh = torch.linalg.svd(H, full_matrices=False)
230+
alfa = self.beta[i] * torch.max(S)**2
231+
S_reg = S / (S**2 + alfa)
232+
temp_reconstructor = Vh.T @ torch.diag(S_reg) @ U.T
233+
else:
234+
self.logger.error('Controller::initializeReconstructor - Unknown reconstructor')
235+
raise ValueError('Unknown reconstructor method.')
186236
reconstructor.append(temp_reconstructor)
187237

188238
self.logger.info(f'Controller::initializeReconstructor - Reconstruction took {time.time()-t0}[s]')
189239

190-
return reconstructor, modal_basis, mask, altitude
240+
return reconstructor, modal_basis, mask, discarded_modes
191241

192242
def initializeController(self, controllerType, reconstructor):
243+
"""
244+
Initialize the control state (history buffers) based on the controller type.
245+
246+
Parameters
247+
----------
248+
controllerType : str
249+
Type of controller ('leaky', 'forwardPI', 'backwardPI').
250+
reconstructor : list
251+
List of reconstructor matrices per DM.
252+
253+
Returns
254+
-------
255+
bool
256+
True if initialization succeeds.
257+
"""
193258

194259
if controllerType == 'leaky':
195260
self.command_previous = [torch.zeros((reconstructor[i].shape[0],1), dtype=torch.float64, device=self.device) for i in range(len(reconstructor))]
@@ -202,6 +267,19 @@ def initializeController(self, controllerType, reconstructor):
202267
return True
203268

204269
def computeControlAction(self, lightPaths):
270+
"""
271+
Compute the control action for each DM given the wavefront error from the light paths.
272+
273+
Parameters
274+
----------
275+
lightPaths : list
276+
List of LightPath objects that contain the wavefront error measurements.
277+
278+
Returns
279+
-------
280+
dm_cmd : list
281+
List of command arrays to be sent to each Deformable Mirror.
282+
"""
205283

206284
# Get the combined measurement array for each DM
207285
error = []
@@ -213,7 +291,10 @@ def computeControlAction(self, lightPaths):
213291
combined_slopes.append(lightPaths[j].get_wavefront_error())
214292

215293
# Convert to torch
216-
error.append((-1)*torch.as_tensor(np.hstack(combined_slopes).T, dtype=torch.float64, device=self.device).unsqueeze(1)) # -1 for the feedback
294+
if len(combined_slopes) > 0:
295+
error.append((-1)*torch.as_tensor(np.hstack(combined_slopes).T, dtype=torch.float64, device=self.device).unsqueeze(1)) # -1 for the feedback
296+
else:
297+
error.append(torch.zeros((0, 1), dtype=torch.float64, device=self.device))
217298

218299
# Compute the DM command
219300
modal_error = []
@@ -235,10 +316,8 @@ def computeControlAction(self, lightPaths):
235316
dm_cmd = []
236317

237318
for i in range(len(self.reconstructor)):
238-
if self.altitude[i] > 0: # TT is discarded automatically in the IM measurement
239-
dm_cmd.append(self.modal_basis[i][:,2:2+self.reconstructor[i].shape[0]] @ modal_cmd[i])
240-
else:
241-
dm_cmd.append(self.modal_basis[i][:,:self.reconstructor[i].shape[0]] @ modal_cmd[i])
319+
offset = self.discarded_modes[i]
320+
dm_cmd.append(self.modal_basis[i][:, offset : offset + self.reconstructor[i].shape[0]] @ modal_cmd[i])
242321

243322
# Update history buffers for the next iteration
244323

‎SAOS/CorrelatingShackHartmann.py‎

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from queue import Queue
1313

1414
import numpy as np
15-
import scipy as sp
15+
1616
import torch
1717

1818
import cv2
@@ -67,8 +67,6 @@ def __init__(self,
6767
Extra FoV in [arcsec] that is taken for the FFT computation, in order to reduce wrapping effects.
6868
use_brightest : int, optional
6969
Picks the n brightest pixels as threshold for center-of-gravity spot detection.
70-
is_geometric : bool, optional
71-
Enable geometric mode (gradient-based measurement).
7270
threshold_convolution : float, optional
7371
Cut-off threshold for Gaussian convolution.
7472
unit_in_rad : bool, optional
@@ -568,6 +566,23 @@ def get_psf(self, phase, fwhm, npix_sun):
568566
return psf
569567

570568
def compute_images(self, psf, subDirs_sun, new_px):
569+
"""
570+
Compute correlated subaperture images using convolution.
571+
572+
Parameters
573+
----------
574+
psf : torch.Tensor
575+
PSF of the subapertures.
576+
subDirs_sun : np.ndarray
577+
Sun subdirectories used as object.
578+
new_px : int
579+
New size in pixels for the sun subdirectories.
580+
581+
Returns
582+
-------
583+
torch.Tensor
584+
Convoluted images.
585+
"""
571586

572587
# Convert to Tensor
573588
sun_torch = torch.from_numpy(subDirs_sun).contiguous().to(self.device) # 4D:
@@ -601,6 +616,21 @@ def compute_images(self, psf, subDirs_sun, new_px):
601616
return sun_patches
602617

603618
def merges_images(self, sun_patches, src):
619+
"""
620+
Merge sun patches into a single frame.
621+
622+
Parameters
623+
----------
624+
sun_patches : torch.Tensor
625+
The convoluted sun patches per subaperture.
626+
src : Source
627+
The solar source object.
628+
629+
Returns
630+
-------
631+
np.ndarray
632+
The merged full-field images.
633+
"""
604634
# Resize the 2D filter
605635
filter_2D_torch = torch.from_numpy(src.filter_2D).contiguous().to(self.device)
606636
filter_2D_torch = filter_2D_torch.view(filter_2D_torch.shape[0], filter_2D_torch.shape[1], -1).permute(2, 0, 1)
@@ -844,12 +874,11 @@ def wfs_measure(self,phase_in, src, pseudoref=None, reference_slopes=None):
844874
Phase map input [radians].
845875
src : Source
846876
Source object.
847-
integrate : bool, optional
848-
Whether to include camera integration effects.
849877
pseudoref : torch.Tensor
850878
Pseudo-reference image used for the correlations, if None the method picks one.
851879
reference_slopes : np.ndarray
852880
Reference for the WF sensor, if None, geometric references are used.
881+
853882
Returns
854883
-------
855884
tuple

0 commit comments

Comments
 (0)