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