-
-
Notifications
You must be signed in to change notification settings - Fork 572
/
mapbase.py
2760 lines (2367 loc) · 112 KB
/
mapbase.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Map is a generic Map class from which all other Map classes inherit from.
"""
import re
import copy
import html
import inspect
import numbers
import textwrap
import itertools
import webbrowser
from tempfile import NamedTemporaryFile
from collections import namedtuple
import matplotlib
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.backend_bases import FigureCanvasBase
from matplotlib.figure import Figure
try:
from dask.array import Array as DaskArray
DASK_INSTALLED = True
except ImportError:
DASK_INSTALLED = False
import astropy.units as u
import astropy.wcs
from astropy.coordinates import BaseCoordinateFrame, Longitude, SkyCoord, UnitSphericalRepresentation
from astropy.nddata import NDData
from astropy.utils.metadata import MetaData
from astropy.visualization import HistEqStretch, ImageNormalize
from astropy.visualization.wcsaxes import Quadrangle, WCSAxes
# The next two are not used but are called to register functions with external modules
import sunpy.coordinates
import sunpy.io as io
import sunpy.io._fits
import sunpy.visualization.colormaps
from sunpy import config, log
from sunpy.coordinates import HeliographicCarrington, get_earth, sun
from sunpy.coordinates.utils import get_rectangle_coordinates
from sunpy.image.resample import resample as sunpy_image_resample
from sunpy.image.resample import reshape_image_to_4d_superpixel
from sunpy.image.transform import _get_transform_method, _rotation_function_names, affine_transform
from sunpy.map.maputils import _clip_interval, _handle_norm
from sunpy.sun import constants
from sunpy.time import is_time, parse_time
from sunpy.util import MetaDict, expand_list
from sunpy.util.decorators import (
add_common_docstring,
cached_property_based_on,
check_arithmetic_compatibility,
)
from sunpy.util.exceptions import warn_metadata, warn_user
from sunpy.util.functools import seconddispatch
from sunpy.util.util import _figure_to_base64, fix_duplicate_notes
from sunpy.visualization import axis_labels_from_ctype, peek_show, wcsaxes_compat
from sunpy.visualization.colormaps import cm as sunpy_cm
TIME_FORMAT = config.get("general", "time_format")
PixelPair = namedtuple('PixelPair', 'x y')
SpatialPair = namedtuple('SpatialPair', 'axis1 axis2')
_META_FIX_URL = 'https://docs.sunpy.org/en/stable/how_to/fix_map_metadata.html'
# Manually specify the ``.meta`` docstring. This is assigned to the .meta
# class attribute in GenericMap.__init__()
_meta_doc = """
The map metadata.
This is used to interpret the map data. It may
have been modified from the original metadata by sunpy. See the
`~sunpy.util.MetaDict.added_items`, `~sunpy.util.MetaDict.removed_items`
and `~sunpy.util.MetaDict.modified_items` properties of MetaDict
to query how the metadata has been modified.
"""
# The notes live here so we can reuse it in the source maps
_notes_doc = """
Notes
-----
A number of the properties of this class are returned as two-value named
tuples that can either be indexed by position ([0] or [1]) or be accessed
by the names (.x and .y) or (.axis1 and .axis2). Things that refer to pixel
axes use the ``.x``, ``.y`` convention, where x and y refer to the FITS
axes (x for columns y for rows). Spatial axes use ``.axis1`` and ``.axis2``
which correspond to the first and second axes in the header. ``axis1``
corresponds to the coordinate axis for ``x`` and ``axis2`` corresponds to
``y``.
This class assumes that the metadata adheres to the FITS 4 standard.
Where the CROTA2 metadata is provided (without PC_ij) it assumes a conversion
to the standard PC_ij described in section 6.1 of .
`Calabretta & Greisen (2002) <https://doi.org/10.1051/0004-6361:20021327>`_
.. warning::
If a header has CD_ij values but no PC_ij values, CDELT values are required
for this class to construct the WCS.
If a file with more than two dimensions is feed into the class,
only the first two dimensions (NAXIS1, NAXIS2) will be loaded and the
rest will be discarded.
"""
__all__ = ['GenericMap', 'MapMetaValidationError', 'PixelPair']
class MapMetaValidationError(AttributeError):
pass
class GenericMap(NDData):
"""
A Generic spatially-aware 2D data array
Parameters
----------
data : `numpy.ndarray`, list
A 2d list or ndarray containing the map data.
header : dict
A dictionary of the original image header tags.
plot_settings : dict, optional
Plot settings.
Other Parameters
----------------
**kwargs :
Additional keyword arguments are passed to `~astropy.nddata.NDData`
init.
Examples
--------
>>> import sunpy.map
>>> import sunpy.data.sample # doctest: +REMOTE_DATA
>>> aia = sunpy.map.Map(sunpy.data.sample.AIA_171_IMAGE) # doctest: +REMOTE_DATA
>>> aia # doctest: +REMOTE_DATA
<sunpy.map.sources.sdo.AIAMap object at ...>
SunPy Map
---------
Observatory: SDO
Instrument: AIA 3
Detector: AIA
Measurement: 171.0 Angstrom
Wavelength: 171.0 Angstrom
Observation Date: 2011-06-07 06:33:02
Exposure Time: 0.234256 s
Dimension: [1024. 1024.] pix
Coordinate System: helioprojective
Scale: [2.402792 2.402792] arcsec / pix
Reference Pixel: [511.5 511.5] pix
Reference Coord: [3.22309951 1.38578135] arcsec
array([[ -95.92475 , 7.076416 , -1.9656711, ..., -127.96519 ,
-127.96519 , -127.96519 ],
[ -96.97533 , -5.1167884, 0. , ..., -98.924576 ,
-104.04137 , -127.919716 ],
[ -93.99607 , 1.0189276, -4.0757103, ..., -5.094638 ,
-37.95505 , -127.87541 ],
...,
[-128.01454 , -128.01454 , -128.01454 , ..., -128.01454 ,
-128.01454 , -128.01454 ],
[-127.899666 , -127.899666 , -127.899666 , ..., -127.899666 ,
-127.899666 , -127.899666 ],
[-128.03072 , -128.03072 , -128.03072 , ..., -128.03072 ,
-128.03072 , -128.03072 ]], dtype=float32)
>>> aia.spatial_units # doctest: +REMOTE_DATA
SpatialPair(axis1=Unit("arcsec"), axis2=Unit("arcsec"))
>>> aia.peek() # doctest: +SKIP
"""
_registry = dict()
# This overrides the default doc for the meta attribute
meta = MetaData(doc=_meta_doc, copy=False)
# Enabling the GenericMap reflected operators is a bit subtle. The GenericMap
# reflected operator will be used only if the Quantity non-reflected operator
# returns NotImplemented. The Quantity operator strips the unit from the
# Quantity and tries to combine the value with the GenericMap using NumPy's
# __array_ufunc__(). If NumPy believes that it can proceed, this will result
# in an error. We explicitly set __array_ufunc__ = None so that the NumPy
# call, and consequently the Quantity operator, will return NotImplemented.
__array_ufunc__ = None
def __init_subclass__(cls, **kwargs):
"""
An __init_subclass__ hook initializes all of the subclasses of a given class.
So for each subclass, it will call this block of code on import.
This replicates some metaclass magic without the need to be aware of metaclasses.
Here we use this to register each subclass in a dict that has the
``is_datasource_for`` attribute.
This is then passed into the Map Factory so we can register them.
"""
super().__init_subclass__(**kwargs)
if cls.__doc__ is None:
# Set an empty string, to prevent an error adding None to str in the next line
cls.__doc__ = ''
cls.__doc__ = fix_duplicate_notes(_notes_doc, cls.__doc__)
if hasattr(cls, 'is_datasource_for'):
# NOTE: This conditional is due to overlapping map sources in sunpy and pfsspy that
# lead to a MultipleMatchError if sunpy.map and pfsspy.map are imported.
# See https://github.com/sunpy/sunpy/issues/7294 for more information.
if f'{cls.__module__}.{cls.__name__}' != "pfsspy.map.GongSynopticMap":
cls._registry[cls] = cls.is_datasource_for
def __init__(self, data, header, plot_settings=None, **kwargs):
# If the data has more than two dimensions, the first dimensions
# (NAXIS1, NAXIS2) are used and the rest are discarded.
ndim = data.ndim
if ndim > 2:
# We create a slice that removes all but the 'last' two
# dimensions. (Note dimensions in ndarray are in reverse order)
new_2d_slice = [0]*(ndim-2)
new_2d_slice.extend([slice(None), slice(None)])
data = data[tuple(new_2d_slice)]
# Warn the user that the data has been truncated
warn_user("This file contains more than 2 dimensions. "
"Data will be truncated to the first two dimensions.")
params = list(inspect.signature(NDData).parameters)
nddata_kwargs = {x: kwargs.pop(x) for x in params & kwargs.keys()}
super().__init__(data, meta=MetaDict(header), **nddata_kwargs)
# Setup some attributes
self._nickname = None
# These are placeholders for default attributes, which are only set
# once if their data isn't present in the map metadata.
self._default_time = None
self._default_dsun = None
self._default_carrington_longitude = None
self._default_heliographic_latitude = None
self._default_heliographic_longitude = None
# Validate header
# TODO: This should be a function of the header, not of the map
self._validate_meta()
if self.dtype == np.uint8:
norm = None
else:
# Put import here to reduce sunpy.map import time
from matplotlib import colors
norm = colors.Normalize()
# Visualization attributes
self.plot_settings = {'cmap': 'gray',
'norm': norm,
'interpolation': 'nearest',
'origin': 'lower'
}
if plot_settings:
self.plot_settings.update(plot_settings)
# Try and set the colormap. This is not always possible if this method
# is run before map sources fix some of their metadata, so
# just ignore any exceptions raised.
try:
cmap = self._get_cmap_name()
if cmap in sunpy_cm.cmlist:
self.plot_settings['cmap'] = cmap
except Exception:
pass
def __getitem__(self, key):
""" This should allow indexing by physical coordinate """
raise NotImplementedError(
"The ability to index Map by physical"
" coordinate is not yet implemented.")
def _text_summary(self):
dt = self.exposure_time
wave = self.wavelength
measurement = self.measurement
dt = 'Unknown' if dt is None else dt
wave = 'Unknown' if wave is None else wave
measurement = 'Unknown' if measurement is None else measurement
return textwrap.dedent("""\
SunPy Map
---------
Observatory:\t\t {obs}
Instrument:\t\t {inst}
Detector:\t\t {det}
Measurement:\t\t {meas}
Wavelength:\t\t {wave}
Observation Date:\t {date}
Exposure Time:\t\t {dt}
Dimension:\t\t {dim}
Coordinate System:\t {coord}
Scale:\t\t\t {scale}
Reference Pixel:\t {refpix}
Reference Coord:\t {refcoord}\
""").format(obs=self.observatory, inst=self.instrument, det=self.detector,
meas=measurement, wave=wave,
date=self.date.strftime(TIME_FORMAT),
dt=dt,
dim=u.Quantity(self.dimensions),
scale=u.Quantity(self.scale),
coord=self._coordinate_frame_name,
refpix=u.Quantity(self.reference_pixel),
refcoord=u.Quantity((self._reference_longitude,
self._reference_latitude)),
tmf=TIME_FORMAT)
def __str__(self):
return f"{self._text_summary()}\n{self.data.__repr__()}"
def __repr__(self):
return f"{object.__repr__(self)}\n{self}"
def _repr_html_(self, compute_dask=False):
"""
Produce an HTML summary with plots for use in Jupyter notebooks.
"""
# Convert the text repr to an HTML table
partial_html = self._text_summary()[20:].replace('\n', '</td></tr><tr><th>')\
.replace(':\t', '</th><td>')
text_to_table = textwrap.dedent(f"""\
<table style='text-align:left'>
<tr><th>{partial_html}</td></tr>
</table>""").replace('\n', '')
# Handle bad values (infinite and NaN) in the data array
finite_data = self.data[np.isfinite(self.data)]
count_nan = np.isnan(self.data).sum()
count_inf = np.isinf(self.data).sum()
if DASK_INSTALLED and isinstance(finite_data, DaskArray):
# This will fetch the entire data array into memory and only happens for the quicklook method
if compute_dask:
finite_data = finite_data.compute()
else:
dask_html = self.data._repr_html_()
return textwrap.dedent(f"""\
<pre>{html.escape(object.__repr__(self))}</pre>
<table>
<tr>
<td>{text_to_table}</td>
<td>
{dask_html}
</td>
</tr>
<tr>
</tr>
</table>""")
# Assemble an informational string with the counts of bad pixels
bad_pixel_text = ""
if count_nan + count_inf > 0:
bad_pixel_text = "Bad pixels are shown in red: "
text_list = []
if count_nan > 0:
text_list.append(f"{count_nan} NaN")
if count_inf > 0:
text_list.append(f"{count_inf} infinite")
bad_pixel_text += ", ".join(text_list)
# Use a grayscale colormap with histogram equalization (and red for bad values)
# Make a copy of the colormap to avoid modifying the matplotlib instance when
# doing set_bad() (copy not needed when min mpl is 3.5, as already a copy)
cmap = copy.copy(matplotlib.colormaps['gray'])
cmap.set_bad(color='red')
norm = ImageNormalize(stretch=HistEqStretch(finite_data))
# Plot the image in pixel space
fig = Figure(figsize=(5.2, 4.8))
# Figure instances in matplotlib<3.1 do not create a canvas by default
if fig.canvas is None:
FigureCanvasBase(fig)
ax = fig.subplots()
ax.imshow(self.data, origin='lower', interpolation='nearest', cmap=cmap, norm=norm)
ax.set_xlabel('X pixel')
ax.set_ylabel('Y pixel')
ax.set_title('In pixel space')
pixel_src = _figure_to_base64(fig)
bounds = ax.get_position().bounds # save these axes bounds for later use
# Plot the image using WCS information, with the same axes bounds as above
fig = Figure(figsize=(5.2, 4.8))
# Figure instances in matplotlib<3.1 do not create a canvas by default
if fig.canvas is None:
FigureCanvasBase(fig)
# Create the WCSAxes manually because we need to avoid using pyplot
ax = WCSAxes(fig, bounds, aspect='equal', wcs=self.wcs)
fig.add_axes(ax)
self.plot(axes=ax, cmap=cmap, norm=norm)
ax.set_title('In coordinate space using WCS information')
wcs_src = _figure_to_base64(fig)
# Plot the histogram of pixel values
fig = Figure(figsize=(4.8, 2.4), constrained_layout=True)
# Figure instances in matplotlib<3.1 do not create a canvas by default
if fig.canvas is None:
FigureCanvasBase(fig)
ax = fig.subplots()
values, bins, patches = ax.hist(finite_data.ravel(), bins=100)
norm_centers = norm(0.5 * (bins[:-1] + bins[1:])).data
for c, p in zip(norm_centers, patches):
plt.setp(p, "facecolor", cmap(c))
ax.plot(np.array([bins[:-1], bins[1:]]).T.ravel(),
np.array([values, values]).T.ravel())
ax.set_facecolor('white')
ax.semilogy()
# Explicitly set the power limits for the X axis formatter to avoid text overlaps
ax.xaxis.get_major_formatter().set_powerlimits((-3, 4))
ax.set_xlabel(f"Pixel value{' (' + str(self.unit) + ')' if self.unit else ''} in linear bins")
ax.set_ylabel('# of pixels')
ax.set_title('Distribution of pixel values [click for cumulative]')
hist_src = _figure_to_base64(fig)
# Plot the CDF of the pixel values using a symmetric-log horizontal scale
fig = Figure(figsize=(4.8, 2.4), constrained_layout=True)
# TODO: Figure instances in matplotlib<3.1 do not create a canvas by default
if fig.canvas is None:
FigureCanvasBase(fig)
ax = fig.subplots()
n_bins = 256
bins = norm.inverse(np.arange(n_bins + 1) / n_bins)
values, _, patches = ax.hist(finite_data.ravel(), bins=bins, cumulative=True)
for i, p in enumerate(patches):
plt.setp(p, "facecolor", cmap((i + 0.5) / n_bins))
ax.plot(np.array([bins[:-1], bins[1:]]).T.ravel(),
np.array([values, values]).T.ravel())
ax.set_facecolor('white')
ax.set_xscale('symlog')
ax.set_yscale('log')
ax.set_xlabel(f"Pixel value{' (' + str(self.unit) + ')' if self.unit else ''} in equalized bins")
ax.set_ylabel('Cumulative # of pixels')
ax.set_title('Cumulative distribution of pixel values')
cdf_src = _figure_to_base64(fig)
return textwrap.dedent(f"""\
<pre>{html.escape(object.__repr__(self))}</pre>
<table>
<tr>
<td>{text_to_table}</td>
<td rowspan=3>
<div align=center>
Image colormap uses histogram equalization<br>
Click on the image to toggle between units
</div>
<img src='data:image/png;base64,{wcs_src}'
src2='data:image/png;base64,{pixel_src}'
onClick='var temp = this.src;
this.src = this.getAttribute("src2");
this.setAttribute("src2", temp)'
/>
<div align=center>
{bad_pixel_text}
</div>
</td>
</tr>
<tr>
</tr>
<tr>
<td><img src='data:image/png;base64,{hist_src}'
src2='data:image/png;base64,{cdf_src}'
onClick='var temp = this.src;
this.src = this.getAttribute("src2");
this.setAttribute("src2", temp)'
/>
</td>
</tr>
</table>""")
def quicklook(self):
"""
Display a quicklook summary of the Map instance using the default web browser.
Notes
-----
The image colormap uses
`histogram equalization <https://en.wikipedia.org/wiki/Histogram_equalization>`__.
Clicking on the image to switch between pixel space and coordinate space requires
Javascript support to be enabled in the web browser.
Examples
--------
>>> from sunpy.map import Map
>>> import sunpy.data.sample # doctest: +REMOTE_DATA
>>> smap = Map(sunpy.data.sample.AIA_171_IMAGE) # doctest: +REMOTE_DATA
>>> smap.quicklook() # doctest: +SKIP
(which will open the following content in the default web browser)
.. generate:: html
:html_border:
from sunpy.map import Map
import sunpy.data.sample
smap = Map(sunpy.data.sample.AIA_171_IMAGE)
print(smap._repr_html_())
"""
with NamedTemporaryFile('w', delete=False, prefix='sunpy.map.', suffix='.html') as f:
url = 'file://' + f.name
f.write(textwrap.dedent(f"""\
<html>
<title>Quicklook summary for {html.escape(object.__repr__(self))}</title>
<body>{self._repr_html_(compute_dask=True)}</body>
</html>"""))
webbrowser.open_new_tab(url)
@classmethod
def _new_instance(cls, data, meta, plot_settings=None, **kwargs):
"""
Instantiate a new instance of this class using given data.
This is a shortcut for ``type(self)(data, meta, plot_settings)``.
"""
new_map = cls(data, meta, **kwargs)
# plot_settings are set explicitly here as some map sources
# explicitly set some of the plot_settings in the constructor
# and we want to preserve the plot_settings of the previous
# instance.
if plot_settings is not None:
new_map.plot_settings.update(plot_settings)
return new_map
def _get_lon_lat(self, frame):
"""
Given a coordinate frame, extract the lon and lat by casting to
SphericalRepresentation first.
"""
r = frame.represent_as(UnitSphericalRepresentation)
return r.lon.to(self.spatial_units[0]), r.lat.to(self.spatial_units[1])
@property
def quantity(self):
"""Unitful representation of the map data."""
return u.Quantity(self.data, self.unit, copy=False)
def _new_instance_from_op(self, new_data):
"""
Helper function for creating new map instances after arithmetic
operations.
"""
new_meta = copy.deepcopy(self.meta)
new_meta['bunit'] = new_data.unit.to_string('fits')
return self._new_instance(new_data.value, new_meta, plot_settings=self.plot_settings)
def __neg__(self):
return self._new_instance(-self.data, self.meta, plot_settings=self.plot_settings)
@check_arithmetic_compatibility
def __pow__(self, value):
new_data = self.quantity ** value
return self._new_instance_from_op(new_data)
@check_arithmetic_compatibility
def __add__(self, value):
new_data = self.quantity + value
return self._new_instance_from_op(new_data)
def __radd__(self, value):
return self.__add__(value)
def __sub__(self, value):
return self.__add__(-value)
def __rsub__(self, value):
return self.__neg__().__add__(value)
@check_arithmetic_compatibility
def __mul__(self, value):
new_data = self.quantity * value
return self._new_instance_from_op(new_data)
def __rmul__(self, value):
return self.__mul__(value)
def __truediv__(self, value):
return self.__mul__(1/value)
@check_arithmetic_compatibility
def __rtruediv__(self, value):
new_data = value / self.quantity
return self._new_instance_from_op(new_data)
@property
def _meta_hash(self):
return self.meta.item_hash()
def _set_symmetric_vmin_vmax(self):
"""
Set symmetric vmin and vmax about zero
"""
threshold = np.nanmax(abs(self.data))
self.plot_settings['norm'].vmin = -threshold
self.plot_settings['norm'].vmax = threshold
@property
@cached_property_based_on('_meta_hash')
def wcs(self):
"""
The `~astropy.wcs.WCS` property of the map.
"""
w2 = astropy.wcs.WCS(naxis=2)
# Add one to go from zero-based to one-based indexing
w2.wcs.crpix = u.Quantity(self.reference_pixel) + 1 * u.pix
# Make these a quantity array to prevent the numpy setting element of
# array with sequence error.
# Explicitly call ``.to()`` to check that scale is in the correct units
w2.wcs.cdelt = u.Quantity([self.scale[0].to(self.spatial_units[0] / u.pix),
self.scale[1].to(self.spatial_units[1] / u.pix)])
w2.wcs.crval = u.Quantity([self._reference_longitude, self._reference_latitude])
w2.wcs.ctype = self.coordinate_system
w2.wcs.pc = self.rotation_matrix
w2.wcs.set_pv(self._pv_values)
# FITS standard doesn't allow both PC_ij *and* CROTA keywords
w2.wcs.crota = (0, 0)
w2.wcs.cunit = self.spatial_units
w2.wcs.dateobs = self.date.isot
w2.wcs.aux.rsun_ref = self.rsun_meters.to_value(u.m)
# Set observer coordinate information except when we know it is not appropriate (e.g., HGS)
sunpy_frame = sunpy.coordinates.wcs_utils._sunpy_frame_class_from_ctypes(w2.wcs.ctype)
if sunpy_frame is None or hasattr(sunpy_frame, 'observer'):
# Clear all the aux information that was set earlier. This is to avoid
# issues with maps that store multiple observer coordinate keywords.
# Note that we have to create a new WCS as it's not possible to modify
# wcs.wcs.aux in place.
header = w2.to_header()
for kw in ['crln_obs', 'dsun_obs', 'hgln_obs', 'hglt_obs']:
header.pop(kw, None)
w2 = astropy.wcs.WCS(header)
# Get observer coord, and set the aux information
obs_coord = self.observer_coordinate
sunpy.coordinates.wcs_utils._set_wcs_aux_obs_coord(w2, obs_coord)
# Set the shape of the data array
w2.array_shape = self.data.shape
# Validate the WCS here.
w2.wcs.set()
return w2
@property
def coordinate_frame(self):
"""
An `astropy.coordinates.BaseCoordinateFrame` instance created from the coordinate
information for this Map, or None if the frame cannot be determined.
"""
try:
return astropy.wcs.utils.wcs_to_celestial_frame(self.wcs)
except ValueError as e:
warn_user(f'Could not determine coordinate frame from map metadata.\n{e}')
return None
@property
def _coordinate_frame_name(self):
if self.coordinate_frame is None:
return 'Unknown'
return self.coordinate_frame.name
def _as_mpl_axes(self):
"""
Compatibility hook for Matplotlib and WCSAxes.
This functionality requires the WCSAxes package to work. The reason
we include this here is that it allows users to use WCSAxes without
having to explicitly import WCSAxes
With this method, one can do::
import matplotlib.pyplot as plt
import sunpy.map
amap = sunpy.map.Map('filename.fits')
fig = plt.figure()
ax = plt.subplot(projection=amap)
...
and this will generate a plot with the correct WCS coordinates on the
axes. See <https://docs.astropy.org/en/stable/visualization/wcsaxes/index.html> for more information.
"""
# This code is reused from Astropy
return WCSAxes, {'wcs': self.wcs}
# Some numpy extraction
@property
def dimensions(self):
"""
The dimensions of the array (x axis first, y axis second).
"""
return PixelPair(*u.Quantity(np.flipud(self.data.shape), 'pixel'))
@property
def dtype(self):
"""
The `numpy.dtype` of the array of the map.
"""
return self.data.dtype
@property
def ndim(self):
"""
The value of `numpy.ndarray.ndim` of the data array of the map.
"""
return self.data.ndim
def std(self, *args, **kwargs):
"""
Calculate the standard deviation of the data array, ignoring NaNs.
"""
return np.nanstd(self.data, *args, **kwargs)
def mean(self, *args, **kwargs):
"""
Calculate the mean of the data array, ignoring NaNs.
"""
return np.nanmean(self.data, *args, **kwargs)
def min(self, *args, **kwargs):
"""
Calculate the minimum value of the data array, ignoring NaNs.
"""
return np.nanmin(self.data, *args, **kwargs)
def max(self, *args, **kwargs):
"""
Calculate the maximum value of the data array, ignoring NaNs.
"""
return np.nanmax(self.data, *args, **kwargs)
@staticmethod
def _parse_fits_unit(unit_str):
replacements = {'gauss': 'G',
'dn': 'ct',
'dn/s': 'ct/s',
'counts / pixel': 'ct/pix',}
if unit_str.lower() in replacements:
unit_str = replacements[unit_str.lower()]
unit = u.Unit(unit_str, format='fits', parse_strict='silent')
if isinstance(unit, u.UnrecognizedUnit):
warn_metadata(f'Could not parse unit string "{unit_str}" as a valid FITS unit.\n'
f'See {_META_FIX_URL} for how to fix metadata before loading it '
'with sunpy.map.Map.\n'
'See https://fits.gsfc.nasa.gov/fits_standard.html for '
'the FITS unit standards.')
unit = None
return unit
@property
def unit(self):
"""
Unit of the map data.
This is taken from the 'BUNIT' FITS keyword. If no 'BUNIT' entry is
present in the metadata then this returns `None`. If the 'BUNIT' value
cannot be parsed into a unit a warning is raised, and `None` returned.
"""
unit_str = self.meta.get('bunit', None)
if unit_str is None:
return
return self._parse_fits_unit(unit_str)
# #### Keyword attribute and other attribute definitions #### #
def _base_name(self):
"""Abstract the shared bit between name and latex_name"""
if self.measurement is None:
format_str = "{nickname} {date}"
else:
format_str = "{nickname} {{measurement}} {date}"
return format_str.format(nickname=self.nickname,
date=parse_time(self.date).strftime(TIME_FORMAT))
@property
def name(self):
"""Human-readable description of the Map."""
return self._base_name().format(measurement=self.measurement)
@property
def latex_name(self):
"""LaTeX formatted description of the Map."""
if isinstance(self.measurement, u.Quantity):
return self._base_name().format(measurement=self.measurement._repr_latex_())
else:
return self.name
@property
def nickname(self):
"""An abbreviated human-readable description of the map-type; part of
the Helioviewer data model."""
return self._nickname if self._nickname else self.detector
@nickname.setter
def nickname(self, n):
self._nickname = n
def _get_date(self, key):
time = self.meta.get(key, None)
if not time:
return
# Get the time scale
if 'TAI' in time:
# SDO specifies the 'TAI' scale in their time string, which is parsed
# by parse_time(). If a different timescale is also present, warn the
# user that it will be ignored.
timesys = 'TAI'
timesys_meta = self.meta.get('timesys', '').upper()
if timesys_meta not in ('', 'TAI'):
warn_metadata('Found "TAI" in time string, ignoring TIMESYS keyword '
f'which is set to "{timesys_meta}".')
else:
timesys = self._timesys
return parse_time(time, scale=timesys.lower())
@property
def _timesys(self):
"""
Time system.
"""
# UTC is the FITS standard default
return self.meta.get('timesys', 'UTC')
@property
def date_start(self):
"""
Time of the beginning of the image acquisition.
Taken from the DATE-BEG FITS keyword.
"""
return self._get_date('date-beg')
@property
def date_end(self):
"""
Time of the end of the image acquisition.
Taken from the DATE-END FITS keyword.
"""
return self._get_date('date-end')
@property
def date_average(self):
"""
Average time of the image acquisition.
Taken from the DATE-AVG FITS keyword if present, otherwise halfway
between `date_start` and `date_end` if both pieces of metadata are
present.
"""
avg = self._get_date('date-avg')
if avg is None:
start, end = self.date_start, self.date_end
if start is not None and end is not None:
avg = start + (end - start) / 2
return avg
@property
def _date_obs(self):
# Get observation date from date-obs, falling back to date_obs
time = self._get_date('date-obs')
if is_time(self.meta.get('date_obs', None)):
time = time or self._get_date('date_obs')
return time
@property
def date(self):
"""
Image observation time.
For different combinations of map metadata this can return either
the start time, end time, or a time between these. It is recommended
to use `~sunpy.map.GenericMap.date_average`,
`~sunpy.map.GenericMap.date_start`, or `~sunpy.map.GenericMap.date_end`
instead if you need one of these specific times.
Taken from, in order of preference:
1. The DATE-OBS FITS keyword
2. `~sunpy.map.GenericMap.date_average`
3. `~sunpy.map.GenericMap.date_start`
4. `~sunpy.map.GenericMap.date_end`
5. The current time
"""
time = self._date_obs
time = time or self.date_average
time = time or self.date_start
time = time or self.date_end
if time is None:
if self._default_time is None:
warn_metadata("Missing metadata for observation time, "
"setting observation time to current time. "
"Set the 'DATE-AVG' FITS keyword to prevent this warning.")
self._default_time = parse_time('now')
time = self._default_time
return time
@property
def detector(self):
"""
Detector name.
This is taken from the 'DETECTOR' FITS keyword.
"""
return self.meta.get('detector', "")
@property
def timeunit(self):
"""
The `~astropy.units.Unit` of the exposure time of this observation.
Taken from the "TIMEUNIT" FITS keyword, and defaults to seconds (as per)
the FITS standard).
"""
return u.Unit(self.meta.get('timeunit', 's'))
@property
def exposure_time(self):
"""
Exposure time of the image.
This is taken from the 'XPOSURE' keyword or the 'EXPTIME' FITS keyword,
in that order.
"""
exptime = self.meta.get('xposure') or self.meta.get('exptime')
if exptime is not None:
return exptime * self.timeunit
@property
def instrument(self):
"""Instrument name."""
return self.meta.get('instrume', "").replace("_", " ")
@property
def measurement(self):
"""
Measurement wavelength.
This is taken from the 'WAVELNTH' FITS keywords. If the keyword is not
present, defaults to `None`. If 'WAVEUNIT' keyword isn't present,
defaults to dimensionless units.
"""
return self.wavelength
@property
def waveunit(self):
"""
The `~astropy.units.Unit` of the wavelength of this observation.
This is taken from the 'WAVEUNIT' FITS keyword. If the keyword is not
present, defaults to `None`
"""
if 'waveunit' in self.meta:
return u.Unit(self.meta['waveunit'])
else:
wunit = sunpy.io._fits.extract_waveunit(self.meta)
if wunit is not None:
return u.Unit(wunit)
@property
def wavelength(self):
"""
Wavelength of the observation.
This is taken from the 'WAVELNTH' FITS keywords. If the keyword is not
present, defaults to `None`. If 'WAVEUNIT' keyword isn't present,
defaults to dimensionless units.
"""
if 'wavelnth' in self.meta:
return u.Quantity(self.meta['wavelnth'], self.waveunit)
@property
def observatory(self):
"""
Observatory or Telescope name.
This is taken from the 'OBSRVTRY' FITS keyword.
"""
return self.meta.get('obsrvtry',
self.meta.get('telescop', "")).replace("_", " ")
@property
def processing_level(self):
"""
Returns the FITS processing level if present.
This is taken from the 'LVL_NUM' FITS keyword.
"""
return self.meta.get('lvl_num', None)
@property
def bottom_left_coord(self):
"""
The physical coordinate at the center of the bottom left ([0, 0]) pixel.
"""
return self.wcs.pixel_to_world(0, 0)
@property
def top_right_coord(self):