foscat 2025.8.4__py3-none-any.whl → 2025.9.3__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.
foscat/HealBili.py ADDED
@@ -0,0 +1,309 @@
1
+ """
2
+ HealBili: Bilinear weights from a curvilinear (theta, phi) source grid to arbitrary HEALPix targets.
3
+
4
+ This module provides a class `HealBili` that, given a *curvilinear* source grid of angular
5
+ coordinates (theta[y,x], phi[y,x]) on the sphere (e.g., a tangent-plane grid built on an ellipsoid),
6
+ computes **bilinear interpolation weights** to map values from that grid onto arbitrary target
7
+ directions specified by HEALPix angles (heal_theta[n], heal_phi[n]).
8
+
9
+ Key idea
10
+ --------
11
+ Because the source grid is not rectilinear in index-space, we cannot assume a simple affine mapping
12
+ from (i,j) to angles. Instead, for each target direction (theta_h, phi_h), we:
13
+ 1) locate a nearby source cell (seed) by nearest neighbor search on the unit sphere;
14
+ 2) consider up to 4 candidate quads around the seed: [(i0,j0),(i0+1,j0),(i0,j0+1),(i0+1,j0+1)];
15
+ 3) project the 4 corner unit vectors and the target onto a **local tangent plane** built at the
16
+ quad barycenter;
17
+ 4) *invert* the bilinear mapping f(s,t) from the quad corners to the plane point using Newton,
18
+ retrieving (s,t) in [0,1]^2;
19
+ 5) build the 4 bilinear weights [(1-s)(1-t), s(1-t), (1-s)t, st] and the 4 linear indices
20
+ into the source image (row-major, j*W + i).
21
+
22
+ If no candidate quad cleanly contains the point, we choose the one with the smallest residual in the
23
+ plane and clamp (s,t) to [0,1].
24
+
25
+ The code is NumPy-only by default, but can optionally use `scipy.spatial.cKDTree` for a faster nearest
26
+ neighbor seed search by setting `prefer_kdtree=True` (falls back automatically if SciPy is absent).
27
+
28
+ Usage
29
+ -----
30
+ >>> hb = HealBili(src_theta, src_phi, prefer_kdtree=True)
31
+ >>> I, W = hb.compute_weights(heal_theta, heal_phi)
32
+ >>> # Apply to a source image `img` of shape (H,W):
33
+ >>> vals = hb.apply_weights(img, I, W) # shape (N,)
34
+
35
+ All angles must be in **radians**. theta is colatitude (0 at north pole), phi is longitude in [0, 2*pi).
36
+ """
37
+ from __future__ import annotations
38
+
39
+ from typing import Tuple
40
+ import healpy as hp
41
+ import numpy as np
42
+
43
+ try:
44
+ from scipy.spatial import cKDTree # optional
45
+ _HAVE_SCIPY = True
46
+ except Exception: # pragma: no cover
47
+ cKDTree = None
48
+ _HAVE_SCIPY = False
49
+
50
+
51
+ class HealBili:
52
+ """Compute bilinear interpolation weights from a curvilinear (theta, phi) grid to HEALPix targets.
53
+
54
+ Parameters
55
+ ----------
56
+ src_theta : np.ndarray, shape (H, W)
57
+ Source **colatitude** (radians) at each grid node.
58
+ src_phi : np.ndarray, shape (H, W)
59
+ Source **longitude** (radians) at each grid node.
60
+ prefer_kdtree : bool, default False
61
+ If True and SciPy is available, use cKDTree on unit vectors for a faster nearest-neighbor seed.
62
+ Falls back to blocked brute-force dot-product search otherwise.
63
+ """
64
+
65
+ def __init__(self, src_theta: np.ndarray, src_phi: np.ndarray, *, prefer_kdtree: bool = False) -> None:
66
+ if src_theta.shape != src_phi.shape or src_theta.ndim != 2:
67
+ raise ValueError("src_theta and src_phi must have the same 2D shape (H, W)")
68
+ self.src_theta = np.asarray(src_theta, dtype=float)
69
+ self.src_phi = np.asarray(src_phi, dtype=float)
70
+ self.H, self.W = self.src_theta.shape
71
+ # Precompute unit vectors of source grid nodes
72
+ self._Vsrc = self._sph_to_vec(self.src_theta.ravel(), self.src_phi.ravel()) # (H*W, 3)
73
+ self.prefer_kdtree = bool(prefer_kdtree) and _HAVE_SCIPY
74
+ if self.prefer_kdtree: # optional acceleration
75
+ self._kdtree = cKDTree(self._Vsrc)
76
+ else:
77
+ self._kdtree = None
78
+
79
+ # -----------------------------
80
+ # Public API
81
+ # -----------------------------
82
+ def compute_weights(
83
+ self,
84
+ level,
85
+ cell_ids: np.ndarray,
86
+ ) -> Tuple[np.ndarray, np.ndarray]:
87
+ """Compute bilinear weights/indices for target HEALPix angles.
88
+
89
+ Parameters
90
+ ----------
91
+ cell_ids : np.ndarray, shape (N,)
92
+ Target **cell_ids** .
93
+
94
+ Returns
95
+ -------
96
+ I : np.ndarray, shape (4, N), dtype=int64
97
+ Linear indices of the 4 source corners per target (row-major: j*W + i). Invalid corners are -1.
98
+ W : np.ndarray, shape (4, N), dtype=float64
99
+ Bilinear weights aligned with `I`. Weights are set to 0.0 for invalid corners and normalized to sum to 1
100
+ when at least one corner is valid.
101
+ """
102
+ #compute the coordinate of the selected cell_ids
103
+ heal_theta, heal_phi = hp.pix2ang(2**level,cell_ids,nest=True)
104
+
105
+ ht = np.asarray(heal_theta, dtype=float).ravel()
106
+ hpt = np.asarray(heal_phi, dtype=float).ravel()
107
+ if ht.shape != hpt.shape:
108
+ raise ValueError("heal_theta and heal_phi must have the same 1D shape (N,)")
109
+ N = ht.size
110
+
111
+ # Target unit vectors
112
+ Vtgt = self._sph_to_vec(ht, hpt) # (N,3)
113
+
114
+ # 1) Choose a seed node for each target (nearest source grid node on the sphere)
115
+ seed_flat = self._nearest_source_indices(Vtgt)
116
+ seed_j, seed_i = np.divmod(seed_flat, self.W)
117
+
118
+ # 2) For each target, test up to 4 candidate quads around the seed; pick the best
119
+ I = np.full((4, N), -1, dtype=np.int64)
120
+ W = np.zeros((4, N), dtype=float)
121
+ candidates = [(0, 0), (-1, 0), (0, -1), (-1, -1)] # offsets for (i0,j0)
122
+
123
+ for n in range(N):
124
+ v = Vtgt[n]
125
+ best = None # (score_tuple, s, t, i0, j0, (idx00, idx10, idx01, idx11))
126
+
127
+ for di, dj in candidates:
128
+ i0 = seed_i[n] + di
129
+ j0 = seed_j[n] + dj
130
+ if i0 < 0 or j0 < 0 or i0 + 1 >= self.W or j0 + 1 >= self.H:
131
+ continue # out of bounds
132
+
133
+ idx00 = j0 * self.W + i0
134
+ idx10 = j0 * self.W + (i0 + 1)
135
+ idx01 = (j0 + 1) * self.W + i0
136
+ idx11 = (j0 + 1) * self.W + (i0 + 1)
137
+
138
+ v00 = self._Vsrc[idx00]
139
+ v10 = self._Vsrc[idx10]
140
+ v01 = self._Vsrc[idx01]
141
+ v11 = self._Vsrc[idx11]
142
+
143
+ # Local tangent plane at the quad barycenter
144
+ vC = v00 + v10 + v01 + v11
145
+ vC /= np.linalg.norm(vC)
146
+ ex, ey, _ = self._tangent_axes_from_vec(vC)
147
+
148
+ # Project 4 corners + target onto (ex, ey)
149
+ P00 = np.array(self._project_to_plane(v00, ex, ey))
150
+ P10 = np.array(self._project_to_plane(v10, ex, ey))
151
+ P01 = np.array(self._project_to_plane(v01, ex, ey))
152
+ P11 = np.array(self._project_to_plane(v11, ex, ey))
153
+ P = np.array(self._project_to_plane(v, ex, ey))
154
+
155
+ # Invert bilinear mapping f(s,t) = P
156
+ s, t, ok, resid = self._invert_bilinear(P00, P10, P01, P11, P)
157
+
158
+ # Prefer in-bounds solutions; otherwise smallest residual
159
+ score = (0, resid) if ok else (1, resid)
160
+ if (best is None) or (score < best[0]):
161
+ best = (score, s, t, i0, j0, (idx00, idx10, idx01, idx11))
162
+
163
+ if best is None:
164
+ continue # leave weights at 0 and indices at -1
165
+
166
+ _, s, t, i0, j0, (idx00, idx10, idx01, idx11) = best
167
+
168
+ # Bilinear weights
169
+ w00 = (1.0 - s) * (1.0 - t)
170
+ w10 = s * (1.0 - t)
171
+ w01 = (1.0 - s) * t
172
+ w11 = s * t
173
+
174
+ I[:, n] = np.array([idx00, idx10, idx01, idx11], dtype=np.int64)
175
+ W[:, n] = np.array([w00, w10, w01, w11], dtype=float)
176
+
177
+ # Normalize for numerical safety
178
+ sW = W[:, n].sum()
179
+ if sW > 0:
180
+ W[:, n] /= sW
181
+
182
+ return I, W
183
+
184
+ def apply_weights(self, img: np.ndarray, I: np.ndarray, W: np.ndarray) -> np.ndarray:
185
+ """Apply precomputed (I, W) to a source image to obtain values at the HEALPix targets.
186
+
187
+ Parameters
188
+ ----------
189
+ img : np.ndarray, shape (H, W)
190
+ Source image values defined on the same grid as (src_theta, src_phi).
191
+ I : np.ndarray, shape (4, N), dtype=int64
192
+ Linear indices (row-major) of corner samples; -1 for invalid corners.
193
+ W : np.ndarray, shape (4, N), dtype=float64
194
+ Bilinear weights aligned with I.
195
+
196
+ Returns
197
+ -------
198
+ vals : np.ndarray, shape (N,)
199
+ Interpolated values at the target directions.
200
+ """
201
+ if img.shape != (self.H, self.W):
202
+ raise ValueError(f"img must have shape {(self.H, self.W)}, got {img.shape}")
203
+ img_flat = img.reshape(-1)
204
+ N = I.shape[1]
205
+ vals = np.zeros(N, dtype=float)
206
+ for k in range(4):
207
+ idx = I[k]
208
+ w = W[k]
209
+ m = idx >= 0
210
+ vals[m] += w[m] * img_flat[idx[m]]
211
+ return vals
212
+
213
+ # -----------------------------
214
+ # Internal helpers (geometry)
215
+ # -----------------------------
216
+ @staticmethod
217
+ def _sph_to_vec(theta: np.ndarray, phi: np.ndarray) -> np.ndarray:
218
+ """(theta, phi) -> unit vectors (x,y,z). theta=colat, phi=lon, radians."""
219
+ st, ct = np.sin(theta), np.cos(theta)
220
+ sp, cp = np.sin(phi), np.cos(phi)
221
+ x = st * cp
222
+ y = st * sp
223
+ z = ct
224
+ return np.stack([x, y, z], axis=-1)
225
+
226
+ @staticmethod
227
+ def _tangent_axes_from_vec(v: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
228
+ """Return an orthonormal basis (ex, ey, ez=v_hat) for the tangent plane at v."""
229
+ ez = v / np.linalg.norm(v)
230
+ a = np.array([1.0, 0.0, 0.0], dtype=float)
231
+ if abs(np.dot(a, ez)) > 0.9: # avoid near-colinearity
232
+ a = np.array([0.0, 1.0, 0.0], dtype=float)
233
+ ex = a - np.dot(a, ez) * ez
234
+ ex /= np.linalg.norm(ex)
235
+ ey = np.cross(ez, ex)
236
+ return ex, ey, ez
237
+
238
+ @staticmethod
239
+ def _project_to_plane(vec: np.ndarray, ex: np.ndarray, ey: np.ndarray) -> Tuple[float, float]:
240
+ """Project 3D unit vector `vec` onto plane spanned by (ex, ey): returns (x, y)."""
241
+ return float(np.dot(vec, ex)), float(np.dot(vec, ey))
242
+
243
+ @staticmethod
244
+ def _bilinear_f(s: float, t: float, P00: np.ndarray, P10: np.ndarray, P01: np.ndarray, P11: np.ndarray) -> np.ndarray:
245
+ """Bilinear blend of 4 points in R^2."""
246
+ return ((1 - s) * (1 - t)) * P00 + (s * (1 - t)) * P10 + ((1 - s) * t) * P01 + (s * t) * P11
247
+
248
+ @staticmethod
249
+ def _bilinear_jacobian(s: float, t: float, P00: np.ndarray, P10: np.ndarray, P01: np.ndarray, P11: np.ndarray) -> np.ndarray:
250
+ """2x2 Jacobian of the bilinear map at (s,t)."""
251
+ dFds = (-(1 - t)) * P00 + (1 - t) * P10 + (-t) * P01 + t * P11
252
+ dFdt = (-(1 - s)) * P00 + (-s) * P10 + (1 - s) * P01 + s * P11
253
+ return np.stack([dFds, dFdt], axis=-1)
254
+
255
+ @classmethod
256
+ def _invert_bilinear(cls, P00: np.ndarray, P10: np.ndarray, P01: np.ndarray, P11: np.ndarray, P: np.ndarray,
257
+ max_iter: int = 10, tol: float = 1e-9) -> Tuple[float, float, bool, float]:
258
+ """Invert the bilinear map f(s,t)=P with a Newton loop; return (s,t, ok, residual)."""
259
+ # Initial guess from parallelogram (ignore cross term)
260
+ A = np.column_stack([P10 - P00, P01 - P00]) # 2x2
261
+ b = P - P00
262
+ try:
263
+ st0 = np.linalg.lstsq(A, b, rcond=None)[0]
264
+ s, t = float(st0[0]), float(st0[1])
265
+ except np.linalg.LinAlgError: # fallback
266
+ s, t = 0.5, 0.5
267
+
268
+ for _ in range(max_iter):
269
+ F = cls._bilinear_f(s, t, P00, P10, P01, P11)
270
+ r = P - F
271
+ if np.linalg.norm(r) < tol:
272
+ break
273
+ J = cls._bilinear_jacobian(s, t, P00, P10, P01, P11)
274
+ try:
275
+ delta = np.linalg.solve(J, r)
276
+ except np.linalg.LinAlgError:
277
+ break
278
+ s += float(delta[0])
279
+ t += float(delta[1])
280
+
281
+ # Clamp to [0,1] softly and compute residual
282
+ s_c = min(max(s, 0.0), 1.0)
283
+ t_c = min(max(t, 0.0), 1.0)
284
+ F_end = cls._bilinear_f(s_c, t_c, P00, P10, P01, P11)
285
+ resid = float(np.linalg.norm(P - F_end))
286
+ ok = (0.0 <= s <= 1.0) and (0.0 <= t <= 1.0)
287
+ return s_c, t_c, ok, resid
288
+
289
+ # -----------------------------
290
+ # Internal helpers (search)
291
+ # -----------------------------
292
+ def _nearest_source_indices(self, Vtgt: np.ndarray) -> np.ndarray:
293
+ """Return flat indices of nearest source nodes for each target unit vector."""
294
+ if self._kdtree is not None: # fast path
295
+ _, nn = self._kdtree.query(Vtgt, k=1)
296
+ return nn.astype(np.int64)
297
+ # Brute-force in blocks to limit memory
298
+ N = Vtgt.shape[0]
299
+ out = np.empty(N, dtype=np.int64)
300
+ block = 20000
301
+ VsrcT = self._Vsrc.T # (3, H*W)
302
+ for start in range(0, N, block):
303
+ end = min(N, start + block)
304
+ D = Vtgt[start:end] @ VsrcT # cosine similarities
305
+ out[start:end] = np.argmax(D, axis=1)
306
+ return out
307
+
308
+
309
+ __all__ = ["HealBili"]
foscat/Plot.py ADDED
@@ -0,0 +1,331 @@
1
+ import numpy as np
2
+ import matplotlib.pyplot as plt
3
+ import healpy as hp
4
+
5
+ def lgnomproject(
6
+ cell_ids, # array-like (N,), HEALPix pixel indices of your samples
7
+ data, # array-like (N,), values per cell id
8
+ nside: int,
9
+ rot=None, # (lon0_deg, lat0_deg, psi_deg). If None: auto-center from cell_ids (pix centers)
10
+ xsize: int = 400,
11
+ ysize: int = 400,
12
+ reso: float = None, # deg/pixel on tangent plane; if None, use fov_deg
13
+ fov_deg=None, # full FoV deg (scalar or (fx,fy))
14
+ nest: bool = True, # True if your cell_ids are NESTED (and ang2pix to be done in NEST)
15
+ reduce: str = "mean", # 'mean'|'median'|'sum'|'first' when duplicates in cell_ids
16
+ mask_outside: bool = True,
17
+ unseen_value=None, # default to hp.UNSEEN
18
+ return_image_only: bool = False,
19
+ title: str = None, cmap: str = "viridis", vmin=None, vmax=None,
20
+ notext: bool = False, # True to avoid tick marks
21
+ hold: bool = True, # create a new figure if True otherwise use sub to split panel
22
+ sub=(1,1,1), # declare sub plot
23
+ cbar: bool = False, # plot colorbar if True
24
+
25
+ ):
26
+ """
27
+ Gnomonic projection from *sparse* HEALPix samples (cell_ids, data) to an image (ysize, xsize).
28
+
29
+ For each output image pixel (i,j):
30
+ plane (x,y) --inverse gnomonic--> (theta, phi) --HEALPix--> ipix
31
+ if ipix in `cell_ids`: assign aggregated value, else UNSEEN.
32
+
33
+ Parameters
34
+ ----------
35
+ cell_ids : (N,) int array
36
+ HEALPix pixel indices of your samples. Must correspond to `nside` and `nest`.
37
+ data : (N,) float array
38
+ Sample values for each cell id.
39
+ nside : int
40
+ HEALPix NSIDE used for both `cell_ids` and the image reprojection.
41
+ rot : (lon0_deg, lat0_deg, psi_deg) or None
42
+ Gnomonic center (lon, lat) and in-plane rotation psi (deg).
43
+ If None, we auto-center from the *centers of the provided pixels* (via hp.pix2ang).
44
+ xsize, ysize : int
45
+ Output image size (pixels).
46
+ reso : float or None
47
+ Pixel size (deg/pixel) on the tangent plane. If None, derived from `fov_deg`.
48
+ fov_deg : float or (float,float)
49
+ Full field of view in degrees.
50
+ nest : bool
51
+ Use True if your `cell_ids` correspond to NESTED indexing.
52
+ reduce : str
53
+ How to combine duplicate cell ids: 'mean'|'median'|'sum'|'first'.
54
+ mask_outside : bool
55
+ Mask pixels outside the valid gnomonic hemisphere (cosc <= 0).
56
+ unseen_value : float or None
57
+ Value for invalid pixels (default hp.UNSEEN).
58
+
59
+ return_image_only : bool
60
+ If True, return the 2D array only (no plotting).
61
+
62
+ Returns
63
+ -------
64
+ (fig, ax, img) or img
65
+ If return_image_only=True, returns img (ysize, xsize).
66
+ """
67
+ if unseen_value is None:
68
+ unseen_value = hp.UNSEEN
69
+
70
+ cell_ids = np.asarray(cell_ids, dtype=np.int64)
71
+ vals_in = np.asarray(data, dtype=float)
72
+ if cell_ids.shape != vals_in.shape:
73
+ raise ValueError("cell_ids and data must have the same shape (N,)")
74
+
75
+ # -------- 1) Aggregate duplicates in cell_ids (if any) --------
76
+ uniq, inv = np.unique(cell_ids, return_inverse=True) # uniq is sorted
77
+ if reduce == "first":
78
+ first_idx = np.full(uniq.size, -1, dtype=np.int64)
79
+ for i, g in enumerate(inv):
80
+ if first_idx[g] < 0:
81
+ first_idx[g] = i
82
+ agg_vals = vals_in[first_idx]
83
+ elif reduce == "sum":
84
+ agg_vals = np.zeros(uniq.size, dtype=float)
85
+ np.add.at(agg_vals, inv, vals_in)
86
+ elif reduce == "median":
87
+ agg_vals = np.empty(uniq.size, dtype=float)
88
+ for k, pix in enumerate(uniq):
89
+ agg_vals[k] = np.median(vals_in[cell_ids == pix])
90
+ elif reduce == "mean":
91
+ sums = np.zeros(uniq.size, dtype=float)
92
+ cnts = np.zeros(uniq.size, dtype=float)
93
+ np.add.at(sums, inv, vals_in)
94
+ np.add.at(cnts, inv, 1.0)
95
+ agg_vals = sums / np.maximum(cnts, 1.0)
96
+ else:
97
+ raise ValueError("reduce must be one of {'mean','median','sum','first'}")
98
+
99
+ # -------- 2) Choose gnomonic center (rot) --------
100
+ if rot is None:
101
+ # Center from pixel centers of provided ids
102
+ theta_c, phi_c = hp.pix2ang(nside, uniq, nest=nest) # colat, lon (rad)
103
+ # circular mean for lon, median for colat
104
+ lon0_deg = np.degrees(np.angle(np.mean(np.exp(1j * phi_c))))
105
+ lat0_deg = 90.0 - np.degrees(np.median(theta_c))
106
+ psi_deg = 0.0
107
+ rot = (lon0_deg % 360.0, float(lat0_deg), float(psi_deg))
108
+
109
+ lon0_deg, lat0_deg, psi_deg = rot
110
+ lon0 = np.deg2rad(lon0_deg)
111
+ lat0 = np.deg2rad(lat0_deg)
112
+ psi = np.deg2rad(psi_deg)
113
+
114
+ # -------- 3) Tangent-plane grid (gnomonic) --------
115
+ if reso is not None:
116
+ dx = np.tan(np.deg2rad(reso))
117
+ dy = dx
118
+ half_x = 0.5 * xsize * dx
119
+ half_y = 0.5 * ysize * dy
120
+ else:
121
+ if fov_deg is None:
122
+ fov_deg=np.rad2deg(np.sqrt(cell_ids.shape[0])/nside)*1.4
123
+
124
+ if np.isscalar(fov_deg):
125
+ fx, fy = float(fov_deg), float(fov_deg)
126
+ else:
127
+ fx, fy = float(fov_deg[0]), float(fov_deg[1])
128
+ ax = np.deg2rad(0.5 * fx)
129
+ ay = np.deg2rad(0.5 * fy)
130
+ half_x = np.tan(ax)
131
+ half_y = np.tan(ay)
132
+
133
+ xs = np.linspace(-half_x, +half_x, xsize, endpoint=False) + (half_x / xsize)
134
+ ys = np.linspace(-half_y, +half_y, ysize, endpoint=False) + (half_y / ysize)
135
+
136
+ X, Y = np.meshgrid(xs, ys) # (ysize, xsize)
137
+
138
+ # in-plane rotation psi
139
+ c, s = np.cos(psi), np.sin(psi)
140
+ Xr = c * X + s * Y
141
+ Yr = -s * X + c * Y
142
+
143
+ # -------- 4) Inverse gnomonic → sphere --------
144
+ rho = np.hypot(Xr, Yr)
145
+ cang = np.arctan(rho)
146
+ sinc, cosc = np.sin(cang), np.cos(cang)
147
+ sinlat0, coslat0 = np.sin(lat0), np.cos(lat0)
148
+
149
+ with np.errstate(invalid="ignore", divide="ignore"):
150
+ lat = np.arcsin(cosc * sinlat0 + (Yr * sinc * coslat0) / np.where(rho == 0, 1.0, rho))
151
+ lon = lon0 + np.arctan2(Xr * sinc, rho * coslat0 * cosc - Yr * sinlat0 * sinc)
152
+
153
+ lon = (lon + 2*np.pi) % (2*np.pi)
154
+ theta_img = (np.pi / 2.0) - lat
155
+ outside = (cosc <= 0.0) if mask_outside else np.zeros_like(cosc, dtype=bool)
156
+
157
+ # -------- 5) Map image pixels to HEALPix ids --------
158
+ ip_img = hp.ang2pix(nside, theta_img.ravel(), lon.ravel(), nest=nest).astype(np.int64)
159
+
160
+ # -------- 6) Assign values by matching ip_img ∈ uniq (safe searchsorted) --------
161
+ # uniq is sorted; build insertion pos then check matches only where pos < len(uniq)
162
+ pos = np.searchsorted(uniq, ip_img, side="left")
163
+ valid = pos < uniq.size
164
+ match = np.zeros_like(valid, dtype=bool)
165
+ match[valid] = (uniq[pos[valid]] == ip_img[valid])
166
+
167
+ img_flat = np.full(ip_img.shape, unseen_value, dtype=float)
168
+ img_flat[match] = agg_vals[pos[match]]
169
+ img = img_flat.reshape(ysize, xsize)
170
+
171
+ # Mask out-of-hemisphere gnomonic region
172
+ if mask_outside:
173
+ img[outside] = unseen_value
174
+
175
+ # -------- 7) Return / plot --------
176
+ if return_image_only:
177
+ return img
178
+
179
+ # Axes in approx. "gnomonic degrees" (atan of plane coords)
180
+ x_deg = np.degrees(np.arctan(xs))
181
+ y_deg = np.degrees(np.arctan(ys))
182
+
183
+ longitude_min=x_deg[0]/np.cos(np.deg2rad(lat0_deg))+lon0_deg
184
+ longitude_max=x_deg[-1]/np.cos(np.deg2rad(lat0_deg))+lon0_deg
185
+
186
+ if longitude_min>180:
187
+ longitude_min-=360
188
+ longitude_max-=360
189
+
190
+ extent = (longitude_min,longitude_max,
191
+ y_deg[0]+lat0_deg, y_deg[-1]+lat0_deg)
192
+
193
+ if hold:
194
+ fig, ax = plt.subplots(figsize=(xsize/100, ysize/100), dpi=100)
195
+ else:
196
+ ax=plt.subplot(sub[0],sub[1],sub[2])
197
+
198
+ im = ax.imshow(
199
+ np.where(img == unseen_value, np.nan, img),
200
+ origin="lower",
201
+ extent=extent,
202
+ cmap=cmap,
203
+ vmin=vmin, vmax=vmax,
204
+ interpolation="nearest",
205
+ aspect="auto"
206
+ )
207
+ if not notext:
208
+ ax.set_xlabel("Longitude (deg)")
209
+ ax.set_ylabel("Latitude (deg)")
210
+ else:
211
+ ax.set_xticks([])
212
+ ax.set_yticks([])
213
+
214
+ if title:
215
+ ax.set_title(title)
216
+
217
+ if cbar:
218
+ if hold:
219
+ cb = fig.colorbar(im, ax=ax)
220
+ cb.set_label("value")
221
+ else:
222
+ plt.colorbar(im, ax=ax, orientation="horizontal", label="value")
223
+
224
+ plt.tight_layout()
225
+ if hold:
226
+ return fig, ax #, img
227
+ else:
228
+ return ax
229
+
230
+
231
+ def plot_scat(s1,s2,s3,s4):
232
+
233
+ if not isinstance(s1,np.ndarray): # manage if Torch tensor
234
+ S1=s1.cpu().numpy()
235
+ S2=s2.cpu().numpy()
236
+ S3=s3.cpu().numpy()
237
+ S4=s4.cpu().numpy()
238
+ else:
239
+ S1=s1
240
+ S2=s2
241
+ S3=s3
242
+ S4=s4
243
+
244
+ N_image=s1.shape[0]
245
+ J=s1.shape[1]
246
+ N_orient=s1.shape[2]
247
+
248
+ # compute index j1 and j2 for S3
249
+ j1_s3=np.zeros([s3.shape[1]],dtype='int')
250
+ j2_s3=np.zeros([s3.shape[1]],dtype='int')
251
+
252
+ # compute index j1 and j2 for S4
253
+ j1_s4=np.zeros([s4.shape[1]],dtype='int')
254
+ j2_s4=np.zeros([s4.shape[1]],dtype='int')
255
+ j3_s4=np.zeros([s4.shape[1]],dtype='int')
256
+
257
+ n_s3=0
258
+ n_s4=0
259
+ for j3 in range(0,J):
260
+ for j2 in range(0, j3 + 1):
261
+ j1_s3[n_s3]=j2
262
+ j2_s3[n_s3]=j3
263
+ n_s3+=1
264
+ for j1 in range(0, j2 + 1):
265
+ j1_s4[n_s4]=j1
266
+ j2_s4[n_s4]=j2
267
+ j3_s4[n_s4]=j3
268
+ n_s4+=1
269
+
270
+
271
+ color=['b','r','orange','pink']
272
+ symbol=['',':','-','.']
273
+ plt.figure(figsize=(16,12))
274
+
275
+ plt.subplot(2,2,1)
276
+ for k in range(4):
277
+ plt.plot(S1[0,:,k],color=color[k%len(color)],label=r'$\Theta = %d$'%(k))
278
+ plt.legend(frameon=0,ncol=2)
279
+ plt.xlabel(r'$J_1$')
280
+ plt.ylabel(r'$S_1$')
281
+ plt.yscale('log')
282
+
283
+ plt.subplot(2,2,2)
284
+ for k in range(4):
285
+ plt.plot(S2[0,:,k],color=color[k%len(color)],label=r'$\Theta = %d$'%(k))
286
+ plt.xlabel(r'$J_1$')
287
+ plt.ylabel(r'$S_2$')
288
+ plt.yscale('log')
289
+
290
+ plt.subplot(2,2,3)
291
+ nidx=np.concatenate([np.zeros([1]),np.cumsum(np.bincount(j1_s3))],0)
292
+ l_pos=[]
293
+ l_name=[]
294
+ for i in np.unique(j1_s3):
295
+ idx=np.where(j1_s3==i)[0]
296
+ for k in range(4):
297
+ for l in range(4):
298
+ if i==0:
299
+ plt.plot(j2_s3[idx]+nidx[i],S3[0,idx,k,l],symbol[l%len(symbol)],color=color[k%len(color)],label=r'$\Theta = %d,%d$'%(k,l))
300
+ else:
301
+ plt.plot(j2_s3[idx]+nidx[i],S3[0,idx,k,l],symbol[l%len(symbol)],color=color[k%len(color)])
302
+ l_pos=l_pos+list(j2_s3[idx]+nidx[i])
303
+ l_name=l_name+["%d,%d"%(j1_s3[m],j2_s3[m]) for m in idx]
304
+ plt.legend(frameon=0,ncol=2)
305
+
306
+ plt.xticks(l_pos,l_name, fontsize=6)
307
+ plt.xlabel(r"$j_{1},j_{2}$", fontsize=9)
308
+ plt.ylabel(r"$S_{3}$", fontsize=9)
309
+
310
+ plt.subplot(2,2,4)
311
+ nidx=0
312
+ l_pos=[]
313
+ l_name=[]
314
+ for i in np.unique(j1_s4):
315
+ for j in np.unique(j2_s4):
316
+ idx=np.where((j1_s4==i)*(j2_s4==j))[0]
317
+ for k in range(4):
318
+ for l in range(4):
319
+ for m in range(4):
320
+ if i==0 and j==0 and m==0:
321
+ plt.plot(j2_s4[idx]+j3_s4[idx]+nidx,S4[0,idx,k,l,m],symbol[l%len(symbol)],color=color[k%len(color)],label=r'$\Theta = %d,%d,%d$'%(k,l,m))
322
+ else:
323
+ plt.plot(j2_s4[idx]+j3_s4[idx]+nidx,S4[0,idx,k,l,m],symbol[l%len(symbol)],color=color[k%len(color)])
324
+ l_pos=l_pos+list(j2_s4[idx]+j3_s4[idx]+nidx)
325
+ l_name=l_name+["%d,%d,%d"%(j1_s4[m],j2_s4[m],j3_s4[m]) for m in idx]
326
+ nidx+=np.max(j2_s4[j1_s4==i]+j3_s4[j1_s4==i])-np.min(j2_s4[j1_s4==i]+j3_s4[j1_s4==i])+1
327
+ plt.legend(frameon=0,ncol=2)
328
+
329
+ plt.xticks(l_pos,l_name, fontsize=6, rotation=90)
330
+ plt.xlabel(r"$j_{1},j_{2},j_{3}$", fontsize=9)
331
+ plt.ylabel(r"$S_{4}$", fontsize=9)