adjeff 1.2.2__tar.gz → 1.2.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. {adjeff-1.2.2 → adjeff-1.2.5}/CHANGELOG.md +34 -0
  2. {adjeff-1.2.2 → adjeff-1.2.5}/PKG-INFO +1 -1
  3. {adjeff-1.2.2 → adjeff-1.2.5}/docs/guide.md +61 -16
  4. {adjeff-1.2.2 → adjeff-1.2.5}/pyproject.toml +1 -1
  5. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/loaders/maja_loader.py +1 -0
  6. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/loaders/product_loader.py +9 -3
  7. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/samplers/_brdf_sampler.py +1 -5
  8. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/samplers/rho_toa.py +1 -1
  9. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/samplers/rho_toa_sym.py +1 -1
  10. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/scene_module.py +4 -0
  11. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/sweep_sampler.py +32 -10
  12. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/reference/wu2024.py +0 -1
  13. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_atmo_samplers.py +60 -0
  14. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_loaders.py +48 -0
  15. {adjeff-1.2.2 → adjeff-1.2.5}/.gitattributes +0 -0
  16. {adjeff-1.2.2 → adjeff-1.2.5}/.github/workflows/ci.yml +0 -0
  17. {adjeff-1.2.2 → adjeff-1.2.5}/.github/workflows/publish.yml +0 -0
  18. {adjeff-1.2.2 → adjeff-1.2.5}/.gitignore +0 -0
  19. {adjeff-1.2.2 → adjeff-1.2.5}/.pre-commit-config.yaml +0 -0
  20. {adjeff-1.2.2 → adjeff-1.2.5}/LICENSE +0 -0
  21. {adjeff-1.2.2 → adjeff-1.2.5}/README.md +0 -0
  22. {adjeff-1.2.2 → adjeff-1.2.5}/docs/plan-observabilite-0.13.0.md +0 -0
  23. {adjeff-1.2.2 → adjeff-1.2.5}/docs/plan-smartg-2.0-0.15.0.md +0 -0
  24. {adjeff-1.2.2 → adjeff-1.2.5}/docs/revue-simplification-2026-08.md +0 -0
  25. {adjeff-1.2.2 → adjeff-1.2.5}/docs/roadmap.md +0 -0
  26. {adjeff-1.2.2 → adjeff-1.2.5}/notebooks/01-create-and-display-image.ipynb +0 -0
  27. {adjeff-1.2.2 → adjeff-1.2.5}/notebooks/02-atmospheric-configuration.ipynb +0 -0
  28. {adjeff-1.2.2 → adjeff-1.2.5}/notebooks/03-compute-radiative-quantities.ipynb +0 -0
  29. {adjeff-1.2.2 → adjeff-1.2.5}/notebooks/04-simulate-rho-toa.ipynb +0 -0
  30. {adjeff-1.2.2 → adjeff-1.2.5}/notebooks/05-learn-psf.ipynb +0 -0
  31. {adjeff-1.2.2 → adjeff-1.2.5}/notebooks/06-evaluate-a-psf.ipynb +0 -0
  32. {adjeff-1.2.2 → adjeff-1.2.5}/pixi.lock +0 -0
  33. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/__init__.py +0 -0
  34. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/_logging.py +0 -0
  35. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/accessor.py +0 -0
  36. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/analysis/__init__.py +0 -0
  37. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/analysis/_energy.py +0 -0
  38. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/analysis/_error.py +0 -0
  39. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/analysis/_plane.py +0 -0
  40. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/analysis/_radial.py +0 -0
  41. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/analysis/_vectorise.py +0 -0
  42. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/api/__init__.py +0 -0
  43. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/api/_config.py +0 -0
  44. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/api/_load.py +0 -0
  45. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/api/_pipeline.py +0 -0
  46. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/api/_psf.py +0 -0
  47. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/atmosphere/__init__.py +0 -0
  48. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/atmosphere/atmo_config.py +0 -0
  49. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/atmosphere/atmo_factory.py +0 -0
  50. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/atmosphere/geo_config.py +0 -0
  51. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/atmosphere/spectral_config.py +0 -0
  52. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/atmosphere/surface.py +0 -0
  53. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/core/__init__.py +0 -0
  54. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/core/_psf.py +0 -0
  55. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/core/analytical_psf.py +0 -0
  56. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/core/bands.py +0 -0
  57. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/core/image_dict.py +0 -0
  58. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/core/image_generator.py +0 -0
  59. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/core/non_analytical_psf.py +0 -0
  60. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/core/psf_tree.py +0 -0
  61. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/exceptions.py +0 -0
  62. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/__init__.py +0 -0
  63. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/classic/__init__.py +0 -0
  64. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/classic/toa_to_unif.py +0 -0
  65. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/classic/unif_to_toa.py +0 -0
  66. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/loaders/__init__.py +0 -0
  67. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/models/__init__.py +0 -0
  68. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/models/psf_conv_module.py +0 -0
  69. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/models/unif2surface.py +0 -0
  70. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/pipeline.py +0 -0
  71. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/samplers/__init__.py +0 -0
  72. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/samplers/_atmo_sampler.py +0 -0
  73. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/samplers/_ensure.py +0 -0
  74. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/samplers/_smartg.py +0 -0
  75. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/samplers/radiatives.py +0 -0
  76. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/samplers/rho_atm.py +0 -0
  77. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/samplers/sph_alb.py +0 -0
  78. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/samplers/sph_alb_brdf.py +0 -0
  79. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/samplers/tdif_down.py +0 -0
  80. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/samplers/tdif_up.py +0 -0
  81. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/samplers/tdif_up_brdf.py +0 -0
  82. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/samplers/tdir_down.py +0 -0
  83. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/samplers/tdir_up.py +0 -0
  84. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/modules/scene_source.py +0 -0
  85. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/optim/__init__.py +0 -0
  86. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/optim/_combo_stage.py +0 -0
  87. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/optim/_config.py +0 -0
  88. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/optim/adam_optimizer.py +0 -0
  89. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/optim/fit.py +0 -0
  90. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/optim/landscape.py +0 -0
  91. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/optim/lbfgs_optimizer.py +0 -0
  92. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/optim/loss.py +0 -0
  93. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/optim/metrics.py +0 -0
  94. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/optim/training_set.py +0 -0
  95. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/py.typed +0 -0
  96. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/reference/__init__.py +0 -0
  97. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/utils/__init__.py +0 -0
  98. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/utils/_config.py +0 -0
  99. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/utils/cache_store.py +0 -0
  100. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/utils/convolve.py +0 -0
  101. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/utils/radial.py +0 -0
  102. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/utils/smartgutils.py +0 -0
  103. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/utils/torchutils.py +0 -0
  104. {adjeff-1.2.2 → adjeff-1.2.5}/src/adjeff/utils/xrutils.py +0 -0
  105. {adjeff-1.2.2 → adjeff-1.2.5}/tests/_test_module.py +0 -0
  106. {adjeff-1.2.2 → adjeff-1.2.5}/tests/conftest.py +0 -0
  107. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_accessor.py +0 -0
  108. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_analysis.py +0 -0
  109. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_api_docs.py +0 -0
  110. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_api_psf.py +0 -0
  111. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_atmosphere_config.py +0 -0
  112. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_brdf_samplers.py +0 -0
  113. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_classic_modules.py +0 -0
  114. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_config.py +0 -0
  115. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_convolve.py +0 -0
  116. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_image_dict.py +0 -0
  117. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_image_generator.py +0 -0
  118. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_import.py +0 -0
  119. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_integration.py +0 -0
  120. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_landscape.py +0 -0
  121. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_logging.py +0 -0
  122. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_optim.py +0 -0
  123. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_psf.py +0 -0
  124. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_psf_tree.py +0 -0
  125. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_radial.py +0 -0
  126. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_scene_module.py +0 -0
  127. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_smartg_api.py +0 -0
  128. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_smartg_defaults.py +0 -0
  129. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_smartgutils.py +0 -0
  130. {adjeff-1.2.2 → adjeff-1.2.5}/tests/test_xrutils.py +0 -0
@@ -5,6 +5,40 @@ All notable changes to this project are documented here.
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## [1.2.5]
9
+
10
+ ### Fixed
11
+
12
+ - **A stray scalar coordinate no longer overwrites a swept axis.** A
13
+ sweep's dropped coordinates were collected from every coordinate an
14
+ input array carried, not just the ones indexing its own dimensions.
15
+ A field read back from a scene that already holds per-wavelength
16
+ results, e.g. for `fit_psf`'s own forward pass, inherits a scalar
17
+ `wl` tag from that scene; merged in, it silently replaced the real,
18
+ one-row-per-band `wl` axis, and the next lookup by wavelength raised
19
+ `ValueError: dimension 'wl' already exists as a scalar variable`.
20
+ - **A loader's atmosphere and geometry now survive a cache hit.**
21
+ `ProductLoader` declared a dynamic `output_vars` property to aggregate
22
+ what its mixins contribute, but `SceneModule`'s caching reads
23
+ `_output_vars`, a different name it never set; a cache write therefore
24
+ persisted nothing, and the next read restored an empty scene. The first
25
+ run of a script looked fine, since `_compute` writes straight into the
26
+ scene; only a second run, or any read of a pre-existing cache, raised
27
+ `MissingVariableError` on `aot`, `h`, `vza` and the rest.
28
+
29
+ ## [1.2.3]
30
+
31
+ ### Fixed
32
+
33
+ - **A radiative sweep over maps keeps the scene's coordinates.** xsweep
34
+ labels by position the dims it does not sweep, so a scene whose `aot`
35
+ and `h` are maps got its terms aligned on those labels: every pixel
36
+ came back NaN but the handful whose index matched one. `load_maja(...,
37
+ as_map=True, compute_radiatives=True)` could not produce anything.
38
+ - **A sweep restores its own coordinates.** A sampler used to do it
39
+ itself, in its own `_compute`, which is what the fix above forgot in
40
+ one of five places. There is now one place.
41
+
8
42
  ## [1.2.2]
9
43
 
10
44
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: adjeff
3
- Version: 1.2.2
3
+ Version: 1.2.5
4
4
  Summary: Library that provides utilities to simulate adjacency effects.
5
5
  Project-URL: Homepage, https://github.com/walcark/Adjeff
6
6
  Project-URL: Issues, https://github.com/walcark/Adjeff/issues
@@ -5,14 +5,15 @@
5
5
  3. [Atmosphere and geometry](#3-atmosphere-and-geometry)
6
6
  4. [Scene modules and pipelines](#4-scene-modules-and-pipelines)
7
7
  5. [Radiative samplers](#5-radiative-samplers)
8
- 6. [PSF models](#6-psf-models)
9
- 7. [Correction](#7-correction)
10
- 8. [Fitting a PSF](#8-fitting-a-psf)
11
- 9. [High-level API](#9-high-level-api)
12
- 10. [Analysis and the `.adjeff` accessor](#10-analysis-and-the-adjeff-accessor)
13
- 11. [Cache](#11-cache)
14
- 12. [Logging](#12-logging)
15
- 13. [Installation details](#13-installation-details)
8
+ 6. [Writing a sampler](#6-writing-a-sampler)
9
+ 7. [PSF models](#7-psf-models)
10
+ 8. [Correction](#8-correction)
11
+ 9. [Fitting a PSF](#9-fitting-a-psf)
12
+ 10. [High-level API](#10-high-level-api)
13
+ 11. [Analysis and the `.adjeff` accessor](#11-analysis-and-the-adjeff-accessor)
14
+ 12. [Cache](#12-cache)
15
+ 13. [Logging](#13-logging)
16
+ 14. [Installation details](#14-installation-details)
16
17
 
17
18
  ---
18
19
 
@@ -180,7 +181,51 @@ data, or Smart-G returns NaN, which the modules turn into an error.
180
181
 
181
182
  > Worked through in [notebook 03](../notebooks/03-compute-radiative-quantities.ipynb).
182
183
 
183
- ## 6. PSF models
184
+ ## 6. Writing a sampler
185
+
186
+ A sampler is a combination of a physics function and a sentence saying how to call it.
187
+ Four declarations are required, and then the base class does the rest: cache, batching,
188
+ deduplication, provenance, and putting the coordinates back.
189
+
190
+ ```python
191
+ class SphAlbSampler(AtmoSampler):
192
+ _output_vars = ["sph_alb"]
193
+ contract = "batch(aot, rh, h, href) vec(wl) -> sph_alb(wl)"
194
+ point_fn = staticmethod(sph_alb)
195
+ default_n_ph = 20000000
196
+ ```
197
+
198
+ `point_fn` is the physics: it takes one state and returns one result.
199
+ The contract says what a state is.
200
+
201
+ ### The contract
202
+
203
+ | Word | Meaning |
204
+ |---|---|
205
+ | `vec(...)` | these parameters travel together in one call, the physics gets arrays |
206
+ | `batch(...)` | these parameters are grouped into calls of `batch_size` states |
207
+ | `loop(...)` | one call per value, nothing else is possible |
208
+ | `-> name(dims)` | the variable written, and the dims it comes back with |
209
+
210
+ ### Writing the output
211
+
212
+ A sampler whose contract carries `wl` is swept once for every band. One
213
+ that reads the scene, like `RhoToaSymSampler`, sweeps per band instead
214
+ and passes the scene to the physics:
215
+
216
+ ```python
217
+ def _compute(self, scene):
218
+ for band in scene.bands:
219
+ scene[band][self._slot("rho_toa")] = self._sweep(
220
+ rho_s=scene[band], band=band
221
+ )
222
+ return scene
223
+ ```
224
+
225
+ What is bound this way stays out of the sweep space, so the physics can
226
+ read a map the sweep knows nothing about.
227
+
228
+ ## 7. PSF models
184
229
 
185
230
  Analytical PSFs are `torch.nn.Module`s with bounded parameters, sampled
186
231
  on an odd, square `PSFGrid`.
@@ -213,7 +258,7 @@ psf_params(tree, S2Band.B02) # {"sigma": DataArray}
213
258
  tree.to_zarr("psf.zarr", mode="w")
214
259
  ```
215
260
 
216
- ## 7. Correction
261
+ ## 8. Correction
217
262
 
218
263
  For a uniform surface, `Unif2Toa` applies the 5S formula and `Toa2Unif`
219
264
  inverts it, giving `rho_unif`, the reflectance a uniform surface would
@@ -234,7 +279,7 @@ from adjeff.modules.models import Unif2Surface
234
279
  scene = Unif2Surface(kernels=tree)(scene)
235
280
  ```
236
281
 
237
- ## 8. Fitting a PSF
282
+ ## 9. Fitting a PSF
238
283
 
239
284
  `fit` optimises the PSF of a model over reference scenes, once per
240
285
  band and atmospheric state, and returns the frozen tree.
@@ -275,7 +320,7 @@ encircled-energy radii over a list of PSFs, e.g. a parameter grid.
275
320
 
276
321
  > Worked through in [notebook 05](../notebooks/05-learn-psf.ipynb).
277
322
 
278
- ## 9. High-level API
323
+ ## 10. High-level API
279
324
 
280
325
  `adjeff` exports one-call functions for the common paths:
281
326
 
@@ -294,7 +339,7 @@ encircled-energy radii over a list of PSFs, e.g. a parameter grid.
294
339
 
295
340
  > Worked through in [notebook 04](../notebooks/04-simulate-rho-toa.ipynb).
296
341
 
297
- ## 10. Analysis and the `.adjeff` accessor
342
+ ## 11. Analysis and the `.adjeff` accessor
298
343
 
299
344
  `adjeff.analysis` quantifies a PSF or a 2-D field:
300
345
 
@@ -322,7 +367,7 @@ da.adjeff.untidy() # and back, before xr.merge
322
367
 
323
368
  > Worked through in [notebook 06](../notebooks/06-evaluate-a-psf.ipynb).
324
369
 
325
- ## 11. Cache
370
+ ## 12. Cache
326
371
 
327
372
  Every module takes a `CacheStore`. Results are stored in Zarr, keyed by
328
373
  the module, its configuration and the provenance of its inputs; an
@@ -340,7 +385,7 @@ scene = pipeline(scene) # read back, no GPU call
340
385
 
341
386
  Without a cache, every output stays in RAM.
342
387
 
343
- ## 12. Logging
388
+ ## 13. Logging
344
389
 
345
390
  Adjeff prints nothing until asked:
346
391
 
@@ -362,7 +407,7 @@ are capped at `warning`, and Python warnings join the stream. Lines are
362
407
  Everything is an ordinary `logging` record under `adjeff`, so an
363
408
  application's own handlers receive it.
364
409
 
365
- ## 13. Installation details
410
+ ## 14. Installation details
366
411
 
367
412
  | Environment | GPU | Contents |
368
413
  |---|---|---|
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adjeff"
3
- version = "1.2.2"
3
+ version = "1.2.5"
4
4
  authors = [{ name = "walcark", email = "kevin.walcarius@gmail.com" }]
5
5
  readme = "README.md"
6
6
  description = "Library that provides utilities to simulate adjacency effects."
@@ -195,6 +195,7 @@ class MajaLoader(
195
195
 
196
196
  with rasterio.open(glob_mnt[0]) as src:
197
197
  arr = src.read(1).astype(float)
198
+ arr = np.where(arr < 0.0, 0.0, arr)
198
199
 
199
200
  if self.as_map:
200
201
  return xr.DataArray(
@@ -116,13 +116,19 @@ class ProductLoader(SceneSource, ABC):
116
116
  _BASE_OUTPUT_VARS: ClassVar[list[str]] = ["rho_s"]
117
117
 
118
118
  @property
119
- def output_vars(self) -> list[str]:
120
- """Aggregate output slots declared by active mixins."""
119
+ def _output_vars(self) -> list[str]: # type: ignore[override]
120
+ """Aggregate output roles declared by active mixins.
121
+
122
+ Roles, not slots: ``SceneModule`` turns these into slots itself,
123
+ for caching, provenance and the public ``output_vars`` it reads
124
+ them from. A plain ``ClassVar`` cannot do this, since which
125
+ mixins are active is only known once this class is built.
126
+ """
121
127
  roles = list(self._BASE_OUTPUT_VARS)
122
128
  for cls in type(self).__mro__:
123
129
  contrib: list[str] = cls.__dict__.get("_output_vars_contribution", [])
124
130
  roles.extend(contrib)
125
- return [self._slot(role) for role in roles]
131
+ return roles
126
132
 
127
133
  def __init__(
128
134
  self,
@@ -126,11 +126,7 @@ class BrdfSampler(AtmoSampler):
126
126
  name = self._output_vars[0]
127
127
  for band in self.spectral_config.bands:
128
128
  ds = scene[band]
129
- # Coordinates first: the algebra below aligns by label, and
130
- # xsweep numbers a dim it did not sweep from zero, which no
131
- # longer matches the scene's own labels.
132
- arr = self._restore_coords(raw.sel(wl=band.wl_nm), ds)
133
- ds[self._slot(name)] = self._normalise(arr, ds)
129
+ ds[self._slot(name)] = self._normalise(raw.sel(wl=band.wl_nm), ds)
134
130
  return scene
135
131
 
136
132
  def _t_sun(self, ds: xr.Dataset) -> xr.DataArray:
@@ -140,6 +140,6 @@ class RhoToaSampler(SweepSampler):
140
140
  # the two travel together rather than through the sweep space.
141
141
  for band in scene.bands:
142
142
  arr = self._sweep(rho_s=scene[band], band=band)
143
- scene[band]["rho_toa"] = self._restore_coords(arr, scene[band])
143
+ scene[band]["rho_toa"] = arr
144
144
 
145
145
  return scene
@@ -125,6 +125,6 @@ class RhoToaSymSampler(SweepSampler):
125
125
  # the two travel together rather than through the sweep space.
126
126
  for band in scene.bands:
127
127
  arr = self._sweep(rho_s=scene[band], band=band)
128
- scene[band][self._slot("rho_toa")] = self._restore_coords(arr, scene[band])
128
+ scene[band][self._slot("rho_toa")] = arr
129
129
 
130
130
  return scene
@@ -133,6 +133,9 @@ class SceneModule:
133
133
 
134
134
  outcome["cached"] = False
135
135
  scene = self._compute(scene)
136
+ for band in scene.bands:
137
+ ds = scene[band]
138
+ print(ds)
136
139
  self._reject_non_finite(scene)
137
140
  self._stamp_provenance(scene, key)
138
141
  self._cache.save_vars(key, self._role_view(scene), self._output_vars)
@@ -162,6 +165,7 @@ class SceneModule:
162
165
  values = ds[slot]
163
166
  if values.dtype.kind not in "fc":
164
167
  continue
168
+ print(values)
165
169
  # Reduced rather than materialised: a lazy output is read
166
170
  # back one chunk at a time instead of all at once.
167
171
  bad = int((~np.isfinite(values)).sum())
@@ -78,14 +78,26 @@ class SweepSampler(SceneModule):
78
78
  def _space(self) -> xr.Dataset:
79
79
  """Collect the swept parameters from the configs into one Dataset.
80
80
 
81
- Coordinates are dropped: two arrays sharing a spatial dim would
82
- otherwise claim conflicting labels for it.
81
+ Coordinates are dropped, two arrays sharing a spatial dim would
82
+ otherwise claim conflicting labels for it, and kept aside for
83
+ :meth:`_sweep` to put back.
83
84
  """
84
85
  wanted = set(self._contract.inputs)
85
86
  arrays: dict[str, xr.DataArray] = {}
87
+ self._dropped: dict[str, xr.DataArray] = {}
86
88
  for config in self._get_configs():
87
89
  for name, array in config._arrays.items():
88
90
  if name in wanted and name not in arrays:
91
+ # Only an array's own dimension coordinates: a scalar
92
+ # tag of the same name, picked up from an earlier
93
+ # pipeline stage sharing this Dataset, is not one.
94
+ self._dropped.update(
95
+ {
96
+ str(d): array.coords[d]
97
+ for d in array.dims
98
+ if d in array.coords
99
+ }
100
+ )
89
101
  arrays[name] = array.drop_vars(list(array.coords), errors="ignore")
90
102
  return xr.Dataset(arrays)
91
103
 
@@ -96,19 +108,29 @@ class SweepSampler(SceneModule):
96
108
 
97
109
  return coerce(type(self).contract)
98
110
 
99
- @staticmethod
100
- def _restore_coords(arr: xr.DataArray, source: xr.Dataset) -> xr.DataArray:
101
- """Give *arr* the coordinates *source* holds for its dims.
111
+ def _restore_coords(
112
+ self, arr: xr.DataArray, source: xr.Dataset | None = None
113
+ ) -> xr.DataArray:
114
+ """Give *arr* back the coordinates the sweep space was stripped of.
102
115
 
103
116
  xsweep labels the dims it does not sweep (``y``, ``x``) by
104
117
  position; assigned as they are, they would misalign with the
105
- scene and leave NaN.
118
+ scene and leave NaN. *source* names the scene the physics was
119
+ bound to, whose own dims are not in the space.
106
120
  """
107
121
  shared = {
108
- str(dim): source.coords[dim]
109
- for dim in arr.dims
110
- if dim in source.coords and dim in source.dims
122
+ name: coord
123
+ for name, coord in getattr(self, "_dropped", {}).items()
124
+ if name in arr.dims
111
125
  }
126
+ if source is not None:
127
+ shared.update(
128
+ {
129
+ str(dim): source.coords[dim]
130
+ for dim in arr.dims
131
+ if dim in source.coords and dim in source.dims
132
+ }
133
+ )
112
134
  return arr.assign_coords(shared) if shared else arr
113
135
 
114
136
  def _sweep(self, **bound: Any) -> xr.DataArray:
@@ -137,7 +159,7 @@ class SweepSampler(SceneModule):
137
159
  self._log.info("sweep.plan", **plan)
138
160
  result = sweeper(space, policy=self._policy(space, bound), **statics)
139
161
  out: xr.DataArray = result[self._contract.outputs[0]]
140
- return out
162
+ return self._restore_coords(out, bound.get("rho_s"))
141
163
 
142
164
  _SPATIAL: ClassVar[frozenset[str]] = frozenset({"x", "y", "x_psf", "y_psf"})
143
165
 
@@ -125,7 +125,6 @@ class WuPsfSampler(SweepSampler):
125
125
  for band in scene.bands:
126
126
  ds = scene[band]
127
127
  arr = self._sweep(rho_s=ds, band=band)
128
- arr = self._restore_coords(arr, ds)
129
128
  # The store allocates from the contract and labels a dimension
130
129
  # it never swept by position, so the grid is put back here.
131
130
  grid = PSFGrid(res=ds["rho_s"].adjeff.res, n=ds["rho_s"].adjeff.n)
@@ -305,3 +305,63 @@ class TestSweepPolicy:
305
305
  for ds in scenes
306
306
  ]
307
307
  assert stores[0] != stores[1]
308
+
309
+ @pytest.mark.integration
310
+ def test_a_mapped_sweep_keeps_the_scene_coordinates(self):
311
+ """Every pixel gets a value, not the few whose index matches a label."""
312
+ import numpy as np
313
+
314
+ from adjeff.api import run_radiatives_from_scene
315
+ from adjeff.core import gaussian_image_dict
316
+
317
+ band, size = S2Band.B02, 5
318
+ scene = gaussian_image_dict(sigma=1.0, res_km=0.5, bands=[band], n=size)
319
+ ds = scene[band]
320
+ rng = np.random.default_rng(0)
321
+ ds["aot"] = (("y", "x"), rng.uniform(0.05, 0.4, (size, size)))
322
+ ds["h"] = (("y", "x"), np.zeros((size, size)))
323
+ for name, value in (
324
+ ("rh", 50.0),
325
+ ("href", 2.0),
326
+ ("sza", 17.0),
327
+ ("saa", 117.0),
328
+ ("vza", 3.2),
329
+ ("vaa", 207.0),
330
+ ):
331
+ ds[name] = xr.DataArray([value], dims=[name])
332
+ scene[band] = ds
333
+
334
+ pytest.importorskip("smartg")
335
+ out = run_radiatives_from_scene(scene, n_bins=2)
336
+ assert bool(np.isfinite(out[band]["tdir_down"]).all())
337
+
338
+ def test_a_stray_scalar_coordinate_does_not_overwrite_a_real_axis(self):
339
+ """An array carrying a stray same-named scalar coord keeps the real one."""
340
+ from adjeff.modules.samplers import TdirDownSampler
341
+
342
+ spectral = atmo.SpectralConfig.from_bands([S2Band.B8A])
343
+ geo = atmo.GeoConfig(
344
+ sza=xr.DataArray([17.0], dims=["sza"]),
345
+ vza=xr.DataArray([3.0], dims=["vza"]),
346
+ saa=xr.DataArray([117.0], dims=["saa"]),
347
+ vaa=xr.DataArray([207.0], dims=["vaa"]),
348
+ )
349
+ # aot carries a stray scalar 'wl' coordinate, as if inherited from
350
+ # a Dataset that already held per-wl radiative terms.
351
+ aot = xr.DataArray([0.2], dims=["aot"]).assign_coords(wl=865.0)
352
+ sampler = TdirDownSampler(
353
+ atmo_config=atmo.AtmoConfig(
354
+ aot=aot,
355
+ rh=xr.DataArray([50.0], dims=["rh"]),
356
+ h=xr.DataArray([0.1], dims=["h"]),
357
+ href=xr.DataArray([2.0], dims=["href"]),
358
+ species={"sulphate": 1.0},
359
+ ),
360
+ geo_config=geo,
361
+ spectral_config=spectral,
362
+ remove_rayleigh=False,
363
+ )
364
+ sampler._space()
365
+ wl = sampler._dropped["wl"]
366
+ assert wl.dims == ("wl",)
367
+ assert float(wl.values[0]) == 865.0
@@ -79,3 +79,51 @@ class TestMissingInputs:
79
79
 
80
80
  with pytest.raises(ImageIOError, match="Sun angles"):
81
81
  loader._sza_saa()
82
+
83
+
84
+ class TestOutputVarsCaching:
85
+ """A loader's mixin-contributed variables must round-trip through cache."""
86
+
87
+ def test_atmosphere_and_geometry_survive_a_cache_hit(self, tmp_path):
88
+ """The second call, reading the cache, still carries aot, h, vza..."""
89
+ from adjeff.core import S2Band
90
+ from adjeff.modules.loaders.product_loader import (
91
+ AtmosphereMixin,
92
+ ElevationMixin,
93
+ GeometryMixin,
94
+ ProductLoader,
95
+ )
96
+ from adjeff.utils import CacheStore
97
+
98
+ class FullLoader(GeometryMixin, AtmosphereMixin, ElevationMixin, ProductLoader):
99
+ def __init__(self, product_path, bands, res, cache=None):
100
+ AtmosphereMixin.__init__(self)
101
+ ProductLoader.__init__(self, product_path, bands, res, cache=cache)
102
+
103
+ def reflectance(self, band):
104
+ return _make_ref()
105
+
106
+ def _vza_vaa(self, band):
107
+ return xr.DataArray([3.0]), xr.DataArray([200.0])
108
+
109
+ def _sza_saa(self):
110
+ return xr.DataArray([17.0]), xr.DataArray([117.0])
111
+
112
+ def _aot(self, ref):
113
+ return xr.DataArray([0.2])
114
+
115
+ def _rh(self):
116
+ return xr.DataArray([50.0])
117
+
118
+ def _h(self, ref):
119
+ return xr.DataArray([0.1])
120
+
121
+ cache = CacheStore(tmp_path)
122
+ product_path = tmp_path / "product"
123
+ product_path.mkdir()
124
+ bands = [S2Band.B02]
125
+
126
+ for _ in range(2): # first run computes, second reads the cache
127
+ scene = FullLoader(product_path, bands, res=0.12, cache=cache)()
128
+ for role in ("rho_s", "aot", "rh", "href", "h", "vza", "vaa", "sza", "saa"):
129
+ assert role in scene[S2Band.B02], role
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes