Passed
Pull Request — develop (#76)
by Angeline
01:38
created

apexpy.apex.Apex.__str__()   A

Complexity

Conditions 1

Size

Total Lines 12
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

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