ppdmod 2.0.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.
ppdmod/components.py ADDED
@@ -0,0 +1,557 @@
1
+ from functools import partial
2
+
3
+ import astropy.units as u
4
+ import numpy as np
5
+ from astropy.modeling.models import BlackBody
6
+ from scipy.interpolate import interp1d
7
+ from scipy.special import j0, jv
8
+
9
+ from .base import Component, FourierComponent
10
+ from .options import OPTIONS
11
+ from .parameter import Parameter
12
+ from .utils import compare_angles
13
+
14
+
15
+ class NBandFit(Component):
16
+ name = "NBandFit"
17
+ description = "A fit to the SED of a star."
18
+ label = "NBandFit"
19
+
20
+ def __init__(self, **kwargs):
21
+ """The class's constructor."""
22
+ super().__init__(**kwargs)
23
+ self.tempc = Parameter(base="tempc")
24
+ self.pah = Parameter(base="pah")
25
+ self.scale_pah = Parameter(base="scale_pah")
26
+ self.f = Parameter(
27
+ name="f",
28
+ unit=u.one,
29
+ description="Offset",
30
+ free=True,
31
+ base="f",
32
+ )
33
+
34
+ self.materials = list(
35
+ ["_".join(key.split("_")[1:]) for key in kwargs.keys() if "kappa" in key]
36
+ )
37
+
38
+ for material in self.materials:
39
+ for prefix in ["kappa", "weight"]:
40
+ key = "kappa_sil" if prefix == "kappa" else "weight_cont"
41
+ param_name = f"{prefix}_{material}"
42
+ param = Parameter(
43
+ name=param_name,
44
+ description=f"The mass fraction for {param_name}",
45
+ base=key,
46
+ )
47
+
48
+ setattr(self, param_name, param)
49
+
50
+ self.eval(**kwargs)
51
+
52
+ def get_opacity(
53
+ self, t: int, wl: u.um, kind: str = "comb", norm: bool = False
54
+ ) -> u.cm**2 / u.g:
55
+ """Gets the summed opacities."""
56
+ weights, kappas = [], []
57
+ for m in self.materials:
58
+ if kind == "sil" and m == "cont":
59
+ continue
60
+ elif kind == "cont" and m != "cont":
61
+ continue
62
+
63
+ weights.append(getattr(self, f"weight_{m}")(t, wl).value / 1e2)
64
+ kappas.append(getattr(self, f"kappa_{m}")(t, wl).value)
65
+
66
+ wnorm = np.sum(weights) if norm else 1
67
+ return np.sum([w * k / wnorm for w, k in zip(weights, kappas)], axis=0)
68
+
69
+ def flux_func(self, t: int, wl: u.um, **kwargs) -> np.ndarray:
70
+ """Returns the flux weight of the point source."""
71
+ bb = BlackBody(temperature=self.tempc(t, wl))(wl)
72
+ opacity = self.get_opacity(t, wl, **kwargs)
73
+ flux = (bb * opacity * u.sr * 10.0**-self.f.value).to(u.Jy)
74
+ if kwargs.get("kind", "comb") == "comb":
75
+ flux += self.scale_pah(t, wl) * self.pah(t, wl)
76
+ return flux.value.reshape((wl.size, 1))
77
+
78
+
79
+ class Point(FourierComponent):
80
+ """Point source."""
81
+
82
+ name = "Point"
83
+ description = "Point source."
84
+
85
+ def __init__(self, **kwargs):
86
+ super().__init__(**kwargs)
87
+ self.f = Parameter(base="f")
88
+ self.dist = Parameter(base="dist")
89
+ self.eval(**kwargs)
90
+
91
+ def flux_func(self, t: int, wl: u.um) -> np.ndarray:
92
+ """Computes the flux of the star."""
93
+ flux = self.f(t, wl).value
94
+ if not isinstance(flux, (tuple, list, np.ndarray)):
95
+ flux = np.array([flux])[:, np.newaxis]
96
+ else:
97
+ flux = flux.reshape((wl.size, 1))
98
+ return flux
99
+
100
+ def vis_func(
101
+ self, spf: 1 / u.rad, psi: u.rad, t: int, wl: u.um, **kwargs
102
+ ) -> np.ndarray:
103
+ """Computes the complex visibility."""
104
+ vis = np.zeros_like(spf).value
105
+ vis[:] = self.flux_func(t, wl)[
106
+ (...,) + (len(spf.shape[1:]) - 1) * (np.newaxis,)
107
+ ]
108
+ return vis.astype(OPTIONS.data.dtype.complex)
109
+
110
+ def image_func(
111
+ self, xx: u.mas, yy: u.mas, pixel_size: u.mas, t: int = None, wl: u.m = None
112
+ ) -> np.ndarray:
113
+ """Computes the image from a 2D grid."""
114
+ image, rho = np.zeros((wl.size, *xx.shape)), np.hypot(xx, yy)
115
+ y_ind, x_ind = np.unravel_index(np.argmin(rho), xx.shape)
116
+ star_flux = (self.compute_flux(t, wl) / 4)[..., np.newaxis]
117
+ image[:, y_ind - 1 : y_ind + 1, x_ind - 1 : x_ind + 1] = star_flux
118
+ return image
119
+
120
+
121
+ class Gauss(FourierComponent):
122
+ """A Gaussian.
123
+
124
+ Parameters
125
+ ----------
126
+ fwhm : astropy.units.mas
127
+ The FWHM of the Gaussian.
128
+ """
129
+
130
+ name = "Gauss"
131
+ description = "A 2D Gaussian"
132
+
133
+ def __init__(self, **kwargs):
134
+ super().__init__(**kwargs)
135
+ self.f = Parameter(base="f")
136
+ self.dist = Parameter(base="dist")
137
+ self.fwhm = Parameter(base="fwhm")
138
+
139
+ self.eval(**kwargs)
140
+
141
+ def flux_func(self, t: int, wl: u.um) -> np.ndarray:
142
+ """Computes the total flux."""
143
+ return self.f(t, wl).value.astype(OPTIONS.data.dtype.real)
144
+
145
+ def vis_func(
146
+ self, spf: 1 / u.rad, psi: u.rad, t: int, wl: u.um, **kwargs
147
+ ) -> np.ndarray:
148
+ """Computes the complex visibility."""
149
+ if self.fwhm(t, wl).unit == u.au:
150
+ fwhm_mas = ((self.fwhm(t, wl) / self.dist(t, wl)).value * u.arcsec).to(
151
+ u.mas
152
+ )
153
+ else:
154
+ fwhm_mas = self.fwhm(t, wl)
155
+
156
+ return self.flux_func(t, wl)[:, np.newaxis] * np.exp(
157
+ -((np.pi * fwhm_mas.to(u.rad) * spf) ** 2) / (4 * np.log(2))
158
+ )
159
+
160
+ def image_func(
161
+ self,
162
+ xx: u.mas,
163
+ yy: u.mas,
164
+ pixel_size: u.mas,
165
+ t: int = None,
166
+ wl: u.m = None,
167
+ **kwargs,
168
+ ) -> np.ndarray:
169
+ """Computes the image from a 2D grid."""
170
+ if self.fwhm(t, wl).unit == u.au:
171
+ fwhm_mas = ((self.fwhm(t, wl) / self.dist(t, wl)).value * u.arcsec).to(
172
+ u.mas
173
+ )
174
+ else:
175
+ fwhm_mas = self.fwhm(t, wl)
176
+
177
+ image = np.exp(-4 * np.log(2) * np.hypot(xx, yy) ** 2 / fwhm_mas**2)
178
+ factor = self.flux_func(t, wl) / np.sum(image)
179
+ return factor * image.reshape(1, *image.shape).value
180
+
181
+
182
+ class BBGauss(Gauss):
183
+ """A blackbody Gaussian.
184
+
185
+ Parameters
186
+ ----------
187
+ fwhm : astropy.units.mas
188
+ The FWHM of the Gaussian.
189
+ """
190
+
191
+ name = "BBGauss"
192
+ description = "A 2D Blackbody Gaussian"
193
+
194
+ def __init__(self, **kwargs):
195
+ super().__init__(**kwargs)
196
+ self.r0 = Parameter(base="r0")
197
+ self.q = Parameter(base="q")
198
+ self.temp0 = Parameter(base="temp0")
199
+ self.kappa_sil = Parameter(base="kappa_sil")
200
+ self.kappa_cont = Parameter(base="kappa_cont")
201
+ self.weight_cont = Parameter(base="weight_cont")
202
+
203
+ self.factor = Parameter(value=18, min=0, max=25, free=True, base="fr")
204
+ self.sigma = Parameter(base="sigma0")
205
+
206
+ self.eval(**kwargs)
207
+
208
+ def flux_func(self, t: int, wl: u.um) -> np.ndarray:
209
+ """Computes the total flux."""
210
+ temp = self.temp0(t, wl) * (np.abs(self.r(t, wl)) / self.r0(t, wl)) ** self.q(
211
+ t, wl
212
+ )
213
+ op_sil = self.kappa_sil(t, wl) * (1 - self.weight_cont(t, wl).value / 1e2)
214
+ op_cont = self.weight_cont(t, wl).value / 1e2 * self.kappa_sil(t, wl)
215
+ emissivity = 1 - np.exp(-self.sigma(t, wl) * (op_sil + op_cont))
216
+ bb = 10 ** -self.factor(t, wl) * BlackBody(temp)(wl)
217
+ return (bb * emissivity * u.sr).to(u.Jy).astype(OPTIONS.data.dtype.real)
218
+
219
+
220
+ class Ring(FourierComponent):
221
+ """A ring.
222
+
223
+ Parameters
224
+ ----------
225
+ rin : astropy.units.mas
226
+ The inner radius of the ring
227
+ thin : bool
228
+ If toggled the ring is infinitesimal. Default is 'True'.
229
+ rout : astropy.units.mas
230
+ The outer radius of the ring. Applies only for 'False' thin
231
+ """
232
+
233
+ name = "Ring"
234
+ description = "A simple ring."
235
+ thin = True
236
+
237
+ def __init__(self, **kwargs):
238
+ super().__init__(**kwargs)
239
+ self.rin = Parameter(base="rin")
240
+ self.rout = Parameter(base="rout")
241
+
242
+ self.eval(**kwargs)
243
+
244
+ def compute_internal_grid(self, t: int, wl: u.um) -> u.Quantity:
245
+ """Computes the model grid.
246
+
247
+ Returns
248
+ -------
249
+ radial_grid
250
+ """
251
+ rin, rout = self.rin(t, wl).value, self.rout(t, wl).value
252
+ dim, dtype = self.dim.value, OPTIONS.data.dtype.real
253
+ if OPTIONS.model.gridtype == "linear":
254
+ return np.linspace(rin, rout, dim).astype(dtype) * self.rin.unit
255
+ return (
256
+ np.logspace(np.log10(rin), np.log10(rout), dim).astype(dtype)
257
+ * self.rin.unit
258
+ )
259
+
260
+ def vis_func(
261
+ self, spf: 1 / u.rad, psi: u.rad, t: int, wl: u.um, **kwargs
262
+ ) -> np.ndarray:
263
+ """Computes the complex visibility."""
264
+ mod_amps, cos_diff, bessel_funcs = [], [], []
265
+ if self.asymmetric:
266
+ for i in range(1, self.modulation.value + 1):
267
+ rho = getattr(self, f"rho{i}")(t, wl)
268
+ theta = getattr(self, f"theta{i}")(t, wl)
269
+ mod_amps.append((-1j) ** i * rho)
270
+ cos_diff.append(
271
+ np.cos(i * compare_angles(psi.value, theta.to(u.rad).value))
272
+ )
273
+ bessel_funcs.append(partial(jv, i))
274
+
275
+ mod_amps = np.array(mod_amps)
276
+ cos_diff = np.array(cos_diff)
277
+ bessel_funcs = np.array(bessel_funcs)
278
+
279
+ def _vis_func(xx: np.ndarray):
280
+ """Shorthand for the vis calculation."""
281
+ nonlocal mod_amps, cos_diff, bessel_funcs
282
+
283
+ vis = j0(xx).astype(complex)
284
+ if self.asymmetric:
285
+ bessel_funcs = np.array(list(map(lambda x: x(xx), bessel_funcs)))
286
+ mod_amps = mod_amps.reshape(
287
+ (mod_amps.shape[0],) + (1,) * (bessel_funcs.ndim - 1)
288
+ )
289
+ vis += (mod_amps * cos_diff * bessel_funcs).sum(axis=0)
290
+ return vis
291
+
292
+ if self.thin:
293
+ vis = _vis_func(2 * np.pi * self.rin().to(u.rad) * spf)
294
+ else:
295
+ intensity_func = kwargs.pop("intensity_func", None)
296
+ radius, intensity = self.compute_internal_grid(t, wl), 1
297
+ if intensity_func is not None:
298
+ intensity = intensity_func(radius, t, wl).to(
299
+ u.erg / (u.rad**2 * u.cm**2 * u.s * u.Hz)
300
+ )
301
+ intensity = intensity[:, np.newaxis]
302
+
303
+ if radius.unit not in [u.rad, u.mas]:
304
+ radius = (radius.to(u.au) / self.dist().to(u.pc)).value * 1e3 * u.mas
305
+
306
+ radius = radius.to(u.rad)
307
+ vis = np.trapezoid(
308
+ radius * intensity * _vis_func(2 * np.pi * radius * spf), radius
309
+ )
310
+
311
+ vis *= 2 * np.pi * self.cinc()
312
+ if intensity == 1:
313
+ vis /= vis[:, 0][:, np.newaxis]
314
+ else:
315
+ vis = vis.to(u.Jy)
316
+
317
+ return vis.value.astype(OPTIONS.data.dtype.complex)
318
+
319
+ def image_func(
320
+ self, xx: u.mas, yy: u.mas, pixel_size: u.mas, t: int, wl: u.um, **kwargs
321
+ ) -> np.ndarray:
322
+ """Computes the image from a 2D grid.
323
+
324
+ Parameters
325
+ ----------
326
+ xx : u.mas
327
+ yy : u.mas
328
+ wavelength : u.um
329
+
330
+ Returns
331
+ -------
332
+ image : astropy.units.Jy
333
+ """
334
+ if self.rin.unit == u.au:
335
+ xx = (xx / 1e3 * self.dist(t, wl)).value * u.au
336
+ yy = (yy / 1e3 * self.dist(t, wl)).value * u.au
337
+
338
+ radius = np.hypot(xx, yy)[np.newaxis, ...]
339
+ dx = np.max([np.diff(xx), np.diff(yy)]) * self.rin.unit
340
+ if not self.thin:
341
+ dx = self.rout(t, wl) - self.rin(t, wl)
342
+
343
+ radial_profile = (radius >= self.rin(t, wl)) & (
344
+ radius <= (self.rin(t, wl) + dx)
345
+ )
346
+ intensity_func = kwargs.pop("intensity_func", None)
347
+ if intensity_func is None:
348
+ intensity = 1 / (2 * np.pi * dx.value)
349
+ else:
350
+ intensity = (
351
+ intensity_func(radius, t, wl).to(
352
+ u.erg / (u.cm**2 * u.mas**2 * u.s * u.Hz)
353
+ )
354
+ * pixel_size**2
355
+ )
356
+ intensity = intensity.to(u.Jy)
357
+
358
+ image = intensity * radial_profile
359
+ if self.asymmetric:
360
+ polar_angle, modulations = np.arctan2(yy, xx), []
361
+ for i in range(1, self.modulation.value + 1):
362
+ try:
363
+ rho = getattr(self, f"rho{i}")(t, wl)
364
+ theta = getattr(self, f"theta{i}")(t, wl)
365
+ except IndexError:
366
+ breakpoint()
367
+ modulations.append(
368
+ rho
369
+ * np.cos(
370
+ compare_angles(theta.to(u.rad).value, i * polar_angle.value)
371
+ )
372
+ )
373
+
374
+ modulations = u.Quantity(modulations)
375
+ image = image * (1 + np.sum(modulations, axis=0))
376
+
377
+ return image.astype(OPTIONS.data.dtype.real)
378
+
379
+
380
+ class TempGrad(Ring):
381
+ """The base class for the component.
382
+
383
+ Parameters
384
+ ----------
385
+ xx : float
386
+ The x-coordinate of the component.
387
+ yy : float
388
+ The x-coordinate of the component.
389
+ dim : float
390
+ The dimension [px].
391
+ """
392
+
393
+ name = "TempGrad"
394
+ thin = False
395
+ optically_thick = False
396
+ const_temperature = False
397
+ continuum_contribution = True
398
+
399
+ def __init__(self, **kwargs):
400
+ """The class's constructor."""
401
+ super().__init__(**kwargs)
402
+ self.rin.unit = self.rout.unit = u.au
403
+ self.dist = Parameter(base="dist")
404
+ self.eff_temp = Parameter(base="eff_temp")
405
+ self.eff_radius = Parameter(base="eff_radius")
406
+
407
+ self.r0 = Parameter(base="r0")
408
+ self.q = Parameter(base="q")
409
+ self.temp0 = Parameter(base="temp0")
410
+ self.p = Parameter(base="p")
411
+ self.sigma0 = Parameter(base="sigma0")
412
+
413
+ self.weights, self.radii, self.matrix = None, None, None
414
+ self.kappa_sil = Parameter(base="kappa_sil")
415
+ self.kappa_cont = Parameter(base="kappa_cont")
416
+ self.weight_cont = Parameter(base="weight_cont")
417
+
418
+ if self.const_temperature:
419
+ self.q.free = self.temp0.free = False
420
+
421
+ if not self.continuum_contribution:
422
+ self.weight_cont.free = False
423
+
424
+ self.eval(**kwargs)
425
+
426
+ def get_opacity(self, t: int, wl: u.um) -> u.cm**2 / u.g:
427
+ """Set the opacity from wavelength."""
428
+ kappa_sil = self.kappa_sil(t, wl)
429
+ if self.continuum_contribution:
430
+ cont_weight, kappa_cont = (
431
+ self.weight_cont(t, wl).value / 1e2,
432
+ self.kappa_cont(t, wl),
433
+ )
434
+ opacity = (1 - cont_weight) * kappa_sil + cont_weight * kappa_cont
435
+ else:
436
+ opacity = kappa_sil
437
+
438
+ opacity = opacity.astype(OPTIONS.data.dtype.real)
439
+ if opacity.size == 1:
440
+ return opacity.squeeze()
441
+
442
+ return opacity
443
+
444
+ def compute_temperature(self, radius: u.au, t: int, wl: u.um) -> u.K:
445
+ """Computes a 1D-temperature profile."""
446
+ if self.const_temperature:
447
+ if self.matrix is not None:
448
+ interp_op_temps = interp1d(self.weights, self.matrix, axis=0)(
449
+ self.weight_cont(t, wl).value / 1e2
450
+ )
451
+ temp = np.interp(radius.value, self.radii, interp_op_temps) * u.K
452
+ else:
453
+ temp = np.sqrt(
454
+ self.eff_radius(t, wl).to(u.au) / (2 * radius)
455
+ ) * self.eff_temp(t, wl)
456
+ else:
457
+ temp = self.temp0(t, wl) * (radius / self.r0(t, wl)) ** self.q(t, wl)
458
+ return temp.astype(OPTIONS.data.dtype.real)
459
+
460
+ def compute_surface_density(self, radius: u.au, t: int, wl: u.um) -> u.one:
461
+ """Computes a 1D-surface density profile."""
462
+ sigma = self.sigma0(t, wl) * (radius / self.r0(t, wl)) ** self.p(t, wl)
463
+ return sigma.astype(OPTIONS.data.dtype.real)
464
+
465
+ def compute_optical_depth(self, radius: u.au, t: int, wl: u.um) -> u.one:
466
+ """Computes a 1D-optical depth profile."""
467
+ tau = self.compute_surface_density(radius, t, wl) * self.get_opacity(t, wl)
468
+ return tau.astype(OPTIONS.data.dtype.real)
469
+
470
+ def compute_emissivity(self, radius: u.au, t: int, wl: u.um) -> u.one:
471
+ """Computes a 1D-emissivity profile."""
472
+ if wl.shape == ():
473
+ wl.reshape((wl.size,))
474
+
475
+ if self.optically_thick:
476
+ return np.array([1])[:, np.newaxis]
477
+
478
+ tau = self.compute_optical_depth(radius, t, wl)
479
+ epsilon = 1 - np.exp(-tau / self.cinc(t))
480
+ return epsilon.astype(OPTIONS.data.dtype.real)
481
+
482
+ def compute_intensity(self, radius: u.au, t: int, wl: u.um) -> u.Jy:
483
+ """Computes a 1D-brightness profile from a dust-surface density- and
484
+ temperature profile.
485
+
486
+ Parameters
487
+ ----------
488
+ wl : astropy.units.um
489
+ Wavelengths.
490
+
491
+ Returns
492
+ -------
493
+ brightness_profile : astropy.units.Jy
494
+ """
495
+ temperature = self.compute_temperature(radius, t, wl)
496
+ emissivity = self.compute_emissivity(radius, t, wl)
497
+ intensity = BlackBody(temperature)(wl) * emissivity
498
+ return intensity.astype(OPTIONS.data.dtype.real)
499
+
500
+ def flux_func(self, t: int, wl: u.um) -> np.ndarray:
501
+ """Computes the total flux from the hankel transformation."""
502
+ radius = self.compute_internal_grid(t, wl)
503
+ intensity = self.compute_intensity(radius, t, wl[:, np.newaxis])
504
+ if self.rin.unit == u.au:
505
+ radius = (radius.to(u.au) / self.dist().to(u.pc)).value * 1e3 * u.mas
506
+
507
+ flux = 2 * np.pi * self.cinc() * np.trapz(radius * intensity, radius).to(u.Jy)
508
+ return flux.value.reshape((flux.shape[0], 1)).astype(OPTIONS.data.dtype.real)
509
+
510
+ def vis_func(self, *args) -> np.ndarray:
511
+ """Computes the correlated fluxes via the hankel transformation.
512
+
513
+ Parameters
514
+ ----------
515
+ radius : astropy.units.mas
516
+ The radius.
517
+ baseline : 1/astropy.units.rad
518
+ The deprojected baselines.
519
+ baseline_angles : astropy.units.rad
520
+ The deprojected baseline angles.
521
+ wavelength : astropy.units.um
522
+ The wavelengths.
523
+
524
+ Returns
525
+ -------
526
+ vis : numpy.ndarray
527
+ The correlated fluxes.
528
+ """
529
+ return super().vis_func(*args, intensity_func=self.compute_intensity)
530
+
531
+ def image_func(self, *args) -> np.ndarray:
532
+ """Computes the image."""
533
+ return super().image_func(*args, intensity_func=self.compute_intensity)
534
+
535
+
536
+ class AsymTempGrad(TempGrad):
537
+ """An analytical implementation of an asymmetric temperature
538
+ gradient."""
539
+
540
+ name = "AsymTempGrad"
541
+ asymmetric = True
542
+
543
+
544
+ class GreyBody(TempGrad):
545
+ """An analytical implementation of an asymmetric temperature
546
+ gradient."""
547
+
548
+ name = "GreyBody"
549
+ const_temperature = True
550
+
551
+
552
+ class AsymGreyBody(GreyBody):
553
+ """An analytical implementation of an asymmetric temperature
554
+ gradient."""
555
+
556
+ name = "AsymGreyBody"
557
+ asymmetric = True