Changelog¶
All notable changes to pyturb are documented here. The format follows Keep a Changelog, and the project aims to adhere to Semantic Versioning.
[Unreleased]¶
[2.4.0] - 2026-10-10¶
Added¶
pyturb.theory.covarianceandpyturb.theory.space_time_structure_function: the space-time covariance<phi(a, t) phi(b, t + lag)>and structure function between any pupil points for a layered profile under frozen flow (phi(r, t) = phi_0(r + wind_vector * t)), Cn²-weighted over the layers, optionally with pyturb's boiling (tau_boil,boil_model, andboil_scaling="kolmogorov"for the spectral engine's per-mode time constants or"uniform"for the extruder's). The boiled covariance is a Hankel transform of the von Kármán PSD times each mode's retention, evaluated to ~1e-7 relative (checked against adaptive quadrature); at zero lag it is the closed form.units="phase"or"opd"; a 1-Dlagadds a lag axis.Atmosphere.covarianceandAtmosphere.structure_functionevaluate them with the atmosphere's own layers, line-of-sight r0, L0 and boiling, in OPD (or phase at awavelength). The test suite checks them against ensembles ofAtmosphere.frames(with and without boiling, both boil models).boil_model="ar2"(spectral engine): smooth boiling. Each mode follows a critically damped second-order process (Matérn-3/2 correlation(1 + 2 lag/tau) exp(-2 lag/tau), the same integral time as AR(1)), sampled exactly for anydt, so the per-frame change grows asdt/tauinstead ofsqrt(dt/tau): at 1 kHz withtau_boil = 50 ms, 109 nm rms per frame over an 8 m pupil instead of 307 nm (r0 = 0.15 m, L0 = 25 m, one static layer). The default"ar1"is unchanged. Recorded into_config/metadata.pyturb.set_threads(n)/pyturb.get_threads(): cap the Numba kernels' threads (in every Python thread) and any threaded FFT workers, e.g. one thread per process in a multiprocessing sweep. Results do not depend on the setting. pyturb's Numba kernels also honourOMP_NUM_THREADSwhenNUMBA_NUM_THREADSis unset.
Changed¶
- The docs state prominently that
tau_boilis the outer-scale lifetime (the 1-10 cm scales an AO system senses boil 40-180 times faster atL0 = 25 m), with guidance on sensible values (seconds) and a table of the per-frame change againsttau_boil; the Performance guide covers threads and parallel sweeps.
Fixed¶
Atmosphere.opd(t=times)on the GPU raisedIndexErrorinstead of warning when a layer wrapped the periodic screen.
Output: every seeded result of the existing options (CPU with and without
Numba, GPU) is bit-identical to 2.3.0, checked bytewise over 664 seeded
configurations per backend, and under set_threads(1) and set_threads(3).
[2.3.0] - 2026-10-07¶
Performance¶
Measured with benchmarks/bench_suite.py on an Arm workstation (RTX 4060,
80-core Neoverse-N1 host pinned to 12 cores), median of three interleaved
runs, 9-layer paranal-median, frames/s at 256² / 512² / 1024² (details in
benchmarks/RESULTS.md §7):
- GPU boiling (
tau_boil) frames: 1,296 / 608 / 124 → 3,761 / 2,184 / 543. The per-mode AR(1) coefficients are computed once perdt, and the update is one kernel that draws its noise inline from the same Philox stream. - GPU LGS cone (
lgs_altitude) frames: 205 / 167 / 49 → 6,073 / 1,896 / 362. The cone frame is now a captured CUDA graph (cuda_graph=True): a batched real inverse FFT of each layer's Hermitian half-spectrum, one zoom-and-sum kernel, and the subharmonics evaluated at the zoomed positions. - CPU spectral frames: 53 / 8 → 128 / 26 at 512² / 1024² (256² unchanged);
tomography and
opd(t=times)1.4-2x at 512²-1024². The Numba layer sum runs its per-pixel loop across Numba's threads (NUMBA_NUM_THREADS) for stacks of 2M layer-pixels and up. - CPU LGS cone frames 1.8-3.1x and boiling frames 1.4-2.1x: one fused Numba pass for the cone zoom and for the boiling update.
InfinitePhaseScreen.step/advance: 2.7-20x (CPU and GPU); the row gather and interpolation run as one kernel.- The GPU extruder draws each layer's pending noise rows in one call.
Output: every CPU result (with and without Numba) and every GPU result except
the LGS cone is bit-identical to 2.2.0 (checked bytewise over ~200 seeded
configurations; validation/validate.py metrics are identical). The GPU LGS
cone frame uses custom kernels where it used cuBLAS (which cannot run inside a
CUDA graph) and a real FFT where it used a complex one, so it agrees with 2.2.0
to float32 rounding (largest difference 5e-6 of the frame RMS; 3e-15 in
float64), the same envelope as GPU results across GPU models.
[2.2.0] - 2026-10-07¶
Changed¶
aobasis>=2.1,<3is now required (wasaobasis>=2.0).analysis.zernike_basisbuilds throughaobasis.zernike_modes_on_maskand skips aobasis' numerical rank check whenever the basis is linearly independent by construction: the highest radial order is below the side of the largest square of pixel centres inside the pupil, so the sampled modes are independent polynomials on a tensor grid. That covers every basis except ones with modes approaching the pupil's pixel count (e.g. more than 21 modes on an 8-pixel pupil), which are still checked and still warn when rank-deficient. The returned bases are bit-identical to 2.1.0 (checked from 1 to 200 modes on grids of 16 to 512 pixels), and building one is ~2.8x faster than in 2.1.0 and ~1.5x faster than in 2.0.0 (200 modes on 512²: 2.2 s, vs 6.0 s and 3.2 s on the same machine).analysis.noll_to_zernikeis a thin wrapper overaobasis.noll_to_nm; the(n, m)values and sign convention (m > 0cosine,m < 0sine, Z2 =(1, 1)tip along x) are unchanged (checked for j = 1 to 20000). It now raisesValueErrorfor a non-integerj(a float, bool or array) instead of returning float orders or failing with an unrelated error; NumPy integers are accepted and the result is a tuple of Python ints.
[2.1.0] - 2026-10-07¶
Changed¶
- pyturb now uses the stack's shared packages for generic primitives, as the
AO conventions contract (aocore
CONVENTIONS.md, sections 2.3, 5.3 and 9) asks.aocore>=0.1.1andaobasis>=2.0are new core dependencies (both pure Python on NumPy/SciPy); their floors raise pyturb's tonumpy>=1.23andscipy>=1.10.aocoreis no longer listed in thetestextra. analysis.zernike_basisevaluates the polynomials withaobasis.ZernikeBasisGeneratoronaobasis.positions_from_maskpositions instead of a local factorial-sum implementation. Ordering (Noll), Noll normalisation, orientation (Z2 along x = columns), pupil mask, centring at(n - 1) / 2and the float64 dtype are unchanged; values agree with the previous implementation to ~1e-14 for low orders and ~2e-13 by j = 50 (aobasis uses a Jacobi recurrence that avoids the factorial sum's cancellation). Turbulence statistics and the theory validation are unchanged. Building a basis takes up to ~1.7x longer, as aobasis checks the basis' numerical rank (e.g. 6.8 s vs 4.1 s for 200 modes on 512²); build it once and pass it tozernike_decompose(..., basis=...).analysis.zernike_basisraisesValueErrorfor a non-positivediameter_pixelsand for more modes than pixels inside the pupil (such a basis cannot be linearly independent), instead of returning a degenerate basis.opd_to_phase/phase_to_opddelegate toaocore.opd_to_phase/aocore.phase_to_opd(same values, NumPy and CuPy alike); a scalar input now returns a NumPy float64 scalar (afloatsubclass).- The arcsecond/radian conversions use
aocore.ARCSEC_TO_RAD/aocore.RAD_TO_ARCSEC(bit-identical to the previous local constants), and the pupil pixel-centre offsets in the frozen-flow engines useaocore.centered_coordinates(bit-identical); seeded screens are unchanged.
[2.0.0] - 2026-10-07¶
Breaking — axis convention for 2.0¶
pyturb now uses the (y, x) array convention of aobasis, makewfs,
solvephase, shmpipeline-ao and HCIPy: x is axis 1 (columns), y is axis 0
(rows). pyturb 1.x labelled axis 0 as x. Array shapes, pixel centres, units
and all turbulence statistics are unchanged; every x/y-labelled input now
refers to the other array axis:
Layer.wind_direction(still the direction the wind blows from) is measured from +x (axis 1) toward +y (axis 0), andLayer.wind_vectorreturns(vx, vy)along (axis 1, axis 0). Withwind_direction=0the pattern now moves toward decreasing column index (it moved toward decreasing row index in 1.x). Applies to both engines and towith_wind,discretize_cn2,from_profileandfrom_cn2.Atmosphere.opd(directions=[(thx, thy)])/(thx, thy, altitude):thxis along x (axis 1),thyalong y (axis 0).Atmosphere.opd_at(x, y, ...):xis the column offset,ythe row offset.FourierFlowScreen.translate(sx, sy):sxalong x (axis 1),syalong y (axis 0).analysis.zernike_basis/zernike_decompose:theta = atan2(y, x)with x = axis 1, so Noll Z2 (tip) varies along the columns and Z3 (tilt) along the rows, identical toaobasis.ZernikeBasisGeneratoronaobasis.positions_from_maskpositions. The new basis is the old one transposed (new[k] == old[k].T): Z2 and Z3 swap, and higher modes map by a signed permutation (table in the migration guide).theory.zernike_temporal_psd(..., wind_direction)is in the same frame asLayer.wind_directionand the new Zernike basis (its values are unchanged: both frames moved together).to_config()writespyturb_config_version: 2.from_configstill reads version-1 configs and converts each layer'swind_directionto(90 - a) mod 360, so a run saved by 1.x replays the frames it recorded.- The named site profiles keep their published
wind_directionnumbers, now read in the new frame. Profiles carry no sky orientation (most directions are illustrative), so this changes which array axis a layer blows along, not any published quantity. HCIPyLayerand HCIPy recipes: pyturb arrays and direction tuples now map onto HCIPy grids with matching x and y, with no axis swap. The interop docs also correct the velocity-sign note: measured with HCIPy 0.7,InfiniteAtmosphericLayer'svelocitypoints the same way as pyturb'swind_vector(the pattern moves along-velocity), whereasFiniteAtmosphericLayerdiffers.- Internal:
pyturb.extrude.ExtrudedAtmospherekeeps its array-axis order (layer_windis(v_axis0, v_axis1)), but its direction arguments are renamedslope0/slope1andsample_pointstakespix0/pix1.
How to upgrade¶
Each 1.x call has a 2.0 equivalent that produces the same arrays (checked to float64 round-off on both engines):
| pyturb 1.x | pyturb 2.0 |
|---|---|
wind_direction=a (any API) |
wind_direction=90 - a |
directions=[(a, b)], (a, b, h) |
directions=[(b, a)], (b, a, h) |
opd_at(p, q, direction=(a, b)) |
opd_at(q, p, direction=(b, a)) |
translate(a, b) |
translate(b, a) |
zernike_basis(J, n) |
zernike_basis(J, n).transpose(0, 2, 1) |
zernike_decompose(phase, J) |
zernike_decompose(phase.swapaxes(-1, -2), J) |
New code should state the geometry in the new frame directly. See Migrating to 2.0 for the full guide, the Zernike mode table and before/after code.
Fixed¶
- The same
seedgives the same turbulence on every GPU (#36). GPU streams came fromcupy.random.default_rng. Its XORWOW generator keeps2048 x multiProcessorCountcuRAND states and fills elementiof a draw from statei % state_count. Every draw larger than that pool (every turbulence noise block) therefore depended on the GPU's SM count. On an RTX 4060 and an RTX A400, the Keck atmosphere atseed=123had 764 nm vs 956 nm RMS OPD. pyturb now draws GPU normals from its own counter-based Philox4x32-10 + Box–Muller kernel, where each value is a pure function of the seed and its position. The random draws are bit-identical on every device. The spectral engine's OPD is bit-identical on the two GPUs tested. The extruder agrees to float32 rounding (a few 1e-6 relative), because its cuBLAS recurrence may pick different kernels per GPU model. AffectsAtmosphere(both engines, boiling included),PhaseScreen,FourierFlowScreenandInfinitePhaseScreenon the GPU.
Changed¶
- GPU realisations for a given seed differ from earlier releases: the GPU
stream is now Philox rather than CuPy's XORWOW. The earlier GPU results
could not be reproduced across GPU models anyway. CPU results are
unchanged (still
numpy.random.default_rng(seed), PCG64, bit for bit). The CPU-vs-GPU contract is documented underseedand in the quickstart's "Reproducing a run": the same seed gives a different but statistically equivalent realisation on CPU and GPU. Acupy.random.Generatorpassed asseedtoPhaseScreen/FourierFlowScreenis still used as given.
[1.2.0] - 2026-10-02¶
Highlights: 53 published turbulence profiles with full provenance, covering
12 sites: Paranal (the fourteen ESO reference profiles and Stereo-SCIDAR), the
five TMT candidate sites, Cerro Pachon, Cerro Tololo, Siding Spring,
Sutherland, Mt Graham and La Palma. profile_info(name) gives the
citation, how the numbers were obtained (table, dataset or figure), and the
published conditions (r0, seeing, theta0, tau0). from_profile uses a
profile's published r0 by default and takes winds via wind=. No breaking
changes.
Added¶
- Fourteen traceable Paranal profiles,
paranal-p01...paranal-p14, from Garcia-Rissmann et al. (2015), MNRAS 448, 2594 (Tables 2-3): seeing classes 0.4"-1.4", each good/median/bad, with their published r0, tau0, mean height and probability of occurrence in the newProfileInfo.conditions. Tests recompute the paper's mean height and tau0 from the stored layers (#18). - 31 more traceable site profiles from refereed papers, each transcribed verbatim and checked against the rendered tables, with tests that recompute every published integrated quantity: TMT site testing at Maunakea 13N, Armazones, Tolar, Tolonchar and San Pedro Martir (Els et al. 2009, good/typical/bad), Cerro Pachon (Tokovinin & Travouillon 2006), Siding Spring (Goodwin et al. 2013, nine GL x FA combinations with modelled winds), Sutherland (Catala et al. 2013) and Mt Graham (Masciadri et al. 2010).
- Eight dataset- and figure-derived profiles, with
ProfileInfo.originrecording how each profile's numbers were obtained ("table","dataset"or"figure"): Cerro Tololo good/typical/bad, computed from the public MASS-DIMM data of Tokovinin et al. (2003), which reproduces their Table 1; Paranal Stereo-SCIDAR mean (Osborn et al. 2018); La Palma median (Garcia-Lorenzo & Fuensalida 2011); San Pedro Martir median (Avila et al. 2019). Figure profiles take their strength from the paper's median seeing; their caveats give the theta0 check against the published value. Also two coarse Maunakea tables (RAVEN and CFHT, Ono et al. 2017), with RAVEN's per-layer median L0. Published profiles may now carry a per-layer L0. - The Mt Graham profiles carry climatological wind speeds digitised from Hagelin et al. (2010). They are not simultaneous with the Cn2, so tau0 comes out ~25% below the paper's median, as the caveat states.
Atmosphere.from_profileuses a traceable profile's published r0 when nor0/seeingis given, acceptswind=/wind_direction=, and warns for profiles whose source publishes no winds (their layers are static).pyturb.with_wind(layers, speed, direction)assigns winds to any profile.
Fixed¶
- A layer with
cn2_fraction=0no longer crashesAtmosphere(its Fried parameter would be infinite); zero-weight layers are left out of the simulation, with a per-layertau_boilkept aligned.
[1.1.0] - 2026-10-02¶
Highlights: an opt-in fix for pupil-sized periodic screens (oversample),
CUDA-graph GPU frames (5-8x on modest hosts), batched time series, per-source
LGS/NGS directions, opd_at, a pyturb.theory module, from_cn2,
replayable configurations, GPU selection, an HCIPy layer adapter, and tested
guides. Behaviour changes to be aware of: Python 3.10 is the minimum,
analysis.temporal_psd now tapers with a Hann window by default, invalid
per-layer inputs now raise, and threadpoolctl is a new dependency.
Added¶
device="gpu:N"picks a GPU on multi-GPU machines; every public method ofAtmosphere,PhaseScreen,FourierFlowScreenandInfinitePhaseScreenruns with that GPU current and leaves the caller's current device unchanged (#20).- Replayable configuration:
Atmosphere.to_config()/Atmosphere.from_config(), andmetadata["config"](written bypyturb.save) holds every constructor input and the layer table, so a saved OPD made with an integer seed can be regenerated exactly (#20). pyturb.interop.HCIPyLayer: an Atmosphere presented as an HCIPy atmospheric layer (t,layer(wavefront),evolve_until,phase_for,reset), e.g. for pyRTC's HCIPy simulator; plus a DLPack (PyTorch/JAX) recipe in the interop docs (#20).- Per-direction sources:
opd(directions=[(thx, thy, altitude), ...])gives each direction its own source range — a laser guide star ataltitudeorNonefor a star at infinity — so an LGS asterism, NGS and science directions read one turbulence realisation in one call, on both engines (#19). Atmosphere.opd_at(x, y, t, direction, altitude)samples the atmosphere at arbitrary pupil-plane coordinates (DM actuators, sub-apertures, sparse or multi-aperture layouts); on the pupil grid it reproducesopd()exactly. Reach beyond the pupil withoversample(spectral) orfield_of_view(extrude) (#19).Atmosphere.from_cn2(heights, cn2, ...)builds an atmosphere straight from a measured or model Cn²(h) profile (compressed withdiscretize_cn2), taking the profile's ownr0unlessr0/seeingis given and recording asourceinmetadata(#18).discretize_cn2(wind_direction=...): a scalar, a per-input-grid array (Cn²-weighted circular mean per bin) or one value per output layer, instead of every layer at 0 degrees (#18).pyturb.theory: reference curves for checking any simulation, at finite outer scale as well as Kolmogorov —structure_function,zernike_variance(any Noll mode, anyL0; reproduces Noll's table forL0=inf),image_motion_variance(one-axis tilt, arcsec²),seeing_fwhm(Tokovinin 2002 outer-scale correction),pixel_temporal_psdandzernike_temporal_psd(Conan, Rousset & Madec 1995; along- vs across-wind aware), anddifferential_phase_variancethrough a profile. Each is tested against Noll's table or an exact identity and against pyturb's own simulations;validation/validate.pyuses them (#17).opd(t=array)returns a stack of frames for many times (and directions) at once; on the GPU the spectral engine evaluates them in one batched transform (~26,000 frames/s at 256² and ~6,700 at 512² on an RTX 4060 for the 9-layerparanal-median). Boiling is not applied to random-access times (#15).Atmosphere(cuda_graph=True): on the GPU, each spectral frame (frames,evolve, single-directionopd) replays a captured CUDA graph built from fused layer-sum and subharmonic kernels with device-side float64 phasors. Frames are bit-identical to ordinary execution (cuda_graph=False), boiling included; capture falls back to ordinary execution if unsupported. On an RTX 4060 with an Arm host, 9-layer spectral frames go from ~830 to ~6,300 fps at 256², ~820 to ~4,300 at 512² and ~460 to ~1,300 at 1024² (RTX A400: ~830/500/130 to ~4,800/1,300/330). Multi-direction spectral frames use the same fused kernels (#15).Atmosphere(oversample=...): FFT screens forsample()and the spectral engine can be made larger than the pupil (default 1, unchanged). On a pupil-sized screen the FFT periodicity puts opposite pupil edges next to each other, so separations beyond ~D/4 come out low (structure function about −13% at D/2, ~0.65x near 0.9 D; tilt ~10-15% low; astigmatisms split ~0.6x/1.6x Noll).oversample=4matches von Kármán to ~2% rms out to 0.9 D. It also multipliestime_to_wrap, at the cost of an FFToversample**2larger (RTX 4060, 512²: ~5,300 to ~1,360 spectral fps at 2x). Validation gains a large-scale structure function check out to 0.9 D (#3).
Changed¶
- The CPU extruder batches row extrusion across layers. Layers sharing an
outer scale share the extrusion matrices, so each row step is two matrix
products for all of them instead of two mat-vecs per layer, and the CPU
extrusion BLAS calls run single-threaded (
threadpoolctl, a new dependency): a threaded BLAS gains nothing on these small memory-bound products and its spinning threads starve the Numba readout. 9-layerparanal-medianat 512² on 16 Arm cores: ~42 to ~98 fps (no change at 256², where the readout dominates). Each layer keeps its own random stream; frames agree with per-layer extrusion to ~1e-14.InfinitePhaseScreenand CPU extruder boiling use the same single-threaded BLAS (#16). - Spectral boiling updates the stored spectra in place.
analysis.temporal_psdtapers with a Hann window by default (window="hann";window=Nonegives the previous untapered periodogram, and anyscipy.signal.get_windowname or an explicit array also works). The untapered periodogram leaks power upward on steep frozen-flow spectra: on a single pupil pixel it read slope −2.47 and 1.7x the theoretical level (−2.18 and 5.9x on the extruder) against −8/3 and 1x; with the taper, −2.63/1.2x and −2.81/0.8x. PSD values from this function change (#6).- Python 3.10 is the minimum; CI tests 3.10, 3.12, 3.13 and 3.14, and the package declares per-version classifiers plus Documentation/Source/Changelog URLs (#13).
- Release tags are bare versions (
1.1.0), and the release workflow checks the tag matchespyproject.tomlbefore publishing (#1). - The self-hosted GPU workflow is removed: no runner exists, so it queued on
every pull request until GitHub cancelled it. GPU paths are checked with a
local
pytest --run-gpu, asCONTRIBUTING.mdnow describes (#11). - New
interopextra (HCIPy, poppy) used to exercise the interop recipes.
Fixed¶
profile_info("hv57").outer_scaleis 25 m (its layers' value) rather thanNone;bench_suite.pyrecords the installed CuPy version (it looked up a non-existent "cupy" distribution).- Per-layer inputs are validated. A NaN or infinite wind speed produced
all-NaN OPD frames and a negative altitude a NaN
theta0; non-finite layer values, negative altitudes or wind speeds, and non-positive per-layerL0now raise aValueErrornaming the layer. Non-integersubharmonics(PhaseScreen) andsteps(frames,InfinitePhaseScreen.step) are rejected instead of truncated, and asking anengine="extrude"atmosphere for an earlier time than it has reached explains the streaming clock andreset()(#7). - Long spectral runs keep float32 accuracy. Shift-theorem displacements
are reduced modulo the screen period in float64 before the float32 phasors
are formed (and subharmonic phasors built in float64), on every spectral
path and in
FourierFlowScreen.translate. A frame 9,000 periods later now reproduces to round-off; before, the error grew to ~0.7% after 40 minutes of wind and ~8% after 7 hours (#8). - GPU extras work in a clean environment.
pyturb[cuda12]now installscupy-cuda12x[ctk]: CuPy 14 compiles every kernel at runtime and fails with "Failed to find CUDA headers" unless the CUDA headers are present, which a fresh environment without a system CUDA toolkit does not have. Added apyturb[cuda13]extra (cupy-cuda13x[ctk]) for CUDA 13 drivers, and a troubleshooting note to the install docs (#9). pyturb.benchmark()no longer emitsPeriodicWrapWarning: its timing loop runs past the spectral screen period by design, which says nothing about the throughput being measured.examples/02_closed_loop.pyruns onengine="extrude"(its 0.5 s run is longer than the spectral engine'stime_to_wrap), and its "last frame" panel shows the last frame (it matched frame times by float equality, so it showedt = 0twice). The README/docs quickstart loops stay withintime_to_wrapand explain why (#4).examples/05_gpu_benchmark.pyskips the GPU rows when CuPy is installed but no CUDA device is usable.docs/interop.md: the poppy recipe passes an astropy-unit pixel scale (poppy 1.2 rejects a bare float), and the DM-fitting residual is restricted to the pupil. Every README/docs code block andexamples/01–05now run in CI (tests/test_docs.py) (#10).
Documentation¶
- New guides: Choosing an engine (decision table and the
periodicity/oversample trade-offs), Turbulence profiles (named profiles
with provenance, custom layers,
from_cn2, discretisation methods), Boiling, LGS and dispersion, Performance and Troubleshooting; the changelog, roadmap and contributing pages are in the site (#14). - Tutorial notebook
tutorials/01_atmosphere_to_psf.ipynb: a layered atmosphere drives a minimal modal AO loop, checked against Noll's fitting-error floor and the Maréchal Strehl; committed executed and re-run in CI (#14). benchmarks/RESULTS.mdadds pyturb 1.1 on an Arm workstation (RTX 4060, RTX A400, 16 Neoverse-N1 cores) with versioned artifacts; the README shows the 1.1 numbers next to the 1.0.0 RTX 5090 reference (#21).- Validation: the structure-function band, the Zernike check (now per mode, tip/tilt included) and the temporal-PSD check (now both engines, tolerance 0.6-1.6x instead of 1-3x) are documented with what they actually measure; the astigmatism split and the shallow PSD are explained as periodicity and spectral leakage rather than "sampling" and "finite screen" effects (#12).
- The wind convention is stated:
wind_direction/wind_vectorpoint where the wind blows from and the pattern moves along-wind_vector, on every engine; a test pins it. A new Conventions section in Concepts covers axes, wind, directions, line-of-sight quantities and units (#5). - Quickstart sections for boiling, the LGS cone, dispersion and the
PhaseScreen/InfinitePhaseScreenbuilding blocks; the API reference lists the warning classes,phase_covariance,profile_info/ProfileInfo,to_numpy,get_array_moduleandget_fft_workers(#13). - Corrected stale or inaccurate claims in the README, docs index,
RESULTS.md,ROADMAP.md,CONTRIBUTING.mdand thestructure_functiondocstring (#12).
[1.0.0] - 2026-07-09¶
Added¶
- Boiling on the non-periodic engine.
tau_boilnow works withengine="extrude", not just"spectral". Each step blends the ring buffer toward a fresh, independently extruded screen (buf = a·buf + √(1−a²)·fresh,a = exp(−dt/tau)), so temporal decorrelation composes with frozen flow while the spatial covariance — and hencer0— is preserved and the screen stays non-periodic. Unlike the spectral engine's per-mode boiling, the extruder decorrelates every spatial scale at the singletau_boilrate (real space has no per-mode handle); staying non-periodic costs a modest deficit in the largest-scale power of the boiled screen, and re-extruding the fresh window makes a boiling frame markedly costlier than a frozen one.lgs_altitudenow composes with boiling on the extruder too (the cone acts on readout geometry, boiling on the stored turbulence). On the GPU, boiling's fresh-screen extrusion is batched across layers (one pair of matmuls per row instead of a Python loop per layer per row), a several-fold speedup over the naive per-layer path. ConstructingAtmosphere(engine="extrude", tau_boil=...)now raisesExtrudeBoilingPerformanceWarning, noting that this combination is still markedly slower thanengine="spectral"boiling.
Changed¶
- Construction is now model/state separated. Immutable validated configurations define phase-screen, atmosphere, and extrusion inputs before backend dispatch; mutable runtime objects own only derived geometry, random streams, spectra, and ring buffers. The two extrusion implementations share their stencil-preserving ring compaction primitive.
- Validation evidence expanded. The CI validation gallery now asserts and
plots finite-screen structure-function ratios at 1–8 pixels and the scalar
zenith-projection laws for
r0,theta0, and layer range. - Benchmark evidence is versioned.
bench_suite.py --jsonrecords the invocation, Git revision, platform, dependency versions, device selection, timing budget, and batch size with every result. The 1.0.0 reference artifact is checked in alongside the published tables. Atmosphere.sample()is ~L× faster for shared-outer-scale profiles. It now draws one aggregate phase screen per distinctL0rather than one per layer: independent von Kármán screens with the same PSD shape add exactly (r0_agg^{-5/3} = Σ r0_i^{-5/3}), so summing them is distributionally identical to summing per-layer draws. A 9-layerparanal-median(uniformL0)sample()is ~9× faster on both CPU and GPU (measured 9.0×/9.1× at 512², ~30k screens/s on GPU). Layers that shareL0are pooled; a profile with mixedL0uses one screen per distinct value. Reproducibility note: for a profile with multiple layers sharing anL0,sample()now consumes a different RNG stream, so a fixedseedyields a different (but statistically identical) realisation than before; single-layer-per-L0profiles are unchanged (the layer's own generator is reused).- Batched multi-direction tomography on the GPU.
opd(directions=[...])onengine="spectral"(without the LGS cone) now integrates all directions through one batched inverse FFT and one subharmonic matmul chain instead of a Python loop, ~1.8× faster at 512² by removing per-direction kernel-launch latency (bit-identical output). The CPU path keeps its per-direction fused loop, which is faster there than a batched transform. - Faster spectral LGS cone frames on the GPU. The per-layer cone-zoom
readout in
_integrate_lgs(previously ~77% of the frame, a per-layer, per-tap Python loop) is now a handful oftake_along_axisgathers batched over all layers and taps: ~3× at 256²/512² (275 → ~830 fps at 512²), bit-identical output. Gated to the GPU below a working-set threshold — on the CPU, and for large(L, n, n_screen)working sets on the GPU (≳1024²), the cache-friendlier per-layer loop is kept.
[0.2.0] - 2026-07-05¶
The "atmosphere" release: pyturb goes from a phase-screen library to a complete, benchmarked, GPU-native AO atmosphere.
Added¶
Atmosphere— layered atmosphere summed to pupil OPD, with per-layer wind, airmass/zenith scaling, off-axisdirections=, field-of-view oversizing, and integratedr0/seeing/theta0/tau0/greenwood_frequency. Built from named profiles viafrom_profile.- Two frozen-flow engines.
engine="spectral"(default): exact sub-pixel shift-theorem translation, all layers in one FFT, but periodic. Boiling (tau_boil) via a spectral AR(1).engine="extrude": Assémat–Wilson row extrusion in a wind-aligned ring buffer with rotated sub-pixel sampling — unbounded, non-periodic, any wind direction. InfinitePhaseScreenrewritten with a ring buffer and sub-pixeladvance()(Catmull-Rom / linear), memory bounded over arbitrarily long runs.- Named profiles:
paranal-median,mauna-kea,keck,las-campanas,cerro-pachon,armazones,hv57,single-layer,two-layer;discretize_cn2(method=...)with moment-conserving"equivalent"(conservestheta0andtau0),"centroid", and"optimal_grouping"— the last chooses bin edges by dynamic programming to minimise the Cn²-weighted within-group spread ofh^{5/3}, for MCAO/tomography layer compression (Saxenhuber et al. 2017). Atmosphere.evolve(dt)— single-step, in-seconds frozen-flow stepper (mirrors HCIPy'sevolve_until); repeated calls reproduceframes(dt).interp="lanczos"(6-tap Lanczos-3) sub-pixel readout on the extruder andInfinitePhaseScreen— a flatter sub-Nyquist kernel that cuts the extruder's finest-scale travel-phase flicker (~10% → ~3.5%) and structure-function deficit versus the default cubic, with no change to the extrusion statistics.pyturb.analysis: Zernike basis/decomposition, Noll (1976) mode variances, temporal PSD + power-law fit, angular decorrelation.- I/O:
pyturb.save/pyturb.loadfor.npzand FITS (optional astropy) with metadata;Atmosphere.metadata. - Chromatic OPD:
dispersion="edlen"(dry air) anddispersion="ciddor"with awet_fractionwater-vapour term for the mid-IR/interferometric "wet–dry" problem;pyturb.air_refractivityandpyturb.water_vapour_refractivity. - LGS cone effect:
Atmosphere(lgs_altitude=...)on both engines — the extruder samples its ring buffer on a magnified grid, the spectral engine zoom-resamples each layer's screen about the pupil centre by the same factor. On the spectral engine the cone now composes withtau_boilboiling, closing the previous cone/boiling mutual exclusivity. - Non-Kolmogorov spectra:
PhaseScreen(power_law=..., inner_scale=...). - Threaded CPU FFT:
pyturb.set_fft_workers(). - GPU test path: GPU tests marked
@pytest.mark.gpu, run withpytest --run-gpu(adevicefixture parameterises statistics tests over CPU/GPU);.github/workflows/gpu.ymlruns them on a self-hosted GPU runner. pyturb.benchmark()convenience;benchmarks/bench_suite.py(per-use-case throughput sweep across CPU/GPU) andbenchmarks/bench_compare.pyhead-to-head vs aotools/soapy/HCIPy;validation/validate.pygallery.- Docs:
docs/comparison.md,docs/interop.md,docs/validation.md; examples gallery (examples/01–05). py.typedmarker; version single-sourced from package metadata.
Changed¶
Atmosphereoutput is OPD in metres (achromatic); passwavelength=for phase.PhaseScreen/InfinitePhaseScreenstill return radians.discretize_cn2default method is now"equivalent"(moment-conserving).
Performance¶
- Spectral engine: collapse the layer axis before the transform. The
inverse FFT and subharmonic outer product are linear and shared across
layers, so
Atmosphere._integratenow sums the shifted spectra to one(n, n)array and inverse-FFTs once instead of once per layer (and sums each subharmonic level's3x3coefficients before the shared basis product). Identical output; measured on an RTX 5090, 9-layer paranal-median: CPU 25 → 87 fps at 512² (3.4×), GPU 865 → 1232 fps (1.4×). - Batch all subharmonic levels into one matmul. The low-frequency
subharmonic correction shares one
(3, n)sinusoid basis across levels and layers, soAtmosphere._integrate,PhaseScreen.generate,FourierFlowScreen.translateand the boiling update now evaluate every level in a couple of batched matmuls instead of a Python loop over levels (which was launch-latency bound on the GPU — ~78% of a frame). Identical output. Measured on an RTX 5090, 9-layer paranal-median frozen flow: 1,232 → 3,004 fps at 512² GPU, 3,234 fps at 256²; single-layer Monte-Carlo generation 14,000 → 31,000 screens/s at 512² (55,000 → 108,000 at 256²); CPU spectral ~87 → ~130 fps at 512² before the accel extra below. - Fused GPU/CPU extruder readout kernel. Every layer's ring buffer is a slab
of one contiguous
(L, cap, W)array, and the per-frame rotated, sub-pixel, per-layer-wind-shifted pupil gather runs in a single pass for the"cubic"and"lanczos"interpolators: a hand-written CUDA kernel on the GPU and a fusedprangeNumba kernel on the CPU (see the accel extra below), bit-exact with the previous tap-broadcast gather. Measured on an RTX 5090, 9-layer paranal-median: 121 → 4,484 fps at 256² GPU (37×), 118 → 1,730 fps at 512² (15×), 50 → 602 fps at 1024²; the"lanczos"readout is now a fused kernel too (~334 fps at 512² GPU, from ~120 fps). - Optional Numba CPU acceleration (
pip install pyturb[accel]). The CPU frozen-flow hot paths — the spectral engine's fused layer sum and the extruder's fused bicubic/Lanczos readout — run through Numba when it is importable, with a NumPy fallback otherwise (identical results to float round-off). Measured on a 32-core CPU, 9-layer paranal-median: spectral ~130 → 270 fps at 512², extruder 6 → 164 fps at 512² (27×) and 28 → 966 fps at 256² (34×). - Geometry-derived extruder buffer sizing. The shared ring buffer is now
sized to the largest along-wind/off-axis requirement actually present among
the layers (each layer's own wind direction and altitude), not a blanket
every-layer-at-45-degrees-and-max-altitude assumption. Measured (n=512): a
ground-layer-only atmosphere with
field_of_view=30"uses ~68% less buffer memory; an axis-aligned atmosphere uses ~41% less even atfield_of_view=0. Never worse than before.
[0.1.0]¶
- Initial release:
PhaseScreen(FFT + subharmonics) andInfinitePhaseScreen(Assémat–Wilson extrusion), NumPy/CuPy backends, structure-function tests.