Passed
Pull Request — main (#103)
by Angeline
01:39
created

apexpy.apex.Apex.__eq__()   B

Complexity

Conditions 8

Size

Total Lines 46
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 18
dl 0
loc 46
rs 7.3333
c 0
b 0
f 0
cc 8
nop 2
1
# -*- coding: utf-8 -*-
2
"""Classes that make up the core of apexpy."""
3
4
import datetime as dt
5
import numpy as np
6
import os
7
import warnings
8
9
from apexpy import helpers
10
11
# Below try..catch required for autodoc to work on readthedocs
12
try:
13
    from apexpy import fortranapex as fa
14
except ImportError as ierr:
15
    warnings.warn("".join(["fortranapex module could not be imported, so ",
16
                           "apexpy probably won't work.  Make sure you have ",
17
                           "a gfortran compiler. Failed with error: ",
18
                           str(ierr)]))
19
20
# Make sure invalid warnings are always shown
21
warnings.filterwarnings('always', message='.*set to NaN where*',
22
                        module='apexpy.apex')
23
24
25
class ApexHeightError(ValueError):
26
    """Specialized ValueError definition, to be used when apex height is wrong.
27
    """
28
    pass
29
30
31
class Apex(object):
32
    """Calculates coordinate conversions, field-line mapping, and base vectors.
33
34
    Parameters
35
    ----------
36
    date : NoneType, float, :class:`dt.date`, or :class:`dt.datetime`, optional
37
        Determines which IGRF coefficients are used in conversions. Uses
38
        current date as default.  If float, use decimal year.  If None, uses
39
        current UTC. (default=None)
40
    refh : float, optional
41
        Reference height in km for apex coordinates, the field lines are mapped
42
        to this height. (default=0)
43
    datafile : str or NoneType, optional
44
        Path to custom coefficient file, if None uses `apexsh.dat` file
45
        (default=None)
46
    fortranlib : str or NoneType, optional
47
        Path to Fortran Apex CPython library, if None uses linked library file
48
        (default=None)
49
50
    Attributes
51
    ----------
52
    year : float
53
        Decimal year used for the IGRF model
54
    RE : float
55
        Earth radius in km, defaults to mean Earth radius
56
    refh : float
57
        Reference height in km for apex coordinates
58
    datafile : str
59
        Path to coefficient file
60
    fortranlib : str
61
        Path to Fortran Apex CPython library
62
    igrf_fn : str
63
        IGRF coefficient filename
64
65
    Notes
66
    -----
67
    The calculations use IGRF-13 with coefficients from 1900 to 2025 [1]_.
68
69
    The geodetic reference ellipsoid is WGS84.
70
71
    References
72
    ----------
73
74
    .. [1] Thébault, E. et al. (2015), International Geomagnetic Reference
75
           Field: the 12th generation, Earth, Planets and Space, 67(1), 79,
76
           :doi:`10.1186/s40623-015-0228-9`.
77
78
    """
79
80
    # ------------------------
81
    # Define the magic methods
82
83
    def __init__(self, date=None, refh=0, datafile=None, fortranlib=None):
84
85
        if datafile is None:
86
            datafile = os.path.join(os.path.dirname(__file__), 'apexsh.dat')
87
88
        if fortranlib is None:
89
            fortranlib = fa.__file__
90
91
        self.RE = 6371.009  # Mean Earth radius in km
92
        self.set_refh(refh)  # Reference height in km
93
94
        if date is None:
95
            self.year = helpers.toYearFraction(dt.datetime.utcnow())
96
        else:
97
            try:
98
                # Convert date/datetime object to decimal year
99
                self.year = helpers.toYearFraction(date)
100
            except AttributeError:
101
                # Failed while finding datetime attribute, so
102
                # date is probably an int or float; use directly
103
                self.year = date
104
105
        if not os.path.isfile(datafile):
106
            raise IOError('Data file does not exist: {}'.format(datafile))
107
108
        if not os.path.isfile(fortranlib):
109
            raise IOError('Fortran library does not exist: {}'.format(
110
                fortranlib))
111
112
        self.datafile = datafile
113
        self.fortranlib = fortranlib
114
115
        # Set the IGRF coefficient text file name
116
        self.igrf_fn = os.path.join(os.path.dirname(__file__),
117
                                    'igrf13coeffs.txt')
118
        if not os.path.exists(self.igrf_fn):
119
            raise OSError("File {} does not exist".format(self.igrf_fn))
120
121
        # Update the Fortran epoch using the year defined above
122
        self.set_epoch(self.year)
123
124
        # Vectorize the fortran functions
125
        self._geo2qd = np.frompyfunc(
126
            lambda glat, glon, height: fa.apxg2q(glat, (glon + 180) % 360 - 180,
127
                                                 height, 0)[:2], 3, 2)
128
        self._geo2apex = np.frompyfunc(
129
            lambda glat, glon, height: fa.apxg2all(glat, (glon + 180) % 360
130
                                                   - 180, height, self.refh,
131
                                                   0)[2:4], 3, 2)
132
        self._geo2apexall = np.frompyfunc(
133
            lambda glat, glon, height: fa.apxg2all(glat, (glon + 180) % 360
134
                                                   - 180, height, self.refh,
135
                                                   1), 3, 14)
136
        self._qd2geo = np.frompyfunc(
137
            lambda qlat, qlon, height, precision: fa.apxq2g(qlat, (qlon + 180)
138
                                                            % 360 - 180, height,
139
                                                            precision), 4, 3)
140
        self._basevec = np.frompyfunc(
141
            lambda glat, glon, height: fa.apxg2q(glat, (glon + 180) % 360 - 180,
142
                                                 height, 1)[2:4], 3, 2)
143
144
        # Vectorize other nonvectorized functions
145
        self._apex2qd = np.frompyfunc(self._apex2qd_nonvectorized, 3, 2)
146
        self._qd2apex = np.frompyfunc(self._qd2apex_nonvectorized, 3, 2)
147
        self._get_babs = np.frompyfunc(self._get_babs_nonvectorized, 3, 1)
148
149
        return
150
151
    def __repr__(self):
152
        """Produce an evaluatable representation of the Apex class."""
153
        rstr = "".join(["apexpy.Apex(date=", self.year.__repr__(), ", refh=",
154
                        self.refh.__repr__(), ", datafile=",
155
                        self.datafile.__repr__(), ", fortranlib=",
156
                        self.fortranlib.__repr__(), ")"])
157
158
        return rstr
159
160
    def __str__(self):
161
        """Produce a user-friendly representation of the Apex class."""
162
163
        out_str = '\n'.join(["Apex class conversions performed with",
164
                             "-------------------------------------",
165
                             "Decimal year: {:.8f}".format(self.year),
166
                             "Reference height: {:.3f} km".format(self.refh),
167
                             "Earth radius: {:.3f} km".format(self.RE), '',
168
                             "Coefficient file: '{:s}'".format(self.datafile),
169
                             "Cython Fortran library: '{:s}'".format(
170
                                 self.fortranlib)])
171
        return out_str
172
173
    def __eq__(self, comp_obj):
174
        """Performs equivalency evaluation.
175
176
        Parameters
177
        ----------
178
        comp_obj
179
            Object of any time to be compared to the class object
180
181
        Returns
182
        -------
183
        bool or NotImplemented
184
            True if self and comp_obj are identical, False if they are not,
185
            and NotImplemented if the classes are not the same
186
187
        """
188
189
        if isinstance(comp_obj, self.__class__):
190
            # Objects are the same if all the attributes are the same
191
            for apex_attr in ['year', 'refh', 'RE', 'datafile', 'fortranlib',
192
                              'igrf_fn']:
193
                bad_attr = False
194
                if hasattr(self, apex_attr):
195
                    aval = getattr(self, apex_attr)
196
197
                    if hasattr(comp_obj, apex_attr):
198
                        cval = getattr(comp_obj, apex_attr)
199
200
                        if cval != aval:
201
                            # Not equal, as the attribute values differ
202
                            bad_attr = True
203
                    else:
204
                        # The comparison object is missing an attribute
205
                        bad_attr = True
206
                else:
207
                    if hasattr(comp_obj, apex_attr):
208
                        # The current object is missing an attribute
209
                        bad_attr = True
210
211
                if bad_attr:
212
                    return False
213
214
            # If we arrive here, all expected attributes exist in both classes
215
            # and have the same values
216
            return True
217
218
        return NotImplemented
219
220
    # -------------------------
221
    # Define the hidden methods
222
223 View Code Duplication
    def _apex2qd_nonvectorized(self, alat, alon, height):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
224
        """Convert from apex to quasi-dipole (not-vectorised)
225
226
        Parameters
227
        -----------
228
        alat : (float)
229
            Apex latitude in degrees
230
        alon : (float)
231
            Apex longitude in degrees
232
        height : (float)
233
            Height in km
234
235
        Returns
236
        ---------
237
        qlat : (float)
238
            Quasi-dipole latitude in degrees
239
        qlon : (float)
240
            Quasi-diplole longitude in degrees
241
242
        """
243
        # Evaluate the latitude
244
        alat = helpers.checklat(alat, name='alat')
245
246
        # Convert modified apex to quasi-dipole, longitude is the same
247
        qlon = alon
248
249
        # Get the apex height
250
        h_apex = self.get_apex(alat)
251
252
        if h_apex < height:
253
            if np.isclose(h_apex, height, rtol=0, atol=1e-5):
254
                # Allow for values that are close
255
                h_apex = height
256
            else:
257
                estr = ''.join(['height {:.3g} is > '.format(np.max(height)),
258
                                'apex height {:.3g} for alat {:.3g}'.format(
259
                                    h_apex, alat)])
260
                raise ApexHeightError(estr)
261
262
        # Convert the latitude
263
        salat = np.sign(alat) if alat != 0 else 1
264
        qlat = salat * np.degrees(np.arccos(np.sqrt((self.RE + height)
265
                                                    / (self.RE + h_apex))))
266
267
        return qlat, qlon
268
269 View Code Duplication
    def _qd2apex_nonvectorized(self, qlat, qlon, height):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
270
        """Converts quasi-dipole to modified apex coordinates.
271
272
        Parameters
273
        ----------
274
        qlat : float
275
            Quasi-dipole latitude
276
        qlon : float
277
            Quasi-dipole longitude
278
        height : float
279
            Altitude in km
280
281
        Returns
282
        -------
283
        alat : float
284
            Modified apex latitude
285
        alon : float
286
            Modified apex longitude
287
288
        Raises
289
        ------
290
        ApexHeightError
291
            if apex height < reference height
292
293
        """
294
        # Evaluate the latitude
295
        qlat = helpers.checklat(qlat, name='qlat')
296
297
        # Get the longitude and apex height
298
        alon = qlon
299
        h_apex = self.get_apex(qlat, height)
300
301
        if h_apex < self.refh:
302
            if np.isclose(h_apex, self.refh, rtol=0, atol=1e-5):
303
                # Allow for values that are close
304
                h_apex = self.refh
305
            else:
306
                estr = ''.join(['apex height ({:.3g}) is < '.format(h_apex),
307
                                'reference height ({:.3g})'.format(self.refh),
308
                                ' for qlat {:.3g}'.format(qlat)])
309
                raise ApexHeightError(estr)
310
311
        # Convert the latitude
312
        sqlat = np.sign(qlat) if qlat != 0 else 1
313
        alat = sqlat * np.degrees(np.arccos(np.sqrt((self.RE + self.refh)
314
                                                    / (self.RE + h_apex))))
315
316
        return alat, alon
317
318
    def _map_EV_to_height(self, alat, alon, height, newheight, data, ev_flag):
319
        """Maps electric field related values to a desired height
320
321
        Parameters
322
        ----------
323
        alat : array-like
324
            Apex latitude in degrees.
325
        alon : array-like
326
            Apex longitude in degrees.
327
        height : array-like
328
            Current altitude in km.
329
        new_height : array-like
330
            Desired altitude to which EV values will be mapped in km.
331
        data : array-like
332
            3D value(s) for the electric field or electric drift
333
        ev_flag : str
334
           Specify if value is an electric field ('E') or electric drift ('V')
335
336
        Returns
337
        -------
338
        data_mapped : array-like
339
            Data mapped along the magnetic field from the old height to the
340
            new height.
341
342
        Raises
343
        ------
344
        ValueError
345
            If an unknown `ev_flag` or badly shaped data input is supplied.
346
347
        """
348
        # Ensure the E-V flag is correct
349
        ev_flag = ev_flag.upper()
350
        if ev_flag not in ['E', 'V']:
351
            raise ValueError('unknown electric field/drift flag')
352
353
        # Make sure data is array of correct shape
354
        if not (np.ndim(data) == 1
355
                and np.size(data) == 3) and not (np.ndim(data) == 2
356
                                                 and np.shape(data)[0] == 3):
357
            # Raise ValueError because if passing e.g. a (6,) ndarray the
358
            # reshape below will work even though the input is invalid
359
            raise ValueError('{:} must be (3, N) or (3,) ndarray'.format(
360
                ev_flag))
361
362
        data = np.reshape(data, (3, np.size(data) // 3))
363
364
        # Get the necessary base vectors at the current and new height
365
        v1 = list()
366
        v2 = list()
367
        for i, alt in enumerate([height, newheight]):
368
            _, _, _, _, _, _, d1, d2, _, e1, e2, _ = self.basevectors_apex(
369
                alat, alon, alt, coords='apex')
370
371
            if ev_flag == 'E' and i == 0 or ev_flag == 'V' and i == 1:
372
                v1.append(e1)
373
                v2.append(e2)
374
            else:
375
                v1.append(d1)
376
                v2.append(d2)
377
378
            # Make sure v1 and v2 have shape (3, N)
379
            v1[-1] = np.reshape(v1[-1], (3, v1[-1].size // 3))
380
            v2[-1] = np.reshape(v2[-1], (3, v2[-1].size // 3))
381
382
        # Take the dot product between the data value and each base vector
383
        # at the current height
384
        data1 = np.sum(data * v1[0], axis=0)
385
        data2 = np.sum(data * v2[0], axis=0)
386
387
        # Map the data to the new height, removing any axes of length 1
388
        # after the calculation
389
        data_mapped = np.squeeze(data1[np.newaxis, :] * v1[1]
390
                                 + data2[np.newaxis, :] * v2[1])
391
392
        return data_mapped
393
394
    def _get_babs_nonvectorized(self, glat, glon, height):
395
        """Get the absolute value of the B-field in Tesla
396
397
        Parameters
398
        ----------
399
        glat : float
400
            Geodetic latitude in degrees
401
        glon : float
402
            Geodetic longitude in degrees
403
        height : float
404
            Altitude in km
405
406
        Returns
407
        -------
408
        babs : float
409
            Absolute value of the magnetic field in Tesla
410
411
        """
412
        # Evaluate the latitude
413
        glat = helpers.checklat(glat, name='qlat')
414
415
        # Get the magnetic field output: North, East, Down, Absolute value
416
        bout = fa.feldg(1, glat, glon, height)
417
418
        # Convert the absolute value from Gauss to Tesla
419
        babs = bout[-1] / 10000.0
420
421
        return babs
422
423
    # -----------------------
424
    # Define the user methods
425
426
    def convert(self, lat, lon, source, dest, height=0, datetime=None,
427
                precision=1e-10, ssheight=50 * 6371):
428
        """Converts between geodetic, modified apex, quasi-dipole and MLT.
429
430
        Parameters
431
        ----------
432
        lat : array_like
433
            Latitude in degrees
434
        lon : array_like
435
            Longitude in degrees or MLT in hours
436
        source : str
437
            Input coordinate system, accepts 'geo', 'apex', 'qd', or 'mlt'
438
        dest : str
439
            Output coordinate system, accepts 'geo', 'apex', 'qd', or 'mlt'
440
        height : array_like, optional
441
            Altitude in km
442
        datetime : :class:`datetime.datetime`
443
            Date and time for MLT conversions (required for MLT conversions)
444
        precision : float, optional
445
            Precision of output (degrees) when converting to geo. A negative
446
            value of this argument produces a low-precision calculation of
447
            geodetic lat/lon based only on their spherical harmonic
448
            representation.
449
            A positive value causes the underlying Fortran routine to iterate
450
            until feeding the output geo lat/lon into geo2qd (APXG2Q) reproduces
451
            the input QD lat/lon to within the specified precision (all
452
            coordinates being converted to geo are converted to QD first and
453
            passed through APXG2Q).
454
        ssheight : float, optional
455
            Altitude in km to use for converting the subsolar point from
456
            geographic to magnetic coordinates. A high altitude is used
457
            to ensure the subsolar point is mapped to high latitudes, which
458
            prevents the South-Atlantic Anomaly (SAA) from influencing the MLT.
459
460
        Returns
461
        -------
462
        lat : ndarray or float
463
            Converted latitude (if converting to MLT, output latitude is apex)
464
        lon : ndarray or float
465
            Converted longitude or MLT
466
467
        Raises
468
        ------
469
        ValueError
470
            For unknown source or destination coordinate system or a missing
471
            or badly formed latitude or datetime input
472
473
        """
474
        # Test the input values
475
        source = source.lower()
476
        dest = dest.lower()
477
478
        if source not in ['geo', 'apex', 'qd', 'mlt'] \
479
           or dest not in ['geo', 'apex', 'qd', 'mlt']:
480
            estr = 'Unknown coordinate transformation: {} -> {}'.format(source,
481
                                                                        dest)
482
            raise ValueError(estr)
483
484
        if datetime is None and ('mlt' in [source, dest]):
485
            raise ValueError('datetime must be given for MLT calculations')
486
487
        lat = helpers.checklat(lat)
488
489
        if source == dest:
490
            # The source and destination are the same, no conversion necessary
491
            return lat, lon
492
        else:
493
            # Select the correct conversion method
494
            if source == 'geo':
495
                if dest == 'qd':
496
                    lat, lon = self.geo2qd(lat, lon, height)
497
                else:
498
                    lat, lon = self.geo2apex(lat, lon, height)
499
                    if dest == 'mlt':
500
                        lon = self.mlon2mlt(lon, datetime, ssheight=ssheight)
501
            elif source == 'apex':
502
                if dest == 'geo':
503
                    lat, lon, _ = self.apex2geo(lat, lon, height,
504
                                                precision=precision)
505
                elif dest == 'qd':
506
                    lat, lon = self.apex2qd(lat, lon, height=height)
507
                elif dest == 'mlt':
508
                    lon = self.mlon2mlt(lon, datetime, ssheight=ssheight)
509
            elif source == 'qd':
510
                if dest == 'geo':
511
                    lat, lon, _ = self.qd2geo(lat, lon, height,
512
                                              precision=precision)
513
                else:
514
                    lat, lon = self.qd2apex(lat, lon, height=height)
515
                    if dest == 'mlt':
516
                        lon = self.mlon2mlt(lon, datetime, ssheight=ssheight)
517
            elif source == 'mlt':
518
                # From MLT means that the input latitude is assumed to be apex,
519
                # so we don't need to update latitude for apex conversions.
520
                lon = self.mlt2mlon(lon, datetime, ssheight=ssheight)
521
                if dest == 'geo':
522
                    lat, lon, _ = self.apex2geo(lat, lon, height,
523
                                                precision=precision)
524
                elif dest == 'qd':
525
                    lat, lon = self.apex2qd(lat, lon, height=height)
526
527
        return lat, lon
528
529
    def geo2apex(self, glat, glon, height):
530
        """Converts geodetic to modified apex coordinates.
531
532
        Parameters
533
        ----------
534
        glat : array_like
535
            Geodetic latitude
536
        glon : array_like
537
            Geodetic longitude
538
        height : array_like
539
            Altitude in km
540
541
        Returns
542
        -------
543
        alat : ndarray or float
544
            Modified apex latitude
545
        alon : ndarray or float
546
            Modified apex longitude
547
548
        """
549
550
        glat = helpers.checklat(glat, name='glat')
551
552
        alat, alon = self._geo2apex(glat, glon, height)
553
554
        if np.any(alat == -9999):
555
            warnings.warn(''.join(['Apex latitude set to NaN where undefined ',
556
                                   '(apex height may be < reference height)']))
557
            if np.isscalar(alat):
558
                alat = np.nan
559
            else:
560
                alat[alat == -9999] = np.nan
561
562
        # If array is returned, dtype is object, so convert to float
563
        return np.float64(alat), np.float64(alon)
564
565
    def apex2geo(self, alat, alon, height, precision=1e-10):
566
        """Converts modified apex to geodetic coordinates.
567
568
        Parameters
569
        ----------
570
        alat : array_like
571
            Modified apex latitude
572
        alon : array_like
573
            Modified apex longitude
574
        height : array_like
575
            Altitude in km
576
        precision : float, optional
577
            Precision of output (degrees). A negative value of this argument
578
            produces a low-precision calculation of geodetic lat/lon based only
579
            on their spherical harmonic representation. A positive value causes
580
            the underlying Fortran routine to iterate until feeding the output
581
            geo lat/lon into geo2qd (APXG2Q) reproduces the input QD lat/lon to
582
            within the specified precision.
583
584
        Returns
585
        -------
586
        glat : ndarray or float
587
            Geodetic latitude
588
        glon : ndarray or float
589
            Geodetic longitude
590
        error : ndarray or float
591
            The angular difference (degrees) between the input QD coordinates
592
            and the qlat/qlon produced by feeding the output glat and glon
593
            into geo2qd (APXG2Q)
594
595
        """
596
        # Evaluate the latitude
597
        alat = helpers.checklat(alat, name='alat')
598
599
        # Perform the two-step convertion to geodetic coordinates
600
        qlat, qlon = self.apex2qd(alat, alon, height=height)
601
        glat, glon, error = self.qd2geo(qlat, qlon, height, precision=precision)
602
603
        return glat, glon, error
604
605
    def geo2qd(self, glat, glon, height):
606
        """Converts geodetic to quasi-dipole coordinates.
607
608
        Parameters
609
        ----------
610
        glat : array_like
611
            Geodetic latitude
612
        glon : array_like
613
            Geodetic longitude
614
        height : array_like
615
            Altitude in km
616
617
        Returns
618
        -------
619
        qlat : ndarray or float
620
            Quasi-dipole latitude
621
        qlon : ndarray or float
622
            Quasi-dipole longitude
623
624
        """
625
        # Evaluate the latitude
626
        glat = helpers.checklat(glat, name='glat')
627
628
        # Convert to quasi-dipole coordinates
629
        qlat, qlon = self._geo2qd(glat, glon, height)
630
631
        # If array is returned, dtype is object, so convert to float
632
        return np.float64(qlat), np.float64(qlon)
633
634
    def qd2geo(self, qlat, qlon, height, precision=1e-10):
635
        """Converts quasi-dipole to geodetic coordinates.
636
637
        Parameters
638
        ----------
639
        qlat : array_like
640
            Quasi-dipole latitude
641
        qlon : array_like
642
            Quasi-dipole longitude
643
        height : array_like
644
            Altitude in km
645
        precision : float, optional
646
            Precision of output (degrees). A negative value of this argument
647
            produces a low-precision calculation of geodetic lat/lon based only
648
            on their spherical harmonic representation. A positive value causes
649
            the underlying Fortran routine to iterate until feeding the output
650
            geo lat/lon into geo2qd (APXG2Q) reproduces the input QD lat/lon to
651
            within the specified precision.
652
653
        Returns
654
        -------
655
        glat : ndarray or float
656
            Geodetic latitude
657
        glon : ndarray or float
658
            Geodetic longitude
659
        error : ndarray or float
660
            The angular difference (degrees) between the input QD coordinates
661
            and the qlat/qlon produced by feeding the output glat and glon
662
            into geo2qd (APXG2Q)
663
664
        """
665
        # Evaluate the latitude
666
        qlat = helpers.checklat(qlat, name='qlat')
667
668
        # Convert to geodetic coordinates
669
        glat, glon, error = self._qd2geo(qlat, qlon, height, precision)
670
671
        # If array is returned, dtype is object, so convert to float
672
        return np.float64(glat), np.float64(glon), np.float64(error)
673
674
    def apex2qd(self, alat, alon, height):
675
        """Converts modified apex to quasi-dipole coordinates.
676
677
        Parameters
678
        ----------
679
        alat : array_like
680
            Modified apex latitude
681
        alon : array_like
682
            Modified apex longitude
683
        height : array_like
684
            Altitude in km
685
686
        Returns
687
        -------
688
        qlat : ndarray or float
689
            Quasi-dipole latitude
690
        qlon : ndarray or float
691
            Quasi-dipole longitude
692
693
        Raises
694
        ------
695
        ApexHeightError
696
            if `height` > apex height
697
698
        """
699
        # Convert the latitude to apex, latitude check performed in the
700
        # hidden method
701
        qlat, qlon = self._apex2qd(alat, alon, height)
702
703
        # If array is returned, the dtype is object, so convert to float
704
        return np.float64(qlat), np.float64(qlon)
705
706
    def qd2apex(self, qlat, qlon, height):
707
        """Converts quasi-dipole to modified apex coordinates.
708
709
        Parameters
710
        ----------
711
        qlat : array_like
712
            Quasi-dipole latitude
713
        qlon : array_like
714
            Quasi-dipole longitude
715
        height : array_like
716
            Altitude in km
717
718
        Returns
719
        -------
720
        alat : ndarray or float
721
            Modified apex latitude
722
        alon : ndarray or float
723
            Modified apex longitude
724
725
        Raises
726
        ------
727
        ApexHeightError
728
            if apex height < reference height
729
730
        """
731
        # Perform the conversion from quasi-dipole to apex coordinates
732
        alat, alon = self._qd2apex(qlat, qlon, height)
733
734
        # If array is returned, the dtype is object, so convert to float
735
        return np.float64(alat), np.float64(alon)
736
737
    def mlon2mlt(self, mlon, dtime, ssheight=318550):
738
        """Computes the magnetic local time at the specified magnetic longitude
739
        and UT.
740
741
        Parameters
742
        ----------
743
        mlon : array_like
744
            Magnetic longitude (apex and quasi-dipole longitude are always
745
            equal)
746
        dtime : :class:`datetime.datetime`
747
            Date and time
748
        ssheight : float, optional
749
            Altitude in km to use for converting the subsolar point from
750
            geographic to magnetic coordinates. A high altitude is used
751
            to ensure the subsolar point is mapped to high latitudes, which
752
            prevents the South-Atlantic Anomaly (SAA) from influencing the MLT.
753
            The current default is  50 * 6371, roughly 50 RE. (default=318550)
754
755
        Returns
756
        -------
757
        mlt : ndarray or float
758
            Magnetic local time in hours [0, 24)
759
760
        Notes
761
        -----
762
        To compute the MLT, we find the apex longitude of the subsolar point at
763
        the given time. Then the MLT of the given point will be computed from
764
        the separation in magnetic longitude from this point (1 hour = 15
765
        degrees).
766
767
        """
768
        # Get the subsolar location
769
        ssglat, ssglon = helpers.subsol(dtime)
770
771
        # Convert the subsolar location to apex coordinates
772
        _, ssalon = self.geo2apex(ssglat, ssglon, ssheight)
773
774
        # Calculate the magnetic local time (0-24 h range) from apex longitude.
775
        # np.float64 will ensure lists are converted to arrays
776
        mlt = (180 + np.float64(mlon) - ssalon) / 15 % 24
777
778
        return mlt
779
780
    def mlt2mlon(self, mlt, dtime, ssheight=318550):
781
        """Computes the magnetic longitude at the specified MLT and UT.
782
783
        Parameters
784
        ----------
785
        mlt : array_like
786
            Magnetic local time
787
        dtime : :class:`datetime.datetime`
788
            Date and time
789
        ssheight : float, optional
790
            Altitude in km to use for converting the subsolar point from
791
            geographic to magnetic coordinates. A high altitude is used
792
            to ensure the subsolar point is mapped to high latitudes, which
793
            prevents the South-Atlantic Anomaly (SAA) from influencing the MLT.
794
            The current default is  50 * 6371, roughly 50 RE. (default=318550)
795
796
        Returns
797
        -------
798
        mlon : ndarray or float
799
            Magnetic longitude [0, 360) (apex and quasi-dipole longitude are
800
            always equal)
801
802
        Notes
803
        -----
804
        To compute the magnetic longitude, we find the apex longitude of the
805
        subsolar point at the given time. Then the magnetic longitude of the
806
        given point will be computed from the separation in magnetic local time
807
        from this point (1 hour = 15 degrees).
808
        """
809
        # Get the location of the subsolar point at this time
810
        ssglat, ssglon = helpers.subsol(dtime)
811
812
        # Convert the location of the subsolar point to apex coordinates
813
        _, ssalon = self.geo2apex(ssglat, ssglon, ssheight)
814
815
        # Calculate the magnetic longitude (0-360 h range) from MLT.
816
        # np.float64 will ensure lists are converted to arrays
817
        mlon = (15 * np.float64(mlt) - 180 + ssalon + 360) % 360
818
819
        return mlon
820
821
    def map_to_height(self, glat, glon, height, newheight, conjugate=False,
822
                      precision=1e-10):
823
        """Performs mapping of points along the magnetic field to the closest
824
        or conjugate hemisphere.
825
826
        Parameters
827
        ----------
828
        glat : array_like
829
            Geodetic latitude
830
        glon : array_like
831
            Geodetic longitude
832
        height : array_like
833
            Source altitude in km
834
        newheight : array_like
835
            Destination altitude in km
836
        conjugate : bool, optional
837
            Map to `newheight` in the conjugate hemisphere instead of the
838
            closest hemisphere
839
        precision : float, optional
840
            Precision of output (degrees). A negative value of this argument
841
            produces a low-precision calculation of geodetic lat/lon based only
842
            on their spherical harmonic representation. A positive value causes
843
            the underlying Fortran routine to iterate until feeding the output
844
            geo lat/lon into geo2qd (APXG2Q) reproduces the input QD lat/lon to
845
            within the specified precision.
846
847
        Returns
848
        -------
849
        newglat : ndarray or float
850
            Geodetic latitude of mapped point
851
        newglon : ndarray or float
852
            Geodetic longitude of mapped point
853
        error : ndarray or float
854
            The angular difference (degrees) between the input QD coordinates
855
            and the qlat/qlon produced by feeding the output glat and glon
856
            into geo2qd (APXG2Q)
857
858
        Notes
859
        -----
860
        The mapping is done by converting glat/glon/height to modified apex
861
        lat/lon, and converting back to geographic using newheight (if
862
        conjugate, use negative apex latitude when converting back)
863
864
        """
865
866
        alat, alon = self.geo2apex(glat, glon, height)
867
        if conjugate:
868
            alat = -alat
869
        try:
870
            newglat, newglon, error = self.apex2geo(alat, alon, newheight,
871
                                                    precision=precision)
872
        except ApexHeightError:
873
            raise ApexHeightError("input height is > apex height")
874
875
        return newglat, newglon, error
876
877
    def map_E_to_height(self, alat, alon, height, newheight, edata):
878
        """Performs mapping of electric field along the magnetic field.
879
880
        It is assumed that the electric field is perpendicular to B.
881
882
        Parameters
883
        ----------
884
        alat : (N,) array_like or float
885
            Modified apex latitude
886
        alon : (N,) array_like or float
887
            Modified apex longitude
888
        height : (N,) array_like or float
889
            Source altitude in km
890
        newheight : (N,) array_like or float
891
            Destination altitude in km
892
        edata : (3,) or (3, N) array_like
893
            Electric field (at `alat`, `alon`, `height`) in geodetic east,
894
            north, and up components
895
896
        Returns
897
        -------
898
        out : (3, N) or (3,) ndarray
899
            The electric field at `newheight` (geodetic east, north, and up
900
            components)
901
902
        """
903
        # Call hidden mapping method with flag for the electric field
904
        out = self._map_EV_to_height(alat, alon, height, newheight, edata, 'E')
905
906
        return out
907
908
    def map_V_to_height(self, alat, alon, height, newheight, vdata):
909
        """Performs mapping of electric drift velocity along the magnetic field.
910
911
        It is assumed that the electric field is perpendicular to B.
912
913
        Parameters
914
        ----------
915
        alat : (N,) array_like or float
916
            Modified apex latitude
917
        alon : (N,) array_like or float
918
            Modified apex longitude
919
        height : (N,) array_like or float
920
            Source altitude in km
921
        newheight : (N,) array_like or float
922
            Destination altitude in km
923
        vdata : (3,) or (3, N) array_like
924
            Electric drift velocity (at `alat`, `alon`, `height`) in geodetic
925
            east, north, and up components
926
927
        Returns
928
        -------
929
        out : (3, N) or (3,) ndarray
930
            The electric drift velocity at `newheight` (geodetic east, north,
931
            and up components)
932
933
        """
934
        # Call hidden mapping method with flag for the electric drift velocities
935
        out = self._map_EV_to_height(alat, alon, height, newheight, vdata, 'V')
936
937
        return out
938
939
    def basevectors_qd(self, lat, lon, height, coords='geo', precision=1e-10):
940
        """Get quasi-dipole base vectors f1 and f2 at the specified coordinates.
941
942
        Parameters
943
        ----------
944
        lat : (N,) array_like or float
945
            Latitude
946
        lon : (N,) array_like or float
947
            Longitude
948
        height : (N,) array_like or float
949
            Altitude in km
950
        coords : {'geo', 'apex', 'qd'}, optional
951
            Input coordinate system
952
        precision : float, optional
953
            Precision of output (degrees) when converting to geo. A negative
954
            value of this argument produces a low-precision calculation of
955
            geodetic lat/lon based only on their spherical harmonic
956
            representation.
957
            A positive value causes the underlying Fortran routine to iterate
958
            until feeding the output geo lat/lon into geo2qd (APXG2Q) reproduces
959
            the input QD lat/lon to within the specified precision (all
960
            coordinates being converted to geo are converted to QD first and
961
            passed through APXG2Q).
962
963
        Returns
964
        -------
965
        f1 : (2, N) or (2,) ndarray
966
        f2 : (2, N) or (2,) ndarray
967
968
        Notes
969
        -----
970
        The vectors are described by Richmond [1995] [2]_ and
971
        Emmert et al. [2010] [3]_.  The vector components are geodetic east and
972
        north.
973
974
        References
975
        ----------
976
        .. [2] Richmond, A. D. (1995), Ionospheric Electrodynamics Using
977
               Magnetic Apex Coordinates, Journal of geomagnetism and
978
               geoelectricity, 47(2), 191–212, :doi:`10.5636/jgg.47.191`.
979
980
        .. [3] Emmert, J. T., A. D. Richmond, and D. P. Drob (2010),
981
               A computationally compact representation of Magnetic-Apex
982
               and Quasi-Dipole coordinates with smooth base vectors,
983
               J. Geophys. Res., 115(A8), A08322, :doi:`10.1029/2010JA015326`.
984
985
        """
986
        # Convert from current coordinates to geodetic coordinates
987
        glat, glon = self.convert(lat, lon, coords, 'geo', height=height,
988
                                  precision=precision)
989
990
        # Get the geodetic base vectors
991
        f1, f2 = self._basevec(glat, glon, height)
992
993
        # If inputs are not scalar, each vector is an array of arrays,
994
        # so reshape to a single array
995
        if f1.dtype == object:
996
            f1 = np.vstack(f1).T
997
            f2 = np.vstack(f2).T
998
999
        return f1, f2
1000
1001
    def basevectors_apex(self, lat, lon, height, coords='geo', precision=1e-10):
1002
        """Returns base vectors in quasi-dipole and apex coordinates.
1003
1004
        Parameters
1005
        ----------
1006
        lat : array_like or float
1007
            Latitude in degrees; input must be broadcastable with `lon` and
1008
            `height`.
1009
        lon : array_like or float
1010
            Longitude in degrees; input must be broadcastable with `lat` and
1011
            `height`.
1012
        height : array_like or float
1013
            Altitude in km; input must be broadcastable with `lon` and `lat`.
1014
        coords : str, optional
1015
            Input coordinate system, expects one of 'geo', 'apex', or 'qd'
1016
            (default='geo')
1017
        precision : float, optional
1018
            Precision of output (degrees) when converting to geo. A negative
1019
            value of this argument produces a low-precision calculation of
1020
            geodetic lat/lon based only on their spherical harmonic
1021
            representation.
1022
            A positive value causes the underlying Fortran routine to iterate
1023
            until feeding the output geo lat/lon into geo2qd (APXG2Q) reproduces
1024
            the input QD lat/lon to within the specified precision (all
1025
            coordinates being converted to geo are converted to QD first and
1026
            passed through APXG2Q).
1027
1028
        Returns
1029
        -------
1030
        f1 : (3, N) or (3,) ndarray
1031
            Quasi-dipole base vector equivalent to e1, tangent to contours of
1032
            constant lambdaA
1033
        f2 : (3, N) or (3,) ndarray
1034
            Quasi-dipole base vector equivalent to e2
1035
        f3 : (3, N) or (3,) ndarray
1036
            Quasi-dipole base vector equivalent to e3, tangent to contours of
1037
            PhiA
1038
        g1 : (3, N) or (3,) ndarray
1039
            Quasi-dipole base vector equivalent to d1
1040
        g2 : (3, N) or (3,) ndarray
1041
            Quasi-dipole base vector equivalent to d2
1042
        g3 : (3, N) or (3,) ndarray
1043
            Quasi-dipole base vector equivalent to d3
1044
        d1 : (3, N) or (3,) ndarray
1045
            Apex base vector normal to contours of constant PhiA
1046
        d2 : (3, N) or (3,) ndarray
1047
            Apex base vector that completes the right-handed system
1048
        d3 : (3, N) or (3,) ndarray
1049
            Apex base vector normal to contours of constant lambdaA
1050
        e1 : (3, N) or (3,) ndarray
1051
            Apex base vector tangent to contours of constant V0
1052
        e2 : (3, N) or (3,) ndarray
1053
            Apex base vector that completes the right-handed system
1054
        e3 : (3, N) or (3,) ndarray
1055
            Apex base vector tangent to contours of constant PhiA
1056
1057
        Notes
1058
        -----
1059
        The vectors are described by Richmond [1995] [4]_ and
1060
        Emmert et al. [2010] [5]_.  The vector components are geodetic east,
1061
        north, and up (only east and north for `f1` and `f2`).
1062
1063
        `f3`, `g1`, `g2`, and `g3` are not part of the Fortran code
1064
        by Emmert et al. [2010] [5]_. They are calculated by this
1065
        Python library according to the following equations in
1066
        Richmond [1995] [4]_:
1067
1068
        * `g1`: Eqn. 6.3
1069
        * `g2`: Eqn. 6.4
1070
        * `g3`: Eqn. 6.5
1071
        * `f3`: Eqn. 6.8
1072
1073
        References
1074
        ----------
1075
        .. [4] Richmond, A. D. (1995), Ionospheric Electrodynamics Using
1076
               Magnetic Apex Coordinates, Journal of geomagnetism and
1077
               geoelectricity, 47(2), 191–212, :doi:`10.5636/jgg.47.191`.
1078
1079
        .. [5] Emmert, J. T., A. D. Richmond, and D. P. Drob (2010),
1080
               A computationally compact representation of Magnetic-Apex
1081
               and Quasi-Dipole coordinates with smooth base vectors,
1082
               J. Geophys. Res., 115(A8), A08322, :doi:`10.1029/2010JA015326`.
1083
1084
        """
1085
        # Convert to geodetic coordinates from current coordinate system
1086
        glat, glon = self.convert(lat, lon, coords, 'geo', height=height,
1087
                                  precision=precision)
1088
1089
        # Retrieve the desired magnetic locations and base vectors
1090
        returnvals = list(self._geo2apexall(glat, glon, height))
1091
        qlat = np.float64(returnvals[0])
1092
        alat = np.float64(returnvals[2])
1093
        bvec_ind = [4, 5, 7, 8, 9, 11, 12, 13]
1094
1095
        # If inputs are not scalar, each vector is an array of arrays,
1096
        # so reshape to a single array
1097
        if returnvals[4].dtype == object:
1098
            for i in bvec_ind:
1099
                returnvals[i] = np.vstack(returnvals[i]).T
1100
1101
        # Make sure the base vector arrays are 2D
1102
        for i in bvec_ind:
1103
            if i in [4, 5]:
1104
                rsize = (2, returnvals[i].size // 2)
1105
            else:
1106
                rsize = (3, returnvals[i].size // 3)
1107
            returnvals[i] = returnvals[i].reshape(rsize)
1108
1109
        # Assign the reshaped base vectors
1110
        f1, f2 = returnvals[4:6]
1111
        d1, d2, d3 = returnvals[7:10]
1112
        e1, e2, e3 = returnvals[11:14]
1113
1114
        # Compute f3, g1, g2, g3 (outstanding quasi-dipole base vectors)
1115
        #
1116
        # Start by calculating the D equivalent, F (F_scalar)
1117
        f1_stack = np.vstack((f1, np.zeros_like(f1[0])))
1118
        f2_stack = np.vstack((f2, np.zeros_like(f2[0])))
1119
        F_scalar = np.cross(f1_stack.T, f2_stack.T).T[-1]
1120
1121
        # Get the cosine of the magnetic inclination
1122
        cos_mag_inc = helpers.getcosIm(alat)
1123
1124
        # Define the k base vectors
1125
        k_unit = np.array([0, 0, 1], dtype=np.float64).reshape((3, 1))
1126
1127
        # Calculate the remaining quasi-dipole base vectors
1128
        g1 = ((self.RE + np.float64(height))
1129
              / (self.RE + self.refh)) ** (3 / 2) * d1 / F_scalar
1130
        g2 = -1.0 / (2.0 * F_scalar * np.tan(np.radians(qlat))) * (
1131
            k_unit + ((self.RE + np.float64(height))
1132
                      / (self.RE + self.refh)) * d2 / cos_mag_inc)
1133
        g3 = k_unit * F_scalar
1134
        f3 = np.cross(g1.T, g2.T).T
1135
1136
        # Reshape the output
1137
        out = [f1, f2, f3, g1, g2, g3, d1, d2, d3, e1, e2, e3]
1138
1139
        if np.any(alat == -9999):
1140
            warnings.warn(''.join(['Base vectors g, d, e, and f3 set to NaN ',
1141
                                   'where apex latitude is undefined (apex ',
1142
                                   'height may be < reference height)']))
1143
            mask = alat == -9999
1144
            for i in range(len(out) - 2):
1145
                out[i + 2] = np.where(mask, np.nan, out[i + 2])
1146
1147
        out = tuple(np.squeeze(bvec) for bvec in out)
1148
1149
        return out
1150
1151
    def get_apex(self, lat, height=None):
1152
        """ Calculate apex height
1153
1154
        Parameters
1155
        ----------
1156
        lat : float
1157
            Apex latitude in degrees
1158
        height : float or NoneType
1159
            Height above the surface of the Earth in km or NoneType to use
1160
            reference height (default=None)
1161
1162
        Returns
1163
        -------
1164
        apex_height : float
1165
            Height of the field line apex in km
1166
1167
        """
1168
        # Check the latitude
1169
        lat = helpers.checklat(lat, name='alat')
1170
1171
        # Ensure the height is set
1172
        if height is None:
1173
            height = self.refh
1174
1175
        # Calculate the apex height
1176
        cos_lat_squared = np.cos(np.radians(lat)) ** 2
1177
        apex_height = (self.RE + height) / cos_lat_squared - self.RE
1178
1179
        return apex_height
1180
1181
    def get_height(self, lat, apex_height):
1182
        """Calculate the height given an apex latitude and apex height.
1183
1184
        Parameters
1185
        ----------
1186
        lat : float
1187
            Apex latitude in degrees
1188
        apex_height : float
1189
            Maximum height of the apex field line above the surface of the
1190
            Earth in km
1191
1192
        Returns
1193
        -------
1194
        height : float
1195
            Height above the surface of the Earth in km
1196
1197
        """
1198
        # Check the latitude
1199
        lat = helpers.checklat(lat, name='alat')
1200
1201
        # Calculate the height from the apex height
1202
        cos_lat_squared = np.cos(np.radians(lat)) ** 2
1203
        height = cos_lat_squared * (apex_height + self.RE) - self.RE
1204
1205
        return height
1206
1207
    def set_epoch(self, year):
1208
        """Updates the epoch for all subsequent conversions.
1209
1210
        Parameters
1211
        ----------
1212
        year : float
1213
            Decimal year
1214
1215
        """
1216
        # Set the year and load the data file
1217
        self.year = np.float64(year)
1218
        fa.loadapxsh(self.datafile, self.year)
1219
1220
        # Call the Fortran routine to set time
1221
        fa.cofrm(self.year, self.igrf_fn)
1222
        return
1223
1224
    def set_refh(self, refh):
1225
        """Updates the apex reference height for all subsequent conversions.
1226
1227
        Parameters
1228
        ----------
1229
        refh : float
1230
            Apex reference height in km
1231
1232
        Notes
1233
        -----
1234
        The reference height is the height to which field lines will be mapped,
1235
        and is only relevant for conversions involving apex (not quasi-dipole).
1236
1237
        """
1238
        self.refh = refh
1239
1240
    def get_babs(self, glat, glon, height):
1241
        """Returns the magnitude of the IGRF magnetic field in tesla.
1242
1243
        Parameters
1244
        ----------
1245
        glat : array_like
1246
            Geodetic latitude in degrees
1247
        glon : array_like
1248
            Geodetic longitude in degrees
1249
        height : array_like
1250
            Altitude in km
1251
1252
        Returns
1253
        -------
1254
        babs : ndarray or float
1255
            Magnitude of the IGRF magnetic field in Tesla
1256
1257
        """
1258
        # Get the absolute value of the magnetic field at the desired location
1259
        babs = self._get_babs(glat, glon, height)
1260
1261
        # If array is returned, the dtype is object, so convert to float
1262
        return np.float64(babs)
1263
1264
    def bvectors_apex(self, lat, lon, height, coords='geo', precision=1e-10):
1265
        """Returns the magnetic field vectors in apex coordinates.
1266
1267
        Parameters
1268
        ----------
1269
        lat : (N,) array_like or float
1270
            Latitude
1271
        lon : (N,) array_like or float
1272
            Longitude
1273
        height : (N,) array_like or float
1274
            Altitude in km
1275
        coords : {'geo', 'apex', 'qd'}, optional
1276
            Input coordinate system
1277
        precision : float, optional
1278
            Precision of output (degrees) when converting to geo. A negative
1279
            value of this argument produces a low-precision calculation of
1280
            geodetic lat/lon based only on their spherical harmonic
1281
            representation.
1282
            A positive value causes the underlying Fortran routine to iterate
1283
            until feeding the output geo lat/lon into geo2qd (APXG2Q) reproduces
1284
            the input QD lat/lon to within the specified precision (all
1285
            coordinates being converted to geo are converted to QD first and
1286
            passed through APXG2Q).
1287
1288
        Returns
1289
        -------
1290
        main_mag_e3: (1, N) or (1,) ndarray
1291
           IGRF magnitude divided by a scaling factor, D (d_scale) to give
1292
           the main B field magnitude along the e3 base vector
1293
        e3 : (3, N) or (3,) ndarray
1294
           Base vector tangent to the contours of constant V_0 and Phi_A
1295
        main_mag_d3: (1, N) or (1,) ndarray
1296
           IGRF magnitude multiplied by a scaling factor, D (d_scale) to give
1297
           the main B field magnitudee along the d3 base vector
1298
        d3 : (3, N) or (3,) ndarray
1299
           Base vector equivalent to the scaled main field unit vector
1300
1301
        Notes
1302
        -----
1303
        See Richmond, A. D. (1995) [4]_ equations 3.8-3.14
1304
1305
        The apex magnetic field vectors described by Richmond [1995] [4]_ and
1306
        Emmert et al. [2010] [5]_, specfically the Be3 (main_mag_e3) and Bd3
1307
        (main_mag_d3) components. The vector components are geodetic east,
1308
        north, and up.
1309
1310
        References
1311
        ----------
1312
        Richmond, A. D. (1995) [4]_
1313
        Emmert, J. T. et al. (2010) [5]_
1314
1315
        """
1316
        # Convert the current coordinates to geodetic coordinates
1317
        glat, glon = self.convert(lat, lon, coords, 'geo', height=height,
1318
                                  precision=precision)
1319
1320
        # Get the magnitude of the magnetic field at the desired location
1321
        babs = self.get_babs(glat, glon, height)
1322
1323
        # Retrieve the necessary base vectors
1324
        _, _, _, _, _, _, d1, d2, d3, _, _, e3 = self.basevectors_apex(
1325
            glat, glon, height, coords='geo')
1326
1327
        # Perform the calculations described in [4]
1328
        d1_cross_d2 = np.cross(d1.T, d2.T).T
1329
        d_scale = np.sqrt(np.sum(d1_cross_d2 ** 2, axis=0))  # D in [4] 3.13
1330
1331
        main_mag_e3 = babs / d_scale  # Solve for b0 in [4] 3.13
1332
        main_mag_d3 = babs * d_scale  # Solve for b0 in [4] 3.10
1333
1334
        return main_mag_e3, e3, main_mag_d3, d3
1335