plotastrodata 1.0.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.
@@ -0,0 +1,1304 @@
1
+ import numpy as np
2
+ import matplotlib as mpl
3
+ import matplotlib.pyplot as plt
4
+ from matplotlib.patches import Ellipse, Rectangle
5
+ from dataclasses import dataclass
6
+
7
+ from plotastrodata.other_utils import coord2xy, xy2coord, listing
8
+ from plotastrodata.analysis_utils import AstroData, AstroFrame
9
+
10
+
11
+ plt.ioff() # force to turn off interactive mode
12
+
13
+
14
+ def set_rcparams(fontsize: int = 18, nancolor: str = 'w',
15
+ dpi: int = 256) -> None:
16
+ """Nice rcParams for figures.
17
+
18
+ Args:
19
+ fontsize (int, optional): plt.rcParams['font.size']. Defaults to 18.
20
+ nancolor (str, optional): plt.rcParams['axes.facecolor']. Defaults to 'w'.
21
+ dpi (int, optional): plt.rcParams['savefig.dpi']. Defaults to 256.
22
+ """
23
+ # plt.rcParams['font.family'] = 'arial'
24
+ plt.rcParams['axes.facecolor'] = nancolor
25
+ plt.rcParams['font.size'] = fontsize
26
+ plt.rcParams['savefig.dpi'] = dpi
27
+ plt.rcParams['legend.fontsize'] = 15
28
+ plt.rcParams['axes.linewidth'] = 1.5
29
+ plt.rcParams['xtick.direction'] = 'inout'
30
+ plt.rcParams['ytick.direction'] = 'inout'
31
+ plt.rcParams['xtick.top'] = True
32
+ plt.rcParams['ytick.right'] = True
33
+ plt.rcParams['xtick.major.size'] = 10
34
+ plt.rcParams['ytick.major.size'] = 10
35
+ plt.rcParams['xtick.minor.size'] = 6
36
+ plt.rcParams['ytick.minor.size'] = 6
37
+ plt.rcParams['xtick.major.width'] = 1.5
38
+ plt.rcParams['ytick.major.width'] = 1.5
39
+ plt.rcParams['xtick.minor.width'] = 1.5
40
+ plt.rcParams['ytick.minor.width'] = 1.5
41
+
42
+
43
+ def logticks(ticks: list[float], lim: list[float, float]
44
+ ) -> tuple[list[float], list[str]]:
45
+ """Make nice ticks for a log axis.
46
+
47
+ Args:
48
+ ticks (list): List of ticks.
49
+ lim (list): [min, max].
50
+
51
+ Returns:
52
+ tuple: (new ticks, new labels).
53
+ """
54
+ order = int(np.floor((np.log10(lim[0]))))
55
+ a = (lim[0] // 10**order + 1) * 10**order
56
+ a = np.round(a, max(-order, 0))
57
+ order = int(np.floor((np.log10(lim[1]))))
58
+ b = (lim[1] // 10**order) * 10**order
59
+ b = np.round(b, max(-order, 0))
60
+ newticks = np.sort(np.r_[a, ticks, b])
61
+ newlabels = [str(t if t < 1 else int(t)) for t in newticks]
62
+ return newticks, newlabels
63
+
64
+
65
+ @dataclass
66
+ class PlotAxes2D():
67
+ """Use Axes.set_* to adjust x and y axes.
68
+
69
+ Args:
70
+ samexy (bool, optional): True supports same ticks between x and y. Defaults to True.
71
+ loglog (float, optional): If a float is given, plot on a log-log plane, and xim=(xmax / loglog, xmax) and so does ylim. Defaults to None.
72
+ xscale (str, optional): Defaults to None.
73
+ yscale (str, optional): Defaults to None.
74
+ xlim (list, optional): Defaults to None.
75
+ ylim (list, optional): Defaults to None.
76
+ xlabel (str, optional): Defaults to None.
77
+ ylabel (str, optional): Defaults to None.
78
+ xticks (list, optional): Defaults to None.
79
+ yticks (list, optional): Defaults to None.
80
+ xticklabels (list, optional): Defaults to None.
81
+ yticklabels (list, optional): Defaults to None.
82
+ xticksminor (list or int, optional): If int, int times more than xticks. Defaults to None.
83
+ yticksminor (list ot int, optional): Defaults to None. If int, int times more than xticks. Defaults to None.
84
+ grid (dict, optional): True means merely grid(). Defaults to None.
85
+ aspect (float, optional): Defaults to None.
86
+ """
87
+ samexy: bool = True
88
+ loglog: bool | None = None
89
+ xscale: str = 'linear'
90
+ yscale: str = 'linear'
91
+ xlim: list | None = None
92
+ ylim: list | None = None
93
+ xlabel: str | None = None
94
+ ylabel: str | None = None
95
+ xticks: list | None = None
96
+ yticks: list | None = None
97
+ xticklabels: list | None = None
98
+ yticklabels: list | None = None
99
+ xticksminor: list | int = None
100
+ yticksminor: list | int = None
101
+ grid: dict | None = None
102
+ aspect: float | None = None
103
+
104
+ def set_xyaxes(self, ax):
105
+ if self.loglog is not None:
106
+ self.xscale = 'log'
107
+ self.yscale = 'log'
108
+ self.samexy = True
109
+ if self.xlim is not None:
110
+ self.xlim[0] = self.xlim[1] / self.loglog
111
+ if self.ylim is not None:
112
+ self.ylim[0] = self.ylim[1] / self.loglog
113
+ ax.set_xscale(self.xscale)
114
+ ax.set_yscale(self.yscale)
115
+ if self.samexy:
116
+ ax.set_xticks(ax.get_yticks())
117
+ ax.set_yticks(ax.get_xticks())
118
+ ax.set_aspect(1)
119
+ if self.xticks is None:
120
+ self.xticks = ax.get_xticks()
121
+ if self.yticks is None:
122
+ self.yticks = ax.get_yticks()
123
+ if self.xscale == 'log':
124
+ self.xticks, self.xticklabels = logticks(self.xticks, self.xlim)
125
+ if self.yscale == 'log':
126
+ self.yticks, self.yticklabels = logticks(self.yticks, self.ylim)
127
+ ax.set_xticks(self.xticks)
128
+ ax.set_yticks(self.yticks)
129
+ if self.xticksminor is not None:
130
+ if type(self.xticksminor) is int:
131
+ t = ax.get_xticks()
132
+ dt = t[1] - t[0]
133
+ t = np.r_[t[0] - dt, t, t[-1] + dt]
134
+ num = self.xticksminor * (len(t) - 1) + 1
135
+ self.xticksminor = np.linspace(t[0], t[-1], num)
136
+ ax.set_xticks(self.xticksminor, minor=True)
137
+ if self.yticksminor is not None:
138
+ if type(self.yticksminor) is int:
139
+ t = ax.get_yticks()
140
+ dt = t[1] - t[0]
141
+ t = np.r_[t[0] - dt, t, t[-1] + dt]
142
+ num = self.yticksminor * (len(t) - 1) + 1
143
+ self.yticksminor = np.linspace(t[0], t[-1], num)
144
+ ax.set_yticks(self.yticksminor, minor=True)
145
+ if self.xticklabels is not None:
146
+ ax.set_xticklabels(self.xticklabels)
147
+ if self.yticklabels is not None:
148
+ ax.set_yticklabels(self.yticklabels)
149
+ if self.xlabel is not None:
150
+ ax.set_xlabel(self.xlabel)
151
+ if self.ylabel is not None:
152
+ ax.set_ylabel(self.ylabel)
153
+ if self.xlim is not None:
154
+ ax.set_xlim(*self.xlim)
155
+ if self.ylim is not None:
156
+ ax.set_ylim(*self.ylim)
157
+ if self.grid is not None:
158
+ ax.grid(**({} if self.grid is True else self.grid))
159
+ if self.aspect is not None:
160
+ ax.set_aspect(self.aspect)
161
+
162
+
163
+ def set_minmax(data: np.ndarray, stretch: str, stretchscale: float,
164
+ stretchpower: float,
165
+ rms: float, kw: dict) -> np.ndarray:
166
+ """Set vmin and vmax for color pcolormesh and RGB maps.
167
+
168
+ Args:
169
+ data (np.ndarray): Plotted data.
170
+ stretch (str): 'log', 'asinh', 'power'. Any other means linear.
171
+ stretchscale (float): For the arcsinh strech.
172
+ stretchpower (float): For the power strech.
173
+ rms (float): RMS noise level.
174
+ kw (dict): Probably like {'vmin':0, 'vmax':1}.
175
+
176
+ Returns:
177
+ np.ndarray: Data clipped with the vmin and vmax.
178
+ """
179
+ if type(stretch) is str:
180
+ data = [data]
181
+ rms = [rms]
182
+ stretch = [stretch]
183
+ stretchscale = [stretchscale]
184
+ if 'vmin' in kw:
185
+ kw['vmin'] = [kw['vmin']]
186
+ if 'vmax' in kw:
187
+ kw['vmax'] = [kw['vmax']]
188
+ z = (data, stretch, stretchscale, rms)
189
+ for i, (c, st, stsc, r) in enumerate(zip(*z)):
190
+ if stsc is None:
191
+ stsc = r
192
+ if st == 'log':
193
+ if np.any(c > 0):
194
+ c = np.log10(c.clip(np.nanmin(c[c > 0]), None))
195
+ elif st == 'asinh':
196
+ c = np.arcsinh(c / stsc)
197
+ elif st == 'power':
198
+ cmin = kw['min'][i] if 'vmin' in kw else r
199
+ c = c.clip(cmin, None)
200
+ c = ((c / cmin)**(1 - stretchpower) - 1) \
201
+ / (1 - stretchpower) / np.log(10)
202
+ data[i] = c
203
+ n = len(data)
204
+ for m in ['vmin', 'vmax']:
205
+ if m in kw:
206
+ for i, (c, st, stsc, _) in enumerate(zip(*z)):
207
+ if st == 'log':
208
+ kw[m][i] = np.log10(kw[m][i])
209
+ elif st == 'asinh':
210
+ kw[m][i] = np.arcsinh(kw[m][i] / stsc)
211
+ elif st == 'power':
212
+ kw[m][i] = ((kw[m][i]/c.min())**(1 - stretchpower) - 1) \
213
+ / (1 - stretchpower) / np.log(10)
214
+ else:
215
+ kw[m] = [None] * n
216
+ for i, (c, st, _, r) in enumerate(zip(*z)):
217
+ if m == 'vmin':
218
+
219
+ kw[m][i] = np.log10(r) if st == 'log' else np.nanmin(c)
220
+ else:
221
+ kw[m][i] = np.nanmax(c)
222
+ data = [c.clip(a, b) for c, a, b in zip(data, kw['vmin'], kw['vmax'])]
223
+ if n == 1:
224
+ data = data[0]
225
+ kw['vmin'] = kw['vmin'][0]
226
+ kw['vmax'] = kw['vmax'][0]
227
+ return data
228
+
229
+
230
+ def kwargs2AstroData(kw: dict) -> AstroData:
231
+ """Get AstroData and remove its arguments from kwargs.
232
+
233
+ Args:
234
+ kw (dict): Parameters to make AstroData.
235
+
236
+ Returns:
237
+ AstroData: AstroData made from the parameters in kwargs.
238
+ """
239
+ tmp = {}
240
+ d = AstroData(data=np.zeros((2, 2)))
241
+ for k in vars(d):
242
+ if k in kw:
243
+ tmp[k] = kw[k]
244
+ del kw[k]
245
+ if tmp == {}:
246
+ print('No argument given.')
247
+ return None
248
+ else:
249
+ d = AstroData(**tmp)
250
+ return d
251
+
252
+
253
+ def kwargs2AstroFrame(kw: dict) -> AstroFrame:
254
+ """Get AstroFrame from kwargs.
255
+
256
+ Args:
257
+ kw (dict): Parameters to make AstroFrame.
258
+
259
+ Returns:
260
+ AstroFrame: AstroFrame made from the parameters in kwargs.
261
+ """
262
+ tmp = {}
263
+ f = AstroFrame()
264
+ for k in vars(f):
265
+ if k in kw:
266
+ tmp[k] = kw[k]
267
+ f = AstroFrame(**tmp)
268
+ return f
269
+
270
+
271
+ def kwargs2PlotAxes2D(kw: dict) -> PlotAxes2D:
272
+ """Get PlotAxes2D and remove its arguments from kwargs.
273
+
274
+ Args:
275
+ kw (dict): Parameters to make PlotAxes2D.
276
+
277
+ Returns:
278
+ PlotAxes2D: PlotAxes2D made from the parameters in kwargs.
279
+ """
280
+ tmp = {}
281
+ d = PlotAxes2D()
282
+ for k in vars(d):
283
+ if k in kw:
284
+ tmp[k] = kw[k]
285
+ del kw[k]
286
+ d = PlotAxes2D(**tmp)
287
+ return d
288
+
289
+
290
+ class PlotAstroData(AstroFrame):
291
+ """Make a figure from 2D/3D FITS files or 2D/3D arrays.
292
+
293
+ Basic rules --- For 3D data, a 1D velocity array or a FITS file
294
+ with a velocity axis must be given to set up channels in each page.
295
+ For 2D/3D data, the spatial center can be read from a FITS file
296
+ or manually given.
297
+ len(v)=1 (default) means to make a 2D figure.
298
+ Spatial lengths are in the unit of arcsec, or au if dist (!= 1) is given.
299
+ Angles are in the unit of degree.
300
+ For region, line, arrow, label, and marker,
301
+ a single input can be treated without a list, e.g., anglelist=60,
302
+ as well as anglelist=[60].
303
+ Each element of poslist supposes a text coordinate
304
+ like '01h23m45.6s 01d23m45.6s' or a list of relative x and y
305
+ like [0.2, 0.3] (0 is left or bottom, 1 is right or top).
306
+ Parameters for original methods in matplotlib.axes.Axes can be
307
+ used as kwargs; see the default _kw for reference.
308
+ Position-velocity diagrams (pv=True) does not yet suppot region, line,
309
+ arrow, and segment because the units of abscissa and ordinate
310
+ are different.
311
+
312
+ kwargs is the arguments of AstroFrame to define plotting ranges.
313
+
314
+ Args:
315
+ v (np.ndarray, optional): Used to set up channels if fitsimage not given. Defaults to [0].
316
+ vskip (int, optional): How many channels are skipped. Defaults to 1.
317
+ veldigit (int, optional): How many digits after the decimal point. Defaults to 2.
318
+ restfreq (float, optional): Used for velocity and brightness T. Defaults to None.
319
+ channelnumber (int, optional): Specify a channel number to make 2D maps. Defaults to None.
320
+ nrows (int, optional): Used for channel maps. Defaults to 4.
321
+ ncols (int, optional): Used for channel maps. Defaults to 6.
322
+ fontsize (int, optional): rc_Params['font.size']. None means 18 (2D) or 12 (3D). Defaults to None.
323
+ nancolor (str, optional): Color for masked regions. Defaults to white.
324
+ dpi (int, optional): Dot per inch for plotting an image. Defaults to 256.
325
+ figsize (tuple, optional): Defaults to None.
326
+ fig (optional): External plt.figure(). Defaults to None.
327
+ ax (optional): External fig.add_subplot(). Defaults to None.
328
+ """
329
+ def __init__(self, v: np.ndarray = np.array([0]), vskip: int = 1,
330
+ veldigit: int = 2, restfreq: float | None = None,
331
+ channelnumber: int | None = None, nrows: int = 4, ncols: int = 6,
332
+ fontsize: int | None = None, nancolor: str = 'w', dpi: int = 256,
333
+ figsize: tuple[float, float] | None = None,
334
+ fig: object | None = None, ax: object | None = None, **kwargs) -> None:
335
+ super().__init__(**kwargs)
336
+ internalfig = fig is None
337
+ internalax = ax is None
338
+ if type(channelnumber) is int:
339
+ nrows = ncols = 1
340
+ if self.fitsimage is not None:
341
+ self.read(d := AstroData(fitsimage=self.fitsimage,
342
+ restfreq=restfreq, sigma=None))
343
+ v = d.v
344
+ if len(v) > 1:
345
+ dv = v[1] - v[0]
346
+ k0 = int(round((self.vmin - v[0]) / dv))
347
+ if k0 < 0:
348
+ vpre = v[0] - (1 + np.arange(-k0)[::-1]) * dv
349
+ v = np.append(vpre, v)
350
+ else:
351
+ v = v[k0:]
352
+ k1 = len(v) + int(round((self.vmax - v[-1]) / dv))
353
+ if k1 > len(v):
354
+ vpost = v[-1] + (1 + np.arange(k1 - len(v))) * dv
355
+ v = np.append(v, vpost)
356
+ else:
357
+ v = v[:k1]
358
+ if self.pv or v is None or len(v) == 1:
359
+ nv = nrows = ncols = npages = nchan = 1
360
+ else:
361
+ nv = len(v := v[::vskip])
362
+ npages = int(np.ceil(nv / nrows / ncols))
363
+ nchan = npages * nrows * ncols
364
+ v = np.r_[v, v[-1] + (np.arange(nchan-nv)+1) * (v[1] - v[0])]
365
+ if type(channelnumber) is int:
366
+ nchan = npages = 1
367
+
368
+ def nij2ch(n: int, i: int, j: int):
369
+ return n*nrows*ncols + i*ncols + j
370
+
371
+ def ch2nij(ch: int) -> tuple:
372
+ n = ch // (nrows*ncols)
373
+ i = (ch - n*nrows*ncols) // ncols
374
+ j = ch % ncols
375
+ return n, i, j
376
+
377
+ if fontsize is None:
378
+ fontsize = 18 if nchan == 1 else 12
379
+ set_rcparams(fontsize=fontsize, nancolor=nancolor, dpi=dpi)
380
+ ax = np.empty(nchan, dtype='object') if internalax else [ax]
381
+ for ch in range(nchan):
382
+ n, i, j = ch2nij(ch)
383
+ if figsize is None:
384
+ sqrt_a = (self.ymax - self.ymin) / (self.xmax - self.xmin)
385
+ sqrt_a = np.sqrt(np.abs(sqrt_a))
386
+ if nchan == 1:
387
+ figsize = (7 / sqrt_a, 5 * sqrt_a)
388
+ else:
389
+ figsize = (ncols * 2 / sqrt_a, max(nrows*2, 3) * sqrt_a)
390
+ if internalfig:
391
+ fig = plt.figure(n, figsize=figsize)
392
+ sharex = ax[nij2ch(n, i - 1, j)] if i > 0 else None
393
+ sharey = ax[nij2ch(n, i, j - 1)] if j > 0 else None
394
+ if internalax:
395
+ ax[ch] = fig.add_subplot(nrows, ncols, i*ncols + j + 1,
396
+ sharex=sharex, sharey=sharey)
397
+ if nchan > 1 or type(channelnumber) is int:
398
+ fig.subplots_adjust(hspace=0, wspace=0, right=0.87, top=0.87)
399
+ vellabel = v[ch] if channelnumber is None else v[channelnumber]
400
+ ax[ch].text(0.9 * self.rmax, 0.7 * self.rmax,
401
+ rf'${vellabel:.{veldigit:d}f}$', color='black',
402
+ backgroundcolor='white', zorder=20)
403
+ self.fig = None if internalfig else fig
404
+ self.ax = ax
405
+ self.rowcol = nrows * ncols
406
+ self.npages = npages
407
+ self.allchan = np.arange(nchan if channelnumber is None else nv)
408
+ self.bottomleft = nij2ch(np.arange(npages), nrows - 1, 0)
409
+ self.channelnumber = channelnumber
410
+
411
+ def vskipfill(c: np.ndarray, v_in: np.ndarray = None) -> np.ndarray:
412
+ """Skip and fill channels with nan.
413
+
414
+ Args:
415
+ c (np.ndarray): 2D or 3D arrays.
416
+ v_in (np.ndarray): 1D array.
417
+
418
+ Returns:
419
+ np.ndarray: 3D arrays skipped and filled with nan.
420
+ """
421
+ if np.ndim(c) == 3:
422
+ if v_in is not None:
423
+ if (k0 := np.argmin(np.abs(v - v_in[0]))) > 0:
424
+ prenan = np.full((k0, *np.shape(c)[1:]), np.nan)
425
+ d = np.append(prenan, c, axis=0)
426
+ else:
427
+ d = c
428
+ d = d[::vskip]
429
+ else:
430
+ d = np.full((nv, *np.shape(c)), c)
431
+ n = nchan if channelnumber is None else nv
432
+ shape = (n - len(d), len(d[0]), len(d[0, 0]))
433
+ dnan = np.full(shape, d[0] * np.nan)
434
+ return np.concatenate((d, dnan), axis=0)
435
+ self.vskipfill = vskipfill
436
+
437
+ def add_region(self, patch: str = 'ellipse',
438
+ poslist: list[str | list[float, float]] = [],
439
+ majlist: list[float] = [], minlist: list[float] = [],
440
+ palist: list[float] = [],
441
+ include_chan: list[int] | None = None,
442
+ **kwargs) -> None:
443
+ """Use add_patch() and Rectangle or Ellipse of matplotlib.
444
+
445
+ Args:
446
+ patch (str, optional): 'ellipse' or 'rectangle'. Defaults to 'ellipse'.
447
+ poslist (list, optional): Text or relative center. Defaults to [].
448
+ majlist (list, optional): Ellipse major axis. Defaults to [].
449
+ minlist (list, optional): Ellipse minor axis. Defaults to [].
450
+ palist (list, optional): Position angle (north to east). Defaults to [].
451
+ include_chan (list, optional): None means all. Defaults to None.
452
+ """
453
+ _kw = {'facecolor': 'none', 'edgecolor': 'gray',
454
+ 'linewidth': 1.5, 'zorder': 10}
455
+ _kw.update(kwargs)
456
+ if include_chan is None:
457
+ include_chan = self.allchan
458
+ if not (patch in ['rectangle', 'ellipse']):
459
+ print('Only patch=\'rectangle\' or \'ellipse\' supported. ')
460
+ return -1
461
+ for x, y, width, height, angle in zip(*self.pos2xy(poslist),
462
+ *listing(minlist, majlist, palist)):
463
+ for ch, axnow in enumerate(self.ax):
464
+ if type(self.channelnumber) is int:
465
+ ch = self.channelnumber
466
+ if not (ch in include_chan):
467
+ continue
468
+ if self.fig is None:
469
+ plt.figure(ch // self.rowcol)
470
+ if patch == 'rectangle':
471
+ a = np.radians(angle)
472
+ xp = x - (width*np.cos(a) + height*np.sin(a)) / 2.
473
+ yp = y - (-width*np.sin(a) + height*np.cos(a)) / 2.
474
+ p = Rectangle
475
+ else:
476
+ xp, yp = x, y
477
+ p = Ellipse
478
+ p = p((xp, yp), width=width, height=height,
479
+ angle=angle * self.xdir, **_kw)
480
+ axnow.add_patch(p)
481
+
482
+ def add_beam(self,
483
+ beam: list[float | None, float | None, float | None] = [None, None, None],
484
+ beamcolor: str = 'gray',
485
+ poslist: list[str | list[float, float]] | None = None) -> None:
486
+ """Use add_region().
487
+
488
+ Args:
489
+ beam (list, optional): [bmaj, bmin, bpa]. Defaults to [None, None, None].
490
+ beamcolor (str, optional): matplotlib color. Defaults to 'gray'.
491
+ poslist (list, optional): text or relative. Defaults to None.
492
+ """
493
+ if None in beam:
494
+ print('No beam to plot.')
495
+ return False
496
+
497
+ if poslist is None:
498
+ poslist = [max(0.35 * beam[0] / self.rmax, 0.1)] * 2
499
+ include_chan = self.bottomleft if self.channelnumber is None else self.allchan
500
+ self.add_region('ellipse', poslist, *beam,
501
+ include_chan=include_chan,
502
+ facecolor=beamcolor, edgecolor=None)
503
+
504
+ def add_marker(self, poslist: list[str | list[float, float]] = [],
505
+ include_chan: list[int] | None = None, **kwargs) -> None:
506
+ """Use Axes.plot of matplotlib.
507
+
508
+ Args:
509
+ poslist (list, optional): Text or relative. Defaults to [].
510
+ include_chan (list, optional): None means all. Defaults to None.
511
+ """
512
+ _kw = {'marker': '+', 'ms': 10, 'mfc': 'gray',
513
+ 'mec': 'gray', 'mew': 2, 'alpha': 1, 'zorder': 10}
514
+ _kw.update(kwargs)
515
+ if include_chan is None:
516
+ include_chan = self.allchan
517
+ for ch, axnow in enumerate(self.ax):
518
+ if type(self.channelnumber) is int:
519
+ ch = self.channelnumber
520
+ if not (ch in include_chan):
521
+ continue
522
+ for x, y in zip(*self.pos2xy(poslist)):
523
+ axnow.plot(x, y, **_kw)
524
+
525
+ def add_text(self, poslist: list[str | list[float, float]] = [],
526
+ slist: list[str] = [],
527
+ include_chan: list[int] | None = None, **kwargs) -> None:
528
+ """Use Axes.text of matplotlib.
529
+
530
+ Args:
531
+ poslist (list, optional): Text or relative. Defaults to [].
532
+ slist (list, optional): List of text. Defaults to [].
533
+ include_chan (list, optional): None means all. Defaults to None.
534
+ """
535
+ _kw = {'color': 'gray', 'fontsize': 15, 'ha': 'center',
536
+ 'va': 'center', 'zorder': 10}
537
+ _kw.update(kwargs)
538
+ if include_chan is None:
539
+ include_chan = self.allchan
540
+ for ch, axnow in enumerate(self.ax):
541
+ if type(self.channelnumber) is int:
542
+ ch = self.channelnumber
543
+ if not (ch in include_chan):
544
+ continue
545
+ for x, y, s in zip(*self.pos2xy(poslist), listing(slist)):
546
+ axnow.text(x=x, y=y, s=s, **_kw)
547
+
548
+ def add_line(self, poslist: list[str | list[float, float]] = [],
549
+ anglelist: list[float] = [],
550
+ rlist: list[float] = [], include_chan: list[int] | None = None,
551
+ **kwargs) -> None:
552
+ """Use Axes.plot of matplotlib.
553
+
554
+ Args:
555
+ poslist (list, optional): Text or relative. Defaults to [].
556
+ anglelist (list, optional): North to east. Defaults to [].
557
+ rlist (list, optional): List of radius. Defaults to [].
558
+ include_chan (list, optional): None means all. Defaults to None.
559
+ """
560
+ _kw = {'color': 'gray', 'linewidth': 1.5,
561
+ 'linestyle': '-', 'zorder': 10}
562
+ _kw.update(kwargs)
563
+ if include_chan is None:
564
+ include_chan = self.allchan
565
+ for ch, axnow in enumerate(self.ax):
566
+ if type(self.channelnumber) is int:
567
+ ch = self.channelnumber
568
+ if not (ch in include_chan):
569
+ continue
570
+ alist = np.radians(anglelist)
571
+ for x, y, a, r in zip(*self.pos2xy(poslist), *listing(alist, rlist)):
572
+ axnow.plot([x, x + r * np.sin(a)],
573
+ [y, y + r * np.cos(a)], **_kw)
574
+
575
+ def add_arrow(self, poslist: list[str | list[float, float]] = [],
576
+ anglelist: list[float] = [],
577
+ rlist: list[float] = [], include_chan: list[int] | None = None,
578
+ **kwargs) -> None:
579
+ """Use Axes.quiver of matplotlib.
580
+
581
+ Args:
582
+ poslist (list, optional): Text or relative. Defaults to [].
583
+ anglelist (list, optional): North to east. Defaults to [].
584
+ rlist (list, optional): List of radius. Defaults to [].
585
+ include_chan (list, optional): None means all. Defaults to None.
586
+ """
587
+ _kw = {'color': 'gray', 'width': 0.012,
588
+ 'headwidth': 5, 'headlength': 5, 'zorder': 10}
589
+ _kw.update(kwargs)
590
+ if include_chan is None:
591
+ include_chan = self.allchan
592
+ for ch, axnow in enumerate(self.ax):
593
+ if type(self.channelnumber) is int:
594
+ ch = self.channelnumber
595
+ if not (ch in include_chan):
596
+ continue
597
+ alist = np.radians(anglelist)
598
+ for x, y, a, r in zip(*self.pos2xy(poslist), *listing(alist, rlist)):
599
+ axnow.quiver(x, y, r * np.sin(a), r * np.cos(a),
600
+ angles='xy', scale_units='xy', scale=1,
601
+ **_kw)
602
+
603
+ def add_scalebar(self, length: float = 0, label: str = '',
604
+ color: str = 'gray', barpos: tuple[float, float] = (0.8, 0.12),
605
+ fontsize: float = None, linewidth: float = 3,
606
+ bbox: dict = {'alpha': 0}) -> None:
607
+ """Use Axes.text and Axes.plot of matplotlib.
608
+
609
+ Args:
610
+ length (float, optional): In the unit of arcsec. Defaults to 0.
611
+ label (str, optional): Text like '100 au'. Defaults to ''.
612
+ color (str, optional): Same for bar and label. Defaults to 'gray'.
613
+ barpos (tuple, optional): Relative position. Defaults to (0.8, 0.12).
614
+ fontsize (float, optional): None means 15 if one channel else 20. Defaults to None.
615
+ linewidth (float, optional): Width of the bar. Defaults to 3.
616
+ """
617
+ if length == 0 or label == '':
618
+ print('Please input length and label.')
619
+ return -1
620
+ if fontsize is None:
621
+ fontsize = 20 if len(self.ax) == 1 else 15
622
+ for ch, axnow in enumerate(self.ax):
623
+ if not (ch in self.bottomleft):
624
+ continue
625
+ x, y = self.pos2xy([barpos[0], barpos[1] - 0.012])
626
+ axnow.text(x[0], y[0], label, color=color, size=fontsize,
627
+ ha='center', va='top', bbox=bbox, zorder=10)
628
+ x, y = self.pos2xy([barpos[0], barpos[1] + 0.012])
629
+ axnow.plot([x[0] - length/2., x[0] + length/2.], [y[0], y[0]],
630
+ '-', linewidth=linewidth, color=color)
631
+
632
+ def add_color(self, xskip: int = 1, yskip: int = 1,
633
+ stretch: str = 'linear',
634
+ stretchscale: float | None = None,
635
+ stretchpower: float = 0,
636
+ show_cbar: bool = True, cblabel: str | None = None,
637
+ cbformat: float = '%.1e', cbticks: list[float] | None = None,
638
+ cbticklabels: list[str] | None = None, cblocation: str = 'right',
639
+ show_beam: bool = True, beamcolor: str = 'gray',
640
+ **kwargs) -> None:
641
+ """Use Axes.pcolormesh of matplotlib. kwargs must include the arguments of AstroData to specify the data to be plotted.
642
+
643
+ Args:
644
+ xskip, yskip (int, optional): Spatial pixel skip. Defaults to 1.
645
+ stretch (str, optional): 'log' means the mapped data are logarithmic. 'asinh' means the mapped data are arc sin hyperbolic. Defaults to 'linear'.
646
+ stretchscale (float, optional): color scale is asinh(data / stretchscale). Defaults to None.
647
+ stretchpower (float, optional): color scale is ((data / vmin)**(1 - stretchpower) - 1) / (1 - stretchpower) / ln(10). 0 means the linear scale. 1 means the logarithmic scale. Defaults to 0.
648
+ show_cbar (bool, optional): Show color bar. Defaults to True.
649
+ cblabel (str, optional): Colorbar label. Defaults to None.
650
+ cbformat (float, optional): Format for ticklabels of colorbar. Defaults to '%.1e'.
651
+ cbticks (list, optional): Ticks of colorbar. Defaults to None.
652
+ cbticklabels (list, optional): Ticklabels of colorbar. Defaults to None.
653
+ cblocation (str, optional): 'left', 'top', 'left', 'right'. Only for 2D images. Defaults to 'right'.
654
+ show_beam (bool, optional): Defaults to True.
655
+ beamcolor (str, optional): Matplotlib color. Defaults to 'gray'.
656
+ """
657
+ _kw = {'cmap': 'cubehelix', 'alpha': 1, 'edgecolors': 'none', 'zorder': 1}
658
+ _kw.update(kwargs)
659
+ d = kwargs2AstroData(_kw)
660
+ self.read(d, xskip, yskip)
661
+ c, x, y, v, beam, sigma = d.data, d.x, d.y, d.v, d.beam, d.sigma
662
+ bunit = d.bunit
663
+ self.beam = beam
664
+ self.sigma = sigma
665
+ if stretchscale is None:
666
+ stretchscale = sigma
667
+ cmin_org = _kw['vmin'] if 'vmin' in _kw else sigma
668
+ c = set_minmax(c, stretch, stretchscale, stretchpower, sigma, _kw)
669
+ c = self.vskipfill(c, v)
670
+ if type(self.channelnumber) is int:
671
+ c = [c[self.channelnumber]]
672
+ for axnow, cnow in zip(self.ax, c):
673
+ p = axnow.pcolormesh(x, y, cnow, **_kw)
674
+ for ch in self.bottomleft:
675
+ if not show_cbar:
676
+ break
677
+ if cblabel is None:
678
+ cblabel = bunit
679
+ if self.fig is None:
680
+ fig = plt.figure(ch // self.rowcol)
681
+ else:
682
+ fig = self.fig
683
+ if len(self.ax) == 1:
684
+ ax = self.ax[0]
685
+ cb = fig.colorbar(p, ax=ax, label=cblabel,
686
+ format=cbformat, location=cblocation)
687
+ else:
688
+ cax = plt.axes([0.88, 0.105, 0.015, 0.77])
689
+ cb = fig.colorbar(p, cax=cax, label=cblabel, format=cbformat)
690
+ cb.ax.tick_params(labelsize=14)
691
+ font = mpl.font_manager.FontProperties(size=16)
692
+ cb.ax.yaxis.label.set_font_properties(font)
693
+ if cbticks is not None:
694
+ if stretch == 'log':
695
+ cbticks = np.log10(cbticks)
696
+ elif stretch == 'asinh':
697
+ cbticks = np.arcsinh(np.array(cbticks) / stretchscale)
698
+ elif stretch == 'power':
699
+ cbticks = (np.array(cbticks) / cmin_org)**(1 - stretchpower)
700
+ cbticks = (cbticks - 1) / (1 - stretchpower) / np.log(10)
701
+ cb.set_ticks(cbticks)
702
+ if cbticklabels is not None:
703
+ cb.set_ticklabels(cbticklabels)
704
+ elif stretch in ['log', 'asinh', 'power']:
705
+ t = cb.get_ticks()
706
+ t = t[(_kw['vmin'] < t) * (t < _kw['vmax'])]
707
+ cb.set_ticks(t)
708
+ if stretch == 'log':
709
+ ticklin = 10**t
710
+ elif stretch == 'asinh':
711
+ ticklin = np.sinh(t) * stretchscale
712
+ elif stretch == 'power':
713
+ ticklin = 1 + (1 - stretchpower) * np.log(10) * t
714
+ ticklin = cmin_org * ticklin**(1 / (1 - stretchpower))
715
+ cb.set_ticklabels([f'{d:{cbformat[1:]}}' for d in ticklin])
716
+ if show_beam and not self.pv:
717
+ self.add_beam(beam, beamcolor)
718
+
719
+ def add_contour(self, xskip: int = 1, yskip: int = 1,
720
+ levels: list[float] = [-12, -6, -3, 3, 6, 12, 24, 48, 96, 192, 384],
721
+ show_beam: bool = True, beamcolor: str = 'gray',
722
+ **kwargs) -> None:
723
+ """Use Axes.contour of matplotlib. kwargs must include the arguments of AstroData to specify the data to be plotted.
724
+
725
+ Args:
726
+ xskip, yskip (int, optional): Spatial pixel skip. Defaults to 1.
727
+ levels (list, optional): Contour levels in the unit of sigma. Defaults to [-12,-6,-3,3,6,12,24,48,96,192,384].
728
+ show_beam (bool, optional): Defaults to True.
729
+ beamcolor (str, optional): Matplotlib color. Defaults to 'gray'.
730
+ """
731
+ _kw = {'colors': 'gray', 'linewidths': 1.0, 'zorder': 2}
732
+ _kw.update(kwargs)
733
+ d = kwargs2AstroData(_kw)
734
+ self.read(d, xskip, yskip)
735
+ c, x, y, v, beam, sigma = d.data, d.x, d.y, d.v, d.beam, d.sigma
736
+ self.beam = beam
737
+ self.sigma = sigma
738
+ c = self.vskipfill(c, v)
739
+ if type(self.channelnumber) is int:
740
+ c = [c[self.channelnumber]]
741
+ for axnow, cnow in zip(self.ax, c):
742
+ axnow.contour(x, y, cnow, np.sort(levels) * sigma, **_kw)
743
+ if show_beam and not self.pv:
744
+ self.add_beam(beam, beamcolor)
745
+
746
+ def add_segment(self, ampfits: str = None, angfits: str = None,
747
+ Ufits: str = None, Qfits: str = None,
748
+ xskip: int = 1, yskip: int = 1,
749
+ amp: list[np.ndarray] | None = None,
750
+ ang: list[np.ndarray] | None = None,
751
+ stU: list[np.ndarray] | None = None,
752
+ stQ: list[np.ndarray] | None = None,
753
+ ampfactor: float = 1., angonly: bool = False,
754
+ rotation: float = 0.,
755
+ cutoff: float = 3.,
756
+ show_beam: bool = True, beamcolor: str = 'gray',
757
+ **kwargs) -> None:
758
+ """Use Axes.quiver of matplotlib. kwargs must include the arguments of AstroData to specify the data to be plotted. fitsimage = [ampfits, angfits, Ufits, Qfits]. data = [amp, ang, stU, stQ].
759
+
760
+ Args:
761
+ ampfits (str, optional): In put fits name. Length of segment. Defaults to None.
762
+ angfits (str, optional): In put fits name. North to east. Defaults to None.
763
+ Ufits (str, optional): In put fits name. Stokes U. Defaults to None.
764
+ Qfits (str, optional): In put fits name. Stokes Q. Defaults to None.
765
+ xskip, yskip (int, optional): Spatial pixel skip. Defaults to 1.
766
+ amp (list, optional): Length of segment. Defaults to None.
767
+ ang (list, optional): North to east. Defaults to None.
768
+ stU (list, optional): Stokes U. Defaults to None.
769
+ stQ (list, optional): Stokes Q. Defaults to None.
770
+ ampfactor (float, optional): Length of segment is amp times ampfactor. Defaults to 1..
771
+ angonly (bool, optional): True means amp=1 for all. Defaults to False.
772
+ rotation (float, optional): Segment angle is ang + rotation. Defaults to 0..
773
+ cutoff (float, optional): Used when amp and ang are calculated from Stokes U and Q. In the unit of sigma. Defaults to 3..
774
+ sigma (str or float, optional): Noise level or method for measuring it. Defaults to 'out'.
775
+ show_beam (bool, optional): Defaults to True.
776
+ beamcolor (str, optional): Matplotlib color. Defaults to 'gray'.
777
+ """
778
+ _kw = {'angles': 'xy', 'scale_units': 'xy', 'color': 'gray',
779
+ 'pivot': 'mid', 'headwidth': 0, 'headlength': 0,
780
+ 'headaxislength': 0, 'width': 0.007, 'zorder': 3}
781
+ _kw.update(kwargs)
782
+ _kw['data'] = [amp, ang, stU, stQ]
783
+ _kw['fitsimage'] = [ampfits, angfits, Ufits, Qfits]
784
+ d = kwargs2AstroData(_kw)
785
+ self.read(d, xskip, yskip)
786
+ c, x, y, v, beam, sigma = d.data, d.x, d.y, d.v, d.beam, d.sigma
787
+ amp, ang, stU, stQ = c
788
+ sigmaU, sigmaQ = sigma[2:]
789
+ self.beam = beam
790
+ beam = [beam[i] for i in range(4) if beam[i][0] is not None][0]
791
+ if stU is not None and stQ is not None:
792
+ self.sigma = sigma = (sigmaU + sigmaQ) / 2.
793
+ ang = np.degrees(np.arctan2(stU, stQ) / 2.)
794
+ amp = np.hypot(stU, stQ)
795
+ amp[amp < cutoff * sigma] = np.nan
796
+ if amp is None:
797
+ amp = np.ones_like(ang)
798
+ if angonly:
799
+ amp = np.sign(amp)**2
800
+ amp = amp / np.nanmax(amp)
801
+ U = ampfactor * amp * np.sin(np.radians(ang + rotation))
802
+ V = ampfactor * amp * np.cos(np.radians(ang + rotation))
803
+ U = self.vskipfill(U, v)
804
+ V = self.vskipfill(V, v)
805
+ if type(self.channelnumber) is int:
806
+ U = [U[self.channelnumber]]
807
+ V = [V[self.channelnumber]]
808
+ _kw['scale'] = 1 if len(x) == 1 else 1. / np.abs(x[1] - x[0])
809
+ for axnow, unow, vnow in zip(self.ax, U, V):
810
+ axnow.quiver(x, y, unow, vnow, **_kw)
811
+ if show_beam and not self.pv:
812
+ self.add_beam(beam, beamcolor)
813
+
814
+ def add_rgb(self, xskip: int = 1, yskip: int = 1,
815
+ stretch: list[str, str, str] = ['linear'] * 3,
816
+ stretchscale: list[float | None, float | None, float | None] = [None] * 3,
817
+ stretchpower: float = 0,
818
+ show_beam: bool = True,
819
+ beamcolor: list[str, str, str] = ['red', 'green', 'blue'],
820
+ **kwargs) -> None:
821
+ """Use PIL.Image and imshow of matplotlib. kwargs must include the arguments of AstroData to specify the data to be plotted. A three-element array ([red, green, blue]) is supposed for all arguments, except for xskip, yskip and show_beam, including vmax and vmin.
822
+
823
+ Args:
824
+ xskip, yskip (int, optional): Spatial pixel skip. Defaults to 1.
825
+ stretch (str, optional): 'log' means the mapped data are logarithmic. 'asinh' means the mapped data are arc sin hyperbolic. Defaults to 'linear'.
826
+ stretchscale (float, optional): color scale is asinh(data / stretchscale). Defaults to None.
827
+ stretchpower (float, optional): color scale is ((data / vmin)**(1 - stretchpower) - 1) / (1 - stretchpower) / ln(10). 0 means the linear scale. 1 means the logarithmic scale. Defaults to 0.
828
+ show_beam (bool, optional): Defaults to True.
829
+ beamcolor (str, optional): Matplotlib color. Defaults to 'gray'.
830
+ """
831
+ from PIL import Image
832
+
833
+ _kw = {}
834
+ _kw.update(kwargs)
835
+ d = kwargs2AstroData(_kw)
836
+ self.read(d, xskip, yskip)
837
+ c, x, y, v, beam, sigma = d.data, d.x, d.y, d.v, d.beam, d.sigma
838
+ self.beam = beam
839
+ self.sigma = sigma
840
+ for i in range(len(stretchscale)):
841
+ if stretchscale[i] is None:
842
+ stretchscale[i] = sigma[i]
843
+ c = set_minmax(c, stretch, stretchscale, stretchpower, sigma, _kw)
844
+ if not (np.shape(c[0]) == np.shape(c[1]) == np.shape(c[2])):
845
+ print('RGB shapes mismatch. Skip add_rgb.')
846
+ return -1
847
+
848
+ for i in range(3):
849
+ c[i] = (c[i] - _kw['vmin'][i]) \
850
+ / (_kw['vmax'][i] - _kw['vmin'][i]) * 255
851
+ c[i] = self.vskipfill(c[i], v)
852
+ size = np.shape(c[0][0])
853
+ for axnow, red, green, blue in zip(self.ax, *c):
854
+ im = Image.new('RGB', size[::-1], (128, 128, 128))
855
+ rgb = [red[::-1, :], green[::-1, :], blue[::-1, :]]
856
+ for j in range(size[0]):
857
+ for i in range(size[1]):
858
+ value = tuple(int(a[j, i]) for a in rgb)
859
+ im.putpixel((i, j), value)
860
+ axnow.imshow(im, extent=[x[0], x[-1], y[0], y[-1]])
861
+ axnow.set_aspect(np.abs((x[-1]-x[0]) / (y[-1]-y[0])))
862
+ if show_beam and not self.pv:
863
+ for i in range(3):
864
+ self.add_beam(beam[i], beamcolor[i])
865
+
866
+ def set_axis(self, title: dict | str | None = None, **kwargs) -> None:
867
+ """Use Axes.set_* of matplotlib. kwargs can include the arguments of PlotAxes2D to adjust x and y axis.
868
+
869
+ Args:
870
+ title (dict, optional): str means set_title(str) for 2D or fig.suptitle(str) for 3D. Defaults to None.
871
+ """
872
+ _kw = {}
873
+ _kw.update(kwargs)
874
+ offunit = '(arcsec)' if self.dist == 1 else '(au)'
875
+ if self.pv:
876
+ offlabel = f'Offset {offunit}'
877
+ vellabel = r'Velocity (km s$^{-1})$'
878
+ if 'xlabel' not in _kw:
879
+ _kw['xlabel'] = vellabel if self.swapxy else offlabel
880
+ if 'ylabel' not in _kw:
881
+ _kw['ylabel'] = offlabel if self.swapxy else vellabel
882
+ _kw['samexy'] = False
883
+ else:
884
+ ralabel, declabel = f'R.A. {offunit}', f'Dec. {offunit}'
885
+ if 'xlabel' not in _kw:
886
+ _kw['xlabel'] = declabel if self.swapxy else ralabel
887
+ if 'ylabel' not in _kw:
888
+ _kw['ylabel'] = ralabel if self.swapxy else declabel
889
+ if 'xlim' not in _kw:
890
+ _kw['xlim'] = self.Xlim
891
+ if 'ylim' not in _kw:
892
+ _kw['ylim'] = self.Ylim
893
+ pa2 = kwargs2PlotAxes2D(_kw)
894
+ for ch, axnow in enumerate(self.ax):
895
+ pa2.set_xyaxes(axnow)
896
+ if not (ch in self.bottomleft):
897
+ plt.setp(axnow.get_xticklabels(), visible=False)
898
+ plt.setp(axnow.get_yticklabels(), visible=False)
899
+ axnow.set_xlabel('')
900
+ axnow.set_ylabel('')
901
+ if len(self.ax) == 1:
902
+ if self.fig is None:
903
+ plt.figure(0).tight_layout()
904
+ if title is not None:
905
+ if len(self.ax) > 1:
906
+ t = {'y': 0.9}
907
+ t_in = {'t': title} if type(title) is str else title
908
+ t.update(t_in)
909
+ for i in range(self.npages):
910
+ fig = plt.figure(i)
911
+ fig.suptitle(**t)
912
+ else:
913
+ t = {'label': title} if type(title) is str else title
914
+ axnow.set_title(**t)
915
+
916
+ def set_axis_radec(self, center: str | None = None,
917
+ xlabel: str = 'R.A. (ICRS)',
918
+ ylabel: str = 'Dec. (ICRS)',
919
+ nticksminor: int = 2,
920
+ grid: dict | None = None, title: dict | None = None) -> None:
921
+ """Use ax.set_* of matplotlib.
922
+
923
+ Args:
924
+ center (str, optional): Defaults to None, initial one.
925
+ xlabel (str, optional): Defaults to 'R.A. (ICRS)'.
926
+ ylabel (str, optional): Defaults to 'Dec. (ICRS)'.
927
+ nticksminor (int, optional): Interval ratio of major and minor ticks. Defaults to 2.
928
+ grid (dict, optional): True means merely grid(). Defaults to None.
929
+ title (dict, optional): str means set_title(str) for 2D or fig.suptitle(str) for 3D. Defaults to None.
930
+ """
931
+ if self.rmax > 50.:
932
+ print('WARNING: set_axis_radec() is not supported '
933
+ + 'with rmax>50 yet.')
934
+ if center is None:
935
+ center = self.center
936
+ if center is None:
937
+ center = '00h00m00s 00d00m00s'
938
+ dec = np.radians(coord2xy(center)[1])
939
+
940
+ def get_sec(x, i):
941
+ return x.split(' ')[i].split('m')[1].strip('s')
942
+
943
+ def get_hmdm(x, i):
944
+ return x.split(' ')[i].split('m')[0]
945
+
946
+ ra_s = get_sec(center, 0)
947
+ dec_s = get_sec(center, 1)
948
+ log2r = np.log10(2. * self.rmax)
949
+ n = np.array([-3, -2, -1, 0, 1, 2, 3])
950
+
951
+ def makegrid(second, mode):
952
+ second = float(second)
953
+ if mode == 'ra':
954
+ scale, factor, sec = 1.5, 15 * np.cos(dec), r'$^{\rm s}$'
955
+ else:
956
+ scale, factor, sec = 0.5, 1, r'$^{\rm \prime\prime}$'
957
+ sec = r'.$\hspace{-0.4}$' + sec
958
+ dorder = log2r - scale - (order := np.floor(log2r - scale))
959
+ if 0.00 < dorder <= 0.33:
960
+ g = 1
961
+ elif 0.33 < dorder <= 0.68:
962
+ g = 2
963
+ elif 0.68 < dorder <= 1.00:
964
+ g = 5
965
+ g *= 10**order
966
+ decimals = max(-int(order), -1)
967
+ rounded = round(second, decimals)
968
+ lastdigit = round(rounded // 10**(-decimals-1) % 100 / 10) % 10
969
+ rounded -= lastdigit * 10**(-decimals) % g
970
+ ticks = (n*g - second + rounded) * factor
971
+ ticksminor = np.linspace(ticks[0], ticks[-1], 6*nticksminor + 1)
972
+ decimals = max(decimals, 0)
973
+ if mode == 'ra':
974
+ xy, i = [ticks / 3600., ticks * 0], 0
975
+ else:
976
+ xy, i = [ticks * 0, ticks / 3600.], 1
977
+ tickvalues = xy2coord(xy, center)
978
+ tickvalues = [float(get_sec(t, i)) for t in tickvalues]
979
+ tickvalues = np.divmod(tickvalues, 1)
980
+ ticklabels = [f'{int(i):02d}{sec}' + f'{j:.{decimals:d}f}'[2:]
981
+ for i, j in zip(*tickvalues)]
982
+ return ticks, ticksminor, ticklabels
983
+
984
+ xticks, xticksminor, xticklabels = makegrid(ra_s, 'ra')
985
+ yticks, yticksminor, yticklabels = makegrid(dec_s, 'dec')
986
+ ra_hm = get_hmdm(xy2coord([xticks[3] / 3600., 0], center), 0)
987
+ dec_dm = get_hmdm(xy2coord([0, yticks[3] / 3600.], center), 1)
988
+ ra_hm = ra_hm.replace('h', r'$^{\rm h}$') + r'$^{\rm m}$'
989
+ dec_dm = dec_dm.replace('d', r'$^{\circ}$') + r'$^{\prime}$'
990
+ xticklabels[3] = ra_hm + xticklabels[3]
991
+ yticklabels[3] = dec_dm + '\n' + yticklabels[3]
992
+ pa2 = PlotAxes2D(True, None, 'linear', 'linear', self.Xlim, self.Ylim,
993
+ xlabel, ylabel, xticks, yticks, xticklabels,
994
+ yticklabels, xticksminor, yticksminor, grid)
995
+ for ch, axnow in enumerate(self.ax):
996
+ pa2.set_xyaxes(axnow)
997
+ if not (ch in self.bottomleft):
998
+ plt.setp(axnow.get_xticklabels(), visible=False)
999
+ plt.setp(axnow.get_yticklabels(), visible=False)
1000
+ axnow.set_xlabel('')
1001
+ axnow.set_ylabel('')
1002
+ if len(self.ax) == 1:
1003
+ if self.fig is None:
1004
+ plt.figure(0).tight_layout()
1005
+ if title is not None:
1006
+ if len(self.ax) > 1:
1007
+ t = {'y': 0.9}
1008
+ t_in = {'t': title} if type(title) is str else title
1009
+ t.update(t_in)
1010
+ for i in range(self.npages):
1011
+ fig = plt.figure(i)
1012
+ fig.suptitle(**t)
1013
+ else:
1014
+ t = {'label': title} if type(title) is str else title
1015
+ axnow.set_title(**t)
1016
+
1017
+ def savefig(self, filename: str | None = None,
1018
+ show: bool = False, **kwargs) -> None:
1019
+ """Use savefig of matplotlib.
1020
+
1021
+ Args:
1022
+ filename (str, optional): Output image file name. Defaults to None.
1023
+ show (bool, optional): True means doing plt.show(). Defaults to False.
1024
+ """
1025
+ _kw = {'transparent': True, 'bbox_inches': 'tight'}
1026
+ _kw.update(kwargs)
1027
+ for axnow in self.ax:
1028
+ axnow.set_xlim(*self.Xlim)
1029
+ axnow.set_ylim(*self.Ylim)
1030
+ if type(filename) is str:
1031
+ ext = filename.split('.')[-1]
1032
+ for i in range(self.npages):
1033
+ ver = '' if self.npages == 1 else f'_{i:d}'
1034
+ fig = plt.figure(i)
1035
+ fig.patch.set_alpha(0)
1036
+ fname = filename.replace(f'.{ext}', f'{ver}.{ext}')
1037
+ fig.savefig(fname, **_kw)
1038
+ if show:
1039
+ plt.show()
1040
+ plt.close()
1041
+
1042
+ def get_figax(self) -> tuple[object, object]:
1043
+ """Output the external fig and ax after plotting.
1044
+
1045
+ Returns:
1046
+ tuple: (fig, ax)
1047
+ """
1048
+ if len(self.ax) > 1:
1049
+ print('get_figax is not supported with channel maps')
1050
+ return -1
1051
+ return self.fig, self.ax[0]
1052
+
1053
+
1054
+ def plotprofile(coords: list[str] | str = [],
1055
+ xlist: list[float] = [], ylist: list[float] = [],
1056
+ ellipse: list[float, float, float] | None = None,
1057
+ ninterp: int = 1,
1058
+ flux: bool = False, width: int = 1,
1059
+ gaussfit: bool = False, gauss_kwargs: dict = {},
1060
+ title: list[str] | None = None, text: list[str] | None = None,
1061
+ dist: float = 1., vsys: float = 0.,
1062
+ nrows: int = 0, ncols: int = 1, fig=None, ax=None,
1063
+ getfigax: bool = False,
1064
+ savefig: dict = None, show: bool = True,
1065
+ **kwargs) -> tuple[object, object]:
1066
+ """Use Axes.plot of matplotlib to plot line profiles at given coordinates. kwargs must include the arguments of AstroData to specify the data to be plotted. kwargs can include the arguments of PlotAxes2D to adjust x and y axes.
1067
+
1068
+ Args:
1069
+ coords (list, optional): Coordinates. Defaults to [].
1070
+ xlist (list, optional): Offset from the center. Defaults to [].
1071
+ ylist (list, optional): Offset from the center. Defaults to [].
1072
+ ellipse (list, optional): [major, minor, pa], For average. Defaults to None.
1073
+ ninterp (int, optional): Number of points for interpolation. Defaults to 1.
1074
+ flux (bool, optional): y axis is flux density. Defaults to False.
1075
+ width (int, optional): Rebinning step along v. Defaults to 1.
1076
+ gaussfit (bool, optional): Fit the profiles. Defaults to False.
1077
+ gauss_kwargs (dict, optional): Kwargs for Axes.plot. Defaults to {}.
1078
+ title (list, optional): For each plot. Defaults to None.
1079
+ text (list, optional): For each plot. Defaults to None.
1080
+
1081
+ Returns:
1082
+ tuple: (fig, ax), where ax is a list, if getfigax=True. Otherwise, no return.
1083
+ """
1084
+ _kw = {'drawstyle': 'steps-mid', 'color': 'k'}
1085
+ _kw.update(kwargs)
1086
+ _kwgauss = {'drawstyle': 'default', 'color': 'g'}
1087
+ _kwgauss.update(gauss_kwargs)
1088
+ savefig0 = {'bbox_inches': 'tight', 'transparent': True}
1089
+ if type(coords) is str:
1090
+ coords = [coords]
1091
+ vmin, vmax = _kw['xlim'] if 'xlim' in _kw else [-1e10, 1e10]
1092
+ f = AstroFrame(dist=dist, vsys=vsys, vmin=vmin, vmax=vmax)
1093
+ d = kwargs2AstroData(_kw)
1094
+ Tb = d.Tb
1095
+ f.read(d)
1096
+ d.binning([width, 1, 1])
1097
+ v, prof, gfitres = d.profile(coords=coords, xlist=xlist, ylist=ylist,
1098
+ ellipse=ellipse, ninterp=ninterp,
1099
+ flux=flux, gaussfit=gaussfit)
1100
+ nprof = len(prof)
1101
+ if 'ylabel' in _kw:
1102
+ ylabel = _kw['ylabel']
1103
+ elif Tb:
1104
+ ylabel = r'$T_b$ (K)'
1105
+ elif flux:
1106
+ ylabel = 'Flux (Jy)'
1107
+ else:
1108
+ ylabel = d.bunit
1109
+ if type(ylabel) is str:
1110
+ ylabel = [ylabel] * nprof
1111
+
1112
+ def gauss(x, p, c, w):
1113
+ return p * np.exp(-4. * np.log(2.) * ((x - c) / w)**2)
1114
+
1115
+ set_rcparams(20, 'w')
1116
+ if ncols == 1:
1117
+ nrows = nprof
1118
+ if fig is None:
1119
+ fig = plt.figure(figsize=(6 * ncols, 3 * nrows))
1120
+ if nprof > 1 and ax is not None:
1121
+ print('External ax is supported only when len(coords)=1.')
1122
+ ax = None
1123
+ ax = np.empty(nprof, dtype='object') if ax is None else [ax]
1124
+ if 'xlabel' not in _kw:
1125
+ _kw['xlabel'] = 'Velocity (km s$^{-1}$)'
1126
+ if 'xlim' not in _kw:
1127
+ _kw['xlim'] = [v.min(), v.max()]
1128
+ _kw['samexy'] = False
1129
+ pa2d = kwargs2PlotAxes2D(_kw)
1130
+ for i in range(nprof):
1131
+ sharex = None if i < nrows - 1 else ax[i - 1]
1132
+ ax[i] = fig.add_subplot(nrows, ncols, i + 1, sharex=sharex)
1133
+ if gaussfit:
1134
+ ax[i].plot(v, gauss(v, *gfitres['best'][i]), **_kwgauss)
1135
+ ax[i].plot(v, prof[i], **_kw)
1136
+ ax[i].hlines([0], v.min(), v.max(), linestyle='dashed', color='k')
1137
+ ax[i].set_ylabel(ylabel[i])
1138
+ pa2d.set_xyaxes(ax[i])
1139
+ if text is not None:
1140
+ ax[i].text(**text[i])
1141
+ if title is not None:
1142
+ if type(title[i]) is str:
1143
+ title[i] = {'label': title[i]}
1144
+ ax[i].set_title(**title[i])
1145
+ if i <= nprof - ncols - 1:
1146
+ plt.setp(ax[i].get_xticklabels(), visible=False)
1147
+ if getfigax:
1148
+ return fig, ax
1149
+ fig.tight_layout()
1150
+ if savefig is not None:
1151
+ s = {'fname': savefig} if type(savefig) is str else savefig
1152
+ savefig0.update(s)
1153
+ fig.savefig(**savefig0)
1154
+ if show:
1155
+ plt.show()
1156
+ plt.close()
1157
+
1158
+
1159
+ def plotslice(length: float, dx: float | None = None, pa: float = 0,
1160
+ dist: float = 1, xoff: float = 0, yoff: float = 0,
1161
+ xflip: bool = True, yflip: bool = False,
1162
+ txtfile: str | None = None,
1163
+ fig: object | None = None, ax: object | None = None,
1164
+ getfigax: bool = False,
1165
+ savefig: str | dict | None = None, show: bool = False,
1166
+ **kwargs) -> None:
1167
+ """Use Axes.plot of matplotlib to plot a 1D spatial slice in a 2D map. kwargs must include the arguments of AstroData to specify the data to be plotted. kwargs can include the arguments of PlotAxes2D to adjust x and y axes.
1168
+
1169
+ Args:
1170
+ length (float): Slice length.
1171
+ dx (float, optional): Grid increment. Defaults to None.
1172
+ pa (float, optional): Degree. Position angle. Defaults to 0.
1173
+ fitsimage to show: same as in PlotAstroData.
1174
+ """
1175
+ _kw = {'linestyle': '-', 'marker': 'o'}
1176
+ _kw.update(kwargs)
1177
+ savefig0 = {'bbox_inches': 'tight', 'transparent': True}
1178
+ center = _kw['center'] if 'center' in _kw else None
1179
+ f = AstroFrame(rmax=length / 2, dist=dist, xoff=xoff, yoff=yoff,
1180
+ xflip=xflip, yflip=yflip, center=center)
1181
+ d = kwargs2AstroData(_kw)
1182
+ Tb = d.Tb
1183
+ f.read(d)
1184
+ if np.ndim(d.data) > 2:
1185
+ print('Only 2D map is supported.')
1186
+ return -1
1187
+
1188
+ r, z = d.slice(length=length, pa=pa, dx=dx)
1189
+ xunit = 'arcsec' if dist == 1 else 'au'
1190
+ yunit = 'K' if Tb else d.bunit
1191
+ yquantity = 'Tb' if Tb else 'intensity'
1192
+
1193
+ if txtfile is not None:
1194
+ np.savetxt(txtfile, np.c_[r, z],
1195
+ header=f'x ({xunit}), {yquantity} ({yunit}); '
1196
+ + f'positive x is pa={pa:.2f} deg.')
1197
+ set_rcparams()
1198
+ if fig is None:
1199
+ fig = plt.figure()
1200
+ if ax is None:
1201
+ ax = fig.add_subplot(1, 1, 1)
1202
+ if 'xlabel' not in _kw:
1203
+ _kw['xlabel'] = f'Offset ({xunit})'
1204
+ if 'ylabel' not in _kw:
1205
+ _kw['ylabel'] = f'Intensity ({yunit})'
1206
+ if 'xlim' not in _kw:
1207
+ _kw['xlim'] = [r.min(), r.max()]
1208
+ _kw['samexy'] = False
1209
+ pa2d = kwargs2PlotAxes2D(_kw)
1210
+ ax.plot(r, z, **_kw)
1211
+ if d.sigma is not None:
1212
+ ax.plot(r, r * 0 + 3 * d.sigma, 'k--')
1213
+ pa2d.set_xyaxes(ax)
1214
+ if getfigax:
1215
+ return fig, ax
1216
+ fig.tight_layout()
1217
+ if savefig is not None:
1218
+ s = {'fname': savefig} if type(savefig) is str else savefig
1219
+ savefig0.update(s)
1220
+ fig.savefig(**savefig0)
1221
+ if show:
1222
+ plt.show()
1223
+ plt.close()
1224
+
1225
+
1226
+ def plot3d(levels: list[float] = [3, 6, 12], cmap: str = 'Jet',
1227
+ xlabel: str = 'R.A. (arcsec)',
1228
+ ylabel: str = 'Dec. (arcsec)',
1229
+ vlabel: str = 'Velocity (km/s)',
1230
+ xskip: int = 1, yskip: int = 1,
1231
+ eye_p: float = 0, eye_i: float = 180,
1232
+ outname: str = 'plot3d', show: bool = False,
1233
+ **kwargs) -> None:
1234
+ """Use Plotly. kwargs must include the arguments of AstroData to specify the data to be plotted. kwargs must include the arguments of AstroFrame to specify the ranges and so on for plotting.
1235
+
1236
+ Args:
1237
+ levels (list, optional): Contour levels. Defaults to [3,6,12].
1238
+ cmap (str, optional): Color map name. Defaults to 'Jet'.
1239
+ xlabel (str, optional): Defaults to 'R.A. (arcsec)'.
1240
+ ylabel (str, optional): Defaults to 'Dec. (arcsec)'.
1241
+ vlabel (str, optional): Defaults to 'Velocity (km/s)'.
1242
+ xskip (int, optional): Number of pixel to skip. Defaults to 1.
1243
+ yskip (int, optional): Number of pixel to skip. Defaults to 1.
1244
+ eye_p (float, optional): Azimuthal angle of camera. Defaults to 0.
1245
+ eye_i (float, optional): Inclination angle of camera. Defaults to 180.
1246
+ outname (str, optional): Output file name. Defaults to 'plot3d'.
1247
+ show (bool, optional): auto_open in plotly. Defaults to False.
1248
+ """
1249
+ import plotly.offline as po
1250
+ import plotly.graph_objs as go
1251
+ from skimage import measure
1252
+
1253
+ f = kwargs2AstroFrame(kwargs)
1254
+ d = kwargs2AstroData(kwargs)
1255
+ f.read(d, xskip, yskip)
1256
+ volume, x, y, v, sigma = d.data, d.x, d.y, d.v, d.sigma
1257
+ dx, dy, dv = x[1] - x[0], y[1] - y[0], v[1] - v[0]
1258
+ volume[np.isnan(volume)] = 0
1259
+ if dx < 0:
1260
+ x, dx, volume = x[::-1], -dx, volume[:, :, ::-1]
1261
+ if dy < 0:
1262
+ y, dy, volume = y[::-1], -dy, volume[:, ::-1, :]
1263
+ if dv < 0:
1264
+ v, dv, volume = v[::-1], -dv, volume[::-1, :, :]
1265
+ s, ds = [x, y, v], [dx, dy, dv]
1266
+ deg = np.radians(1)
1267
+ xeye = -np.sin(eye_i * deg) * np.sin(eye_p * deg)
1268
+ yeye = -np.sin(eye_i * deg) * np.cos(eye_p * deg)
1269
+ zeye = np.cos(eye_i * deg)
1270
+ margin = dict(l=0, r=0, b=0, t=0)
1271
+ camera = dict(eye=dict(x=xeye, y=yeye, z=zeye), up=dict(x=0, y=1, z=0))
1272
+ xaxis = dict(range=[x[0], x[-1]], title=xlabel)
1273
+ yaxis = dict(range=[y[0], y[-1]], title=ylabel)
1274
+ zaxis = dict(range=[v[0], v[-1]], title=vlabel)
1275
+ scene = dict(aspectmode='cube', camera=camera,
1276
+ xaxis=xaxis, yaxis=yaxis, zaxis=zaxis)
1277
+ layout = go.Layout(margin=margin, scene=scene, showlegend=False)
1278
+
1279
+ data = []
1280
+ for lev in levels:
1281
+ if lev * sigma > np.max(volume):
1282
+ continue
1283
+ vertices, simplices, _, _ = measure.marching_cubes(volume, lev * sigma)
1284
+ Xg, Yg, Zg = [t[0] + i * dt for t, i, dt
1285
+ in zip(s, vertices.T[::-1], ds)]
1286
+ i, j, k = simplices.T
1287
+ mesh = dict(type='mesh3d', x=Xg, y=Yg, z=Zg, i=i, j=j, k=k,
1288
+ intensity=Zg * 0 + lev,
1289
+ colorscale=cmap, reversescale=False,
1290
+ cmin=np.min(levels), cmax=np.max(levels),
1291
+ opacity=0.08, name='', showscale=False)
1292
+ data.append(mesh)
1293
+ Xe, Ye, Ze = [], [], []
1294
+ for t in vertices[simplices]:
1295
+ Xe += [x[0] + dx * t[k % 3][2] for k in range(4)] + [None]
1296
+ Ye += [y[0] + dy * t[k % 3][1] for k in range(4)] + [None]
1297
+ Ze += [v[0] + dv * t[k % 3][0] for k in range(4)] + [None]
1298
+ lines = dict(type='scatter3d', x=Xe, y=Ye, z=Ze,
1299
+ mode='lines', opacity=0.04, visible=True,
1300
+ name='', line=dict(color='rgb(0,0,0)', width=1))
1301
+ data.append(lines)
1302
+
1303
+ fig = dict(data=data, layout=layout)
1304
+ po.plot(fig, filename=outname + '.html', auto_open=show)