radphen-iadpython 0.7.0__py3-none-any.whl

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.
iadpython/agrid.py ADDED
@@ -0,0 +1,306 @@
1
+ """Adaptive grid helper for inverse adding-doubling calculations.
2
+
3
+ `AGrid` builds an adaptive 2D grid for the two-parameter search modes:
4
+ `find_ab`, `find_ag`, and `find_bg`. Corner values are recursively sampled until
5
+ the local variation in `(ur1 + ut1)` falls below a tolerance, which typically
6
+ requires fewer RT evaluations than a dense uniform grid.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import matplotlib.pyplot as plt
12
+ import numpy as np
13
+
14
+ from .cache import Cache
15
+
16
+
17
+ class AGrid:
18
+ """Adaptive cache-backed grid for 2-parameter inversion warm starts."""
19
+
20
+ # for each mode, which axis is held fixed (and which two vary)
21
+ _modes = {
22
+ "find_ab": ("g", ("a", "b")),
23
+ "find_ag": ("b", ("a", "g")),
24
+ "find_bg": ("a", ("b", "g")),
25
+ }
26
+
27
+ # overall ranges for each axis
28
+ _ranges = {
29
+ "a": (0.0, 1.0),
30
+ "b": (0.0, 10.0),
31
+ "g": (-0.99, 0.99),
32
+ }
33
+
34
+ def __init__(
35
+ self,
36
+ exp=None,
37
+ search: str | None = None,
38
+ tol: float = 0.03,
39
+ max_depth: int = 6,
40
+ min_depth: int = 2,
41
+ default: float | None = None,
42
+ ):
43
+ """Initialize adaptive grid.
44
+
45
+ Args:
46
+ exp: Optional experiment. If provided with `search`, the grid is built.
47
+ search: Search mode (`find_ab`, `find_ag`, or `find_bg`).
48
+ tol: Subdivision threshold for center interpolation error of `(ur1 + ut1)`.
49
+ max_depth: Maximum recursion depth for quadtree subdivision.
50
+ min_depth: Minimum enforced subdivision depth.
51
+ default: Fixed-axis value for the selected search mode.
52
+ """
53
+ self.cache = Cache()
54
+ self.exp = exp
55
+ self.search = search
56
+ self.default = default
57
+ self.tol = tol
58
+ self.max_depth = max_depth
59
+ self.min_depth = min_depth
60
+ self.fixed_axis = None
61
+ self.vary_axes = None
62
+
63
+ if search is not None:
64
+ self._set_mode(search)
65
+
66
+ if exp is not None and search is not None:
67
+ self.calc(exp, default=default, search=search)
68
+
69
+ def _set_mode(self, search: str) -> None:
70
+ """Set fixed and varying axes based on search mode."""
71
+ try:
72
+ self.fixed_axis, self.vary_axes = self._modes[search]
73
+ except KeyError as exc:
74
+ raise ValueError(f"Unknown search mode: {search!r}") from exc
75
+ self.search = search
76
+
77
+ def _default_for_search(self) -> float:
78
+ """Return fixed-axis value for current search mode."""
79
+ if self.exp is None:
80
+ raise ValueError("AGrid.calc requires an experiment")
81
+ if self.search == "find_ab":
82
+ return self.exp.sample.g
83
+ if self.search == "find_ag":
84
+ return self.exp.sample.b
85
+ if self.search == "find_bg":
86
+ return self.exp.sample.a
87
+ raise ValueError(f"Unsupported search mode: {self.search!r}")
88
+
89
+ def calc(self, exp, default: float | None = None, search: str | None = None):
90
+ """Build (or rebuild) adaptive grid for an experiment.
91
+
92
+ Args:
93
+ exp: Experiment instance used to evaluate RT.
94
+ default: Fixed-axis value for the selected search mode.
95
+ search: Optional search mode override.
96
+ """
97
+ self.exp = exp
98
+ if search is None:
99
+ search = self.search or exp.search
100
+ self._set_mode(search)
101
+ self.default = self._default_for_search() if default is None else default
102
+ self.cache = Cache()
103
+ self.build()
104
+
105
+ def __str__(self):
106
+ """Return basic details as a string for printing."""
107
+ s = "---------------- Grid ---------------\n"
108
+ if self.search is None:
109
+ s += "search = None\n"
110
+ else:
111
+ s += "search = %s\n" % self.search
112
+
113
+ if self.default is None:
114
+ s += "default = None\n"
115
+ else:
116
+ s += "default = %.5f\n" % self.default
117
+
118
+ s += "entries = %d" % len(self.cache)
119
+ return s
120
+
121
+ def eval_f(self, a: float, b: float, g: float) -> None:
122
+ """Memoised evaluation that fills the cache if missing."""
123
+ if self.cache.get(a, b, g) is None:
124
+ self.exp.sample.a = a
125
+ self.exp.sample.b = b
126
+ self.exp.sample.g = g
127
+ ur1, ut1, uru, utu = self.exp.sample.rt()
128
+ self.cache.put(a, b, g, ur1, ut1, uru, utu)
129
+
130
+ def _sum_val(self, a: float, b: float, g: float) -> float:
131
+ """Ensure cached, then return ur1+ut1."""
132
+ self.eval_f(a, b, g)
133
+ ur1, ut1, _, _ = self.cache.get(a, b, g)
134
+ return ur1 + ut1
135
+
136
+ def _subdivide(self, x0, x1, y0, y1, depth, collect):
137
+ """Recursively subdivide the rectangle [x0,x1]×[y0,y1]."""
138
+ xm = 0.5 * (x0 + x1)
139
+ ym = 0.5 * (y0 + y1)
140
+
141
+ # corners
142
+ v00 = self._sum_val(*self._make_args(x0, y0))
143
+ v10 = self._sum_val(*self._make_args(x1, y0))
144
+ v01 = self._sum_val(*self._make_args(x0, y1))
145
+ v11 = self._sum_val(*self._make_args(x1, y1))
146
+ vcc = self._sum_val(*self._make_args(xm, ym))
147
+
148
+ interp = 0.25 * (v00 + v10 + v01 + v11)
149
+ err = np.abs(vcc - interp)
150
+ need_split = (depth < self.min_depth) or (err > self.tol and depth < self.max_depth)
151
+
152
+ if need_split:
153
+ # recurse into four sub-quads
154
+ for nx0, nx1, ny0, ny1 in [
155
+ (x0, xm, y0, ym),
156
+ (xm, x1, y0, ym),
157
+ (x0, xm, ym, y1),
158
+ (xm, x1, ym, y1),
159
+ ]:
160
+ self._subdivide(nx0, nx1, ny0, ny1, depth + 1, collect)
161
+ else:
162
+ # accept corners and centre for the leaf cell
163
+ collect.update(
164
+ {
165
+ (x0, y0),
166
+ (x1, y0),
167
+ (x0, y1),
168
+ (x1, y1),
169
+ (xm, ym),
170
+ }
171
+ )
172
+
173
+ def _make_args(self, u, v):
174
+ """Build (a,b,g) from the 2D coords (u,v) and the fixed axis."""
175
+ if self.fixed_axis == "g":
176
+ return (u, v, self.default)
177
+ if self.fixed_axis == "b":
178
+ return (u, self.default, v)
179
+ if self.fixed_axis == "a":
180
+ return (self.default, u, v)
181
+ raise ValueError(f"Bad fixed axis: {self.fixed_axis!r}")
182
+
183
+ def build(self) -> None:
184
+ """
185
+ Build an adaptive quadtree.
186
+
187
+ The quadtree is created over the two free axes determined by `search`,
188
+ subdividing until center interpolation error in `(ur1 + ut1)` is below
189
+ `tol` (after `min_depth`) or `max_depth` is reached.
190
+ """
191
+ if self.search is None or self.fixed_axis is None:
192
+ raise ValueError("Search mode must be set before build()")
193
+ if self.default is None:
194
+ raise ValueError("Fixed-axis default must be set before build()")
195
+ if self.exp is None:
196
+ raise ValueError("Experiment must be set before build()")
197
+
198
+ axis0, axis1 = self.vary_axes
199
+
200
+ # grab the ranges for the two free axes
201
+ (u0, u1), (v0, v1) = self._ranges[axis0], self._ranges[axis1]
202
+
203
+ # collect the (u,v) corner points that meet the tolerance
204
+ uv_points: set[tuple[float, float]] = set()
205
+ self._subdivide(u0, u1, v0, v1, depth=0, collect=uv_points)
206
+
207
+ for u, v in uv_points:
208
+ a, b, g = self._make_args(u, v)
209
+ self.eval_f(a, b, g)
210
+
211
+ def min_abg(self, mr, mt):
212
+ """Find closest a, b, g closest to mr and mt."""
213
+ minimum = float("inf")
214
+ a_min = 0
215
+ b_min = 0
216
+ g_min = 0
217
+
218
+ for a, b, g, ur1, ut1, _uru, _utu in self.cache:
219
+ delta = np.abs(mr - ur1) + np.abs(mt - ut1)
220
+ if delta < minimum:
221
+ minimum = delta
222
+ a_min, b_min, g_min = a, b, g
223
+
224
+ if minimum == float("inf"):
225
+ raise RuntimeError("AGrid cache is empty. Call calc() first.")
226
+ return a_min, b_min, g_min
227
+
228
+ def is_stale(self, default_value, search: str | None = None):
229
+ """Decide if current grid is still useful."""
230
+ if self.default is None:
231
+ return True
232
+ if search is not None and self.search != search:
233
+ return True
234
+ if self.default != default_value:
235
+ return True
236
+ return False
237
+
238
+ def square_grid(self, N=21):
239
+ """Return a square grid."""
240
+ aa = np.linspace(self._ranges["a"][0], self._ranges["a"][1], N)
241
+ bb = np.linspace(self._ranges["b"][0], self._ranges["b"][1], N)
242
+ gg = np.linspace(self._ranges["g"][0], self._ranges["g"][1], N)
243
+
244
+ if self.search == "find_ab":
245
+ ggg = np.full((N, N), self.default)
246
+ aaa, bbb = np.meshgrid(aa, bb)
247
+ elif self.search == "find_bg":
248
+ aaa = np.full((N, N), self.default)
249
+ ggg, bbb = np.meshgrid(gg, bb)
250
+ elif self.search == "find_ag":
251
+ bbb = np.full((N, N), self.default)
252
+ aaa, ggg = np.meshgrid(aa, gg)
253
+ else:
254
+ raise ValueError(f"Unknown search mode: {self.search!r}")
255
+
256
+ ur1 = np.zeros((N, N))
257
+ ut1 = np.zeros((N, N))
258
+ for i in range(N):
259
+ for j in range(N):
260
+ self.exp.sample.a = aaa[i, j]
261
+ self.exp.sample.b = bbb[i, j]
262
+ self.exp.sample.g = ggg[i, j]
263
+ ur1[i, j], ut1[i, j], _, _ = self.exp.sample.rt()
264
+
265
+ return aaa, bbb, ggg, ur1, ut1
266
+
267
+ def plot(self):
268
+ """Plot the grid."""
269
+ # turn the cache into a (N×7) array
270
+ data = np.array(list(self.cache))
271
+ if data.size == 0:
272
+ raise RuntimeError("Cache is empty – run calc() first")
273
+
274
+ a, b, g, _ur1, _ut1, _uru, _utu = data.T
275
+
276
+ A, B, G, R, T = self.square_grid()
277
+ Z = R + T
278
+ # for i in range(21):
279
+ # for j in range(21):
280
+ # print("%8.4f %8.4f %8.4f %8.4f" % (A[i,j], B[i,j], G[i,j], Z[i,j]))
281
+
282
+ if self.search == "find_ab":
283
+ plt.plot(b, a, "sb", markersize=1)
284
+ plt.ylabel("albedo [-]")
285
+ plt.xlabel("optical thickness [-]")
286
+ plt.pcolormesh(B, A, Z, cmap="gray")
287
+
288
+ if self.search == "find_bg":
289
+ plt.plot(b, g, "sb", markersize=1)
290
+ plt.ylabel("anisotropy [-]")
291
+ plt.xlabel("optical thickness [-]")
292
+ plt.pcolormesh(B, G, Z, cmap="gray")
293
+
294
+ if self.search == "find_ag":
295
+ plt.plot(a, g, "sb", markersize=1)
296
+ plt.xlabel("albedo [-]")
297
+ plt.ylabel("anisotropy [-]")
298
+ plt.pcolormesh(A, G, Z, cmap="gray")
299
+
300
+ def __len__(self):
301
+ """Return number of sampled points in the adaptive cache."""
302
+ return len(self.cache)
303
+
304
+ def __iter__(self):
305
+ """Iterate over cached adaptive grid entries."""
306
+ return iter(self.cache)
iadpython/cache.py ADDED
@@ -0,0 +1,133 @@
1
+ """
2
+ Light-weight (a, b, g) → (ur1, ut1, uru, utu) cache.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ from dataclasses import dataclass
7
+ import struct
8
+ from typing import Dict, Iterator, Tuple, Optional
9
+
10
+ _FNV_OFFSET = 0xCBF29CE484222325
11
+ _FNV_PRIME = 0x100000001B3
12
+
13
+
14
+ def _bits(f: float) -> int:
15
+ """Return the IEEE-754 bit pattern of *f* as an unsigned 64-bit int."""
16
+ return struct.unpack("<Q", struct.pack("<d", f))[0]
17
+
18
+
19
+ def fnv1a_hash(a: float, b: float, g: float) -> int:
20
+ """64-bit FNV-1a hash of the 3 doubles (identical to the C code)."""
21
+ h = _FNV_OFFSET
22
+ for x in (a, b, g):
23
+ h ^= _bits(x)
24
+ h = (h * _FNV_PRIME) & 0xFFFFFFFFFFFFFFFF # stay in 64-bit range
25
+ return h
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class Key:
30
+ """Cache key for the `(a, b, g)` optical parameter triple.
31
+
32
+ Attributes:
33
+ a: Single-scattering albedo.
34
+ b: Optical thickness.
35
+ g: Scattering anisotropy.
36
+ """
37
+
38
+ a: float
39
+ b: float
40
+ g: float
41
+
42
+ def __hash__(self) -> int: # Python calls this automatically
43
+ """Return a stable hash compatible with the C cache implementation.
44
+
45
+ Returns:
46
+ A 64-bit FNV-1a hash for `(a, b, g)`.
47
+ """
48
+ return fnv1a_hash(self.a, self.b, self.g)
49
+
50
+ def __iter__(self):
51
+ """Iterate over key components in `(a, b, g)` order.
52
+
53
+ Yields:
54
+ The `a`, `b`, and `g` values.
55
+ """
56
+ yield from (self.a, self.b, self.g)
57
+
58
+
59
+ class Cache:
60
+ """Dictionary-backed cache mapping `(a, b, g)` to reflectance/transmittance.
61
+
62
+ Cached values are stored as `(ur1, ut1, uru, utu)` tuples and keyed by
63
+ :class:`Key` so hash behavior matches the corresponding C implementation.
64
+ """
65
+
66
+ def __init__(self):
67
+ """Initialize an empty cache."""
68
+ self._data: Dict[Key, Tuple[float, float, float, float]] = {}
69
+
70
+ def put(
71
+ self,
72
+ a: float,
73
+ b: float,
74
+ g: float,
75
+ ur1: float,
76
+ ut1: float,
77
+ uru: float,
78
+ utu: float,
79
+ ) -> None:
80
+ """Insert or overwrite the cache entry for a parameter triple.
81
+
82
+ Args:
83
+ a: Single-scattering albedo.
84
+ b: Optical thickness.
85
+ g: Scattering anisotropy.
86
+ ur1: Total reflection for collimated incidence.
87
+ ut1: Total transmission for collimated incidence.
88
+ uru: Total reflection for diffuse incidence.
89
+ utu: Total transmission for diffuse incidence.
90
+ """
91
+ self._data[Key(a, b, g)] = (ur1, ut1, uru, utu)
92
+
93
+ def get(self, a: float, b: float, g: float) -> Optional[Tuple[float, float, float, float]]:
94
+ """Fetch a cached result for a parameter triple.
95
+
96
+ Args:
97
+ a: Single-scattering albedo.
98
+ b: Optical thickness.
99
+ g: Scattering anisotropy.
100
+
101
+ Returns:
102
+ The tuple `(ur1, ut1, uru, utu)` if present; otherwise `None`.
103
+ """
104
+ return self._data.get(Key(a, b, g))
105
+
106
+ # helpers mirroring the C extras
107
+ def __len__(self):
108
+ """Return the number of cache entries.
109
+
110
+ Returns:
111
+ The current cache size.
112
+ """
113
+ return len(self._data) # cache_size()
114
+
115
+ def __iter__(
116
+ self,
117
+ ) -> Iterator[Tuple[float, float, float, float, float, float, float]]:
118
+ """Iterate over cache records.
119
+
120
+ Yields:
121
+ Tuples of `(a, b, g, ur1, ut1, uru, utu)` for each entry.
122
+ """
123
+ for k, v in self._data.items():
124
+ yield (*k, *v)
125
+
126
+ # convenience for testing / demo
127
+ def __repr__(self) -> str:
128
+ """Return a concise debug representation of the cache.
129
+
130
+ Returns:
131
+ A string containing the cache size.
132
+ """
133
+ return f"Cache(n={len(self)})"