Passed
Pull Request — develop (#76)
by Angeline
02:48 queued 01:30
created

apexpy.apex.Apex.__repr__()   A

Complexity

Conditions 1

Size

Total Lines 8
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

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