demeteor 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- demeteor-0.1.0/PKG-INFO +21 -0
- demeteor-0.1.0/README.md +0 -0
- demeteor-0.1.0/demeteor/__init__.py +0 -0
- demeteor-0.1.0/demeteor/catalogue/__init__.py +1 -0
- demeteor-0.1.0/demeteor/catalogue/catalogue.py +177 -0
- demeteor-0.1.0/demeteor/classes/loadable.py +26 -0
- demeteor-0.1.0/demeteor/metrics/__init__.py +3 -0
- demeteor-0.1.0/demeteor/metrics/metrics.py +34 -0
- demeteor-0.1.0/demeteor/physics/atmosphere.py +34 -0
- demeteor-0.1.0/demeteor/physics/constants.py +3 -0
- demeteor-0.1.0/demeteor/projections/__init__.py +3 -0
- demeteor-0.1.0/demeteor/projections/base.py +55 -0
- demeteor-0.1.0/demeteor/projections/borovicka.py +85 -0
- demeteor-0.1.0/demeteor/projections/equidistant.py +26 -0
- demeteor-0.1.0/demeteor/projections/koniferka.py +98 -0
- demeteor-0.1.0/demeteor/projections/scalers.py +17 -0
- demeteor-0.1.0/demeteor/projections/shifters.py +114 -0
- demeteor-0.1.0/demeteor/projections/transformers.py +139 -0
- demeteor-0.1.0/demeteor/projections/zenith.py +68 -0
- demeteor-0.1.0/demeteor/trajectory/__init__.py +1 -0
- demeteor-0.1.0/demeteor/trajectory/ray.py +39 -0
- demeteor-0.1.0/pyproject.toml +22 -0
demeteor-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: demeteor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A collection of utility functions for all-sky and spectral meteor cameras
|
|
5
|
+
Author: Martin Odokienko
|
|
6
|
+
Author-email: martin.balaz@fmph.uniba.sk
|
|
7
|
+
Requires-Python: >=3.11,<4.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Dist: astropy
|
|
14
|
+
Requires-Dist: dotmap
|
|
15
|
+
Requires-Dist: numpy
|
|
16
|
+
Requires-Dist: pandas
|
|
17
|
+
Requires-Dist: pyyaml
|
|
18
|
+
Requires-Dist: scipy
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
|
demeteor-0.1.0/README.md
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .catalogue import Catalogue
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from astropy.coordinates import EarthLocation, FK5, SkyCoord, AltAz, get_body, concatenate, Angle
|
|
10
|
+
from astropy.time import Time
|
|
11
|
+
from astropy import units as u
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Catalogue:
|
|
15
|
+
PLANETS = ['mercury', 'venus', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune']
|
|
16
|
+
|
|
17
|
+
def __init__(self, filename: Path = None):
|
|
18
|
+
self._populated = False
|
|
19
|
+
self.planets = []
|
|
20
|
+
self.planets_skycoord = None
|
|
21
|
+
|
|
22
|
+
if filename is not None:
|
|
23
|
+
self._populated = True
|
|
24
|
+
self.stars = pd.read_csv(filename, sep='\t', header=1)
|
|
25
|
+
self.stars_skycoord = SkyCoord(self.stars.ra.to_numpy() * u.deg,
|
|
26
|
+
self.stars.dec.to_numpy() * u.deg,
|
|
27
|
+
frame=FK5(equinox=Time('J2000')))
|
|
28
|
+
else:
|
|
29
|
+
self.stars = []
|
|
30
|
+
|
|
31
|
+
self._mask = np.ones(shape=len(self.stars) + len(self.PLANETS), dtype=bool)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def populated(self) -> bool:
|
|
35
|
+
return self._populated
|
|
36
|
+
|
|
37
|
+
def build_planets(self,
|
|
38
|
+
location: EarthLocation,
|
|
39
|
+
time: Time = None):
|
|
40
|
+
if time is None:
|
|
41
|
+
time = Time(datetime.datetime.now(tz=datetime.UTC))
|
|
42
|
+
|
|
43
|
+
sun = get_body('sun', time=time, location=location)
|
|
44
|
+
|
|
45
|
+
planets = pd.DataFrame(columns=self.stars.columns)
|
|
46
|
+
|
|
47
|
+
index = len(self.stars)
|
|
48
|
+
for name in self.PLANETS:
|
|
49
|
+
body = get_body(name, time=time, location=location)
|
|
50
|
+
sundist = body.hcrs.distance
|
|
51
|
+
phase = body.separation(sun)
|
|
52
|
+
new_planet = pd.DataFrame(
|
|
53
|
+
data=[
|
|
54
|
+
[
|
|
55
|
+
body.ra.degree,
|
|
56
|
+
body.dec.degree,
|
|
57
|
+
body.distance.to(u.lightyear).value,
|
|
58
|
+
self.planet_brightness(name, body.distance, sundist, phase),
|
|
59
|
+
-10
|
|
60
|
+
]
|
|
61
|
+
],
|
|
62
|
+
columns=self.stars.columns,
|
|
63
|
+
index=[index]
|
|
64
|
+
)
|
|
65
|
+
if len(planets) > 0:
|
|
66
|
+
planets = pd.concat([planets, new_planet])
|
|
67
|
+
index += 1
|
|
68
|
+
else:
|
|
69
|
+
planets = new_planet
|
|
70
|
+
|
|
71
|
+
self.planets = planets
|
|
72
|
+
self.planets_skycoord = SkyCoord(planets.ra.to_numpy() * u.deg,
|
|
73
|
+
planets.dec.to_numpy() * u.deg,
|
|
74
|
+
frame=FK5(equinox=Time('J2000')))
|
|
75
|
+
|
|
76
|
+
def radec(self,
|
|
77
|
+
location: EarthLocation,
|
|
78
|
+
time: Time = None,
|
|
79
|
+
*,
|
|
80
|
+
planets: bool = True,
|
|
81
|
+
masked: bool) -> SkyCoord:
|
|
82
|
+
if self._populated:
|
|
83
|
+
if time is None:
|
|
84
|
+
time = Time(datetime.datetime.now(tz=datetime.UTC))
|
|
85
|
+
|
|
86
|
+
if planets:
|
|
87
|
+
self.build_planets(location, time)
|
|
88
|
+
total = concatenate([self.stars_skycoord, self.planets_skycoord])
|
|
89
|
+
else:
|
|
90
|
+
total = self.stars_skycoord
|
|
91
|
+
|
|
92
|
+
return total[self.mask] if masked else total
|
|
93
|
+
else:
|
|
94
|
+
return SkyCoord([] * u.rad, [] * u.rad, frame=FK5(equinox=Time('J2000')))
|
|
95
|
+
|
|
96
|
+
def altaz(self,
|
|
97
|
+
location: EarthLocation,
|
|
98
|
+
time: Time = None,
|
|
99
|
+
*,
|
|
100
|
+
planets: bool = True,
|
|
101
|
+
masked: bool) -> AltAz:
|
|
102
|
+
"""
|
|
103
|
+
Return the catalogue in alt-az coordinates at `location` and at `time`.
|
|
104
|
+
Optionally include planets.
|
|
105
|
+
"""
|
|
106
|
+
if time is None:
|
|
107
|
+
time = Time(datetime.datetime.now(tz=datetime.UTC))
|
|
108
|
+
|
|
109
|
+
altaz = AltAz(location=location, obstime=time, pressure=100000 * u.pascal, obswl=550 * u.nm)
|
|
110
|
+
radec = self.radec(location, time, planets=planets, masked=masked)
|
|
111
|
+
return radec.transform_to(altaz)
|
|
112
|
+
|
|
113
|
+
def vmag(self,
|
|
114
|
+
location: EarthLocation,
|
|
115
|
+
time: Time = None,
|
|
116
|
+
*,
|
|
117
|
+
masked: bool) -> np.ndarray[float]:
|
|
118
|
+
"""
|
|
119
|
+
Return visual magnitudes of all objects at `location` and at `time`.
|
|
120
|
+
Optionally include planets.
|
|
121
|
+
"""
|
|
122
|
+
self.build_planets(location, time)
|
|
123
|
+
vmags = pd.concat([self.stars, self.planets]).vmag.to_numpy()
|
|
124
|
+
return vmags[self.mask] if masked else vmags
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def planet_brightness(planet: str,
|
|
128
|
+
distance_earth: u.Quantity,
|
|
129
|
+
distance_sun: u.Quantity,
|
|
130
|
+
phase: Angle):
|
|
131
|
+
"""
|
|
132
|
+
Get the approximate visual magnitude of a planet.
|
|
133
|
+
Shamelessly stolen from APC, Montenbruck 1999
|
|
134
|
+
"""
|
|
135
|
+
p = phase.degree / 100.0
|
|
136
|
+
|
|
137
|
+
match planet:
|
|
138
|
+
case 'mercury':
|
|
139
|
+
mag = -0.42 + (3.80 - (2.73 - 2 * p) * p) * p
|
|
140
|
+
case 'venus':
|
|
141
|
+
mag = -4.40 + (0.09 + (2.39 - 0.65 * p) * p) * p
|
|
142
|
+
case 'mars':
|
|
143
|
+
mag = -1.52 + 1.6 * p
|
|
144
|
+
case 'jupiter':
|
|
145
|
+
mag = -9.4 + 0.5 * p
|
|
146
|
+
case 'saturn':
|
|
147
|
+
# Currently we do not care about the rings, but it might be worth checking it later
|
|
148
|
+
sd = 0 # np.abs(np.sin(lat))
|
|
149
|
+
dl = 0 # np.abs((dlong + np.pi) % (2 * np.pi) - np.pi) / 100
|
|
150
|
+
mag = -8.88 + 2.60 * sd + 1.25 * sd**2 + 4.4 * dl
|
|
151
|
+
case 'uranus':
|
|
152
|
+
mag = -7.19
|
|
153
|
+
case 'neptune':
|
|
154
|
+
mag = -6.87
|
|
155
|
+
|
|
156
|
+
return mag + 5 * np.log10(distance_earth.to(u.au).value * distance_sun.to(u.au).value)
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def count(self) -> int:
|
|
160
|
+
return len(self.stars) + len(self.planets)
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def count_visible(self) -> int:
|
|
164
|
+
return len(self.mask[self.mask])
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def mask(self) -> np.ndarray[bool]:
|
|
168
|
+
return self._mask
|
|
169
|
+
|
|
170
|
+
@mask.setter
|
|
171
|
+
def mask(self, m: Optional[np.ndarray[bool]] = None) -> None:
|
|
172
|
+
self._mask = np.ones(shape=(self.count,), dtype=bool) if m is None else m
|
|
173
|
+
assert self.mask.shape == (self.count,), \
|
|
174
|
+
f"Mask shape does not match data shape: expected {self.count,}, got {self.mask.shape}"
|
|
175
|
+
|
|
176
|
+
def __str__(self):
|
|
177
|
+
return f"<Catalogue with {self.count_visible} / {self.count} reference objects>"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Loadable(ABC):
|
|
6
|
+
def __init_subclass__(cls, **kwargs):
|
|
7
|
+
super().__init_subclass__(**kwargs)
|
|
8
|
+
cls._registry[cls.__name__] = cls
|
|
9
|
+
|
|
10
|
+
@classmethod
|
|
11
|
+
def from_dict(cls,
|
|
12
|
+
config: dict[str, Any]):
|
|
13
|
+
"""
|
|
14
|
+
Load from a dictionary. This expects that its 'name' attribute points to the actual class in the registry.
|
|
15
|
+
"""
|
|
16
|
+
return cls._registry[config['name']](**config['parameters'])
|
|
17
|
+
|
|
18
|
+
def as_dict(self):
|
|
19
|
+
""" Return a dict representation of the Projection's parameters """
|
|
20
|
+
return {
|
|
21
|
+
'name': self.__class__.__name__,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def _as_dict(self):
|
|
26
|
+
pass
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
All functions within this module should observe the following signature:
|
|
3
|
+
Parameters
|
|
4
|
+
----------
|
|
5
|
+
a: np.ndarray(M, D)
|
|
6
|
+
b: np.ndarray(N, D)
|
|
7
|
+
|
|
8
|
+
Returns
|
|
9
|
+
-------
|
|
10
|
+
np.ndarray(M, N) of scalars
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from numpy.typing import NDArray
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def spherical(a: NDArray, b: NDArray) -> NDArray:
|
|
19
|
+
"""
|
|
20
|
+
Compute spherical distance between a and b, each are vectors of points in two dimensions
|
|
21
|
+
"""
|
|
22
|
+
return 2 * np.arcsin(
|
|
23
|
+
np.sqrt(
|
|
24
|
+
np.sin(0.5 * (b[..., 0] - a[..., 0]))**2 +
|
|
25
|
+
np.cos(a[..., 0]) * np.cos(b[..., 0]) * np.sin(0.5 * (b[..., 1] - a[..., 1]))**2
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def euclidean(a: NDArray, b: NDArray) -> NDArray:
|
|
31
|
+
"""
|
|
32
|
+
Compute Euclidean distance between a and b, each are vectors of points in two dimensions
|
|
33
|
+
"""
|
|
34
|
+
return np.sqrt(np.sum((a - b)**2, axis=2))
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from . import constants
|
|
4
|
+
|
|
5
|
+
from astropy import units as u
|
|
6
|
+
from astropy.coordinates import Angle
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AirMass:
|
|
10
|
+
def kasten_young(altitude: Angle,
|
|
11
|
+
elevation: u.Quantity = 0 * u.m):
|
|
12
|
+
return np.where(
|
|
13
|
+
altitude >= 0 * u.deg,
|
|
14
|
+
AirDensity.isa(elevation) / AirDensity.isa(0 * u.m) / (np.sin(altitude) + 0.50572 * ((altitude.degree + 6.07995) ** (-1.6364))),
|
|
15
|
+
np.inf
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
def pickering2002(altitude: u.Quantity,
|
|
19
|
+
elevation: u.Quantity = 0 * u.m):
|
|
20
|
+
return np.where(
|
|
21
|
+
altitude >= 0 * u.deg,
|
|
22
|
+
AirDensity.isa(elevation) / AirDensity.isa(0 * u.m) / (np.sin(altitude + np.radians(244 / (165 + 47 * altitude.degree ** 1.1)))),
|
|
23
|
+
np.inf
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def attenuate(flux: u.Quantity,
|
|
27
|
+
air_mass: u.Quantity,
|
|
28
|
+
one: float = constants.AttenuationOneAirMassMag):
|
|
29
|
+
return np.where(air_mass <= 100, flux * np.exp(-np.log(100) / 5 * one * air_mass), 0)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AirDensity:
|
|
33
|
+
def isa(altitude: u.Quantity):
|
|
34
|
+
return 101325 * np.exp(-altitude.to(u.m) / (7990 * u.m)) * u.pascal
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import dotmap
|
|
2
|
+
import numpy as np
|
|
3
|
+
from typing import Tuple, Any
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Projection(ABC):
|
|
11
|
+
"""
|
|
12
|
+
A base class for all projections. Should implement xy -> za and za -> xy conversions.
|
|
13
|
+
"""
|
|
14
|
+
bounds = np.array((
|
|
15
|
+
(0, None),
|
|
16
|
+
))
|
|
17
|
+
|
|
18
|
+
_registry = {}
|
|
19
|
+
|
|
20
|
+
def __init__(self):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def __call__(self, x: np.ndarray, y: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
|
25
|
+
""" Apply this projection to an array of points: xy -> za """
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def invert(self, z: np.ndarray, a: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
|
29
|
+
""" Apply an inverse projection to an array of points: za -> xy """
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def as_dict(self) -> dict[str, float]:
|
|
33
|
+
""" Return a dict representation of the Projection's parameters """
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
def __init_subclass__(cls, **kwargs):
|
|
37
|
+
super().__init_subclass__(**kwargs)
|
|
38
|
+
Projection._registry[cls.name] = cls
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_dict(cls,
|
|
42
|
+
config: dict[str, Any]):
|
|
43
|
+
return cls._registry[config['name']](**config['parameters'])
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_dotmap(cls, dm):
|
|
47
|
+
"""
|
|
48
|
+
Load from a dotmap. Useful as an intermediate step when loading from YAML.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def load(cls, file):
|
|
53
|
+
data = dotmap.DotMap(yaml.safe_load(file), _dynamic=False)
|
|
54
|
+
data = data.projection.parameters
|
|
55
|
+
return cls.from_dotmap(data)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from typing import Union
|
|
3
|
+
from numpy.typing import ArrayLike
|
|
4
|
+
|
|
5
|
+
from .base import Projection
|
|
6
|
+
from .shifters import TiltShifter
|
|
7
|
+
from .transformers import BiexponentialTransformer
|
|
8
|
+
from .zenith import ZenithShifter
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BorovickaProjection(Projection):
|
|
12
|
+
"""
|
|
13
|
+
Borovička all-sky projection.
|
|
14
|
+
"""
|
|
15
|
+
bounds = np.array((
|
|
16
|
+
(None, None), # x0
|
|
17
|
+
(None, None), # y0
|
|
18
|
+
(None, None), # a0
|
|
19
|
+
(None, None), # A
|
|
20
|
+
(None, None), # F
|
|
21
|
+
(0.001, None), # V
|
|
22
|
+
(None, None), # S
|
|
23
|
+
(None, None), # D
|
|
24
|
+
(None, None), # P
|
|
25
|
+
(None, None), # Q
|
|
26
|
+
(0, None), # epsilon
|
|
27
|
+
(None, None), # E
|
|
28
|
+
))
|
|
29
|
+
name = 'Borovička'
|
|
30
|
+
|
|
31
|
+
def __init__(self,
|
|
32
|
+
x0: float = 0, y0: float = 0, a0: float = 0,
|
|
33
|
+
A: float = 0, F: float = 0,
|
|
34
|
+
V: float = 1, S: float = 0, D: float = 0, P: float = 0, Q: float = 0,
|
|
35
|
+
epsilon: float = 0, E: float = 0):
|
|
36
|
+
super().__init__()
|
|
37
|
+
self.axis_shifter = TiltShifter(x0=x0, y0=y0, a0=a0, A=A, F=F, E=E)
|
|
38
|
+
self.radial_transform = BiexponentialTransformer(V, S, D, P, Q)
|
|
39
|
+
self.zenith_shifter = ZenithShifter(epsilon=epsilon, E=E)
|
|
40
|
+
|
|
41
|
+
def __call__(self,
|
|
42
|
+
x: Union[float, ArrayLike],
|
|
43
|
+
y: Union[float, ArrayLike]) -> tuple[np.ndarray, np.ndarray]:
|
|
44
|
+
r, b = self.axis_shifter(x, y)
|
|
45
|
+
u = self.radial_transform(r)
|
|
46
|
+
z, a = self.zenith_shifter(u, b)
|
|
47
|
+
return z, a
|
|
48
|
+
|
|
49
|
+
def invert(self,
|
|
50
|
+
z: ArrayLike,
|
|
51
|
+
a: ArrayLike) -> tuple[ArrayLike, ArrayLike]:
|
|
52
|
+
if z.shape == (0,):
|
|
53
|
+
return np.empty((0,)), np.empty((0,))
|
|
54
|
+
|
|
55
|
+
u, b = self.zenith_shifter.invert(z, a)
|
|
56
|
+
r = self.radial_transform.invert(u)
|
|
57
|
+
x, y = self.axis_shifter.invert(r, b)
|
|
58
|
+
return x, y
|
|
59
|
+
|
|
60
|
+
def __str__(self):
|
|
61
|
+
return f"Borovička projection with \n" \
|
|
62
|
+
f" {self.axis_shifter} \n" \
|
|
63
|
+
f" {self.radial_transform} \n" \
|
|
64
|
+
f" {self.zenith_shifter}"
|
|
65
|
+
|
|
66
|
+
def as_dict(self):
|
|
67
|
+
return self.axis_shifter.as_dict() | self.radial_transform.as_dict() | self.zenith_shifter.as_dict()
|
|
68
|
+
|
|
69
|
+
def as_tuple(self) -> tuple[float, ...]:
|
|
70
|
+
return (
|
|
71
|
+
self.axis_shifter.x0, self.axis_shifter.y0, self.axis_shifter.a0,
|
|
72
|
+
self.axis_shifter.A, self.axis_shifter.F,
|
|
73
|
+
self.radial_transform.V, self.radial_transform.S, self.radial_transform.D,
|
|
74
|
+
self.radial_transform.P, self.radial_transform.Q,
|
|
75
|
+
self.zenith_shifter.epsilon, self.zenith_shifter.E,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_dotmap(cls, dm):
|
|
80
|
+
return cls(
|
|
81
|
+
dm.x0, dm.y0, dm.a0,
|
|
82
|
+
dm.A, dm.F,
|
|
83
|
+
dm.V, dm.S, dm.D, dm.S, dm.Q,
|
|
84
|
+
dm.epsilon, dm.E,
|
|
85
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from typing import Tuple
|
|
4
|
+
|
|
5
|
+
from .base import Projection
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EquidistantProjection(Projection):
|
|
9
|
+
""" Equidistant projection that is perfectly aligned to zenith-north """
|
|
10
|
+
|
|
11
|
+
name = 'equidistant'
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
super().__init__()
|
|
15
|
+
|
|
16
|
+
def __call__(self, x: np.ndarray, y: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
|
17
|
+
z = np.sqrt(np.square(x) + np.square(y))
|
|
18
|
+
a = np.arctan2(x, -y)
|
|
19
|
+
return z, a
|
|
20
|
+
|
|
21
|
+
def invert(self, z: np.ndarray, a: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
|
22
|
+
return z * np.sin(a), z * np.cos(a)
|
|
23
|
+
|
|
24
|
+
def as_dict(self):
|
|
25
|
+
return {}
|
|
26
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import dotmap
|
|
3
|
+
import yaml
|
|
4
|
+
from typing import Union
|
|
5
|
+
from numpy.typing import NDArray
|
|
6
|
+
|
|
7
|
+
from .base import Projection
|
|
8
|
+
from .shifters import TiltShifter
|
|
9
|
+
from .transformers import SaneBiexponentialTransformer
|
|
10
|
+
from .zenith import ZenithShifter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class KoniferkaProjection(Projection):
|
|
14
|
+
"""
|
|
15
|
+
Improved Borovička all-sky projection, with correct dimensions.
|
|
16
|
+
Namely,
|
|
17
|
+
r1 = 1 / D,
|
|
18
|
+
r2 = sqrt(1 / Q).
|
|
19
|
+
"""
|
|
20
|
+
bounds = np.array((
|
|
21
|
+
(None, None), # x0
|
|
22
|
+
(None, None), # y0
|
|
23
|
+
(None, None), # a0
|
|
24
|
+
(None, None), # A
|
|
25
|
+
(None, None), # F
|
|
26
|
+
(0.001, None), # V
|
|
27
|
+
(None, None), # k_1
|
|
28
|
+
(None, None), # p_1
|
|
29
|
+
(None, None), # k_2
|
|
30
|
+
(None, None), # p_2
|
|
31
|
+
(0, None), # epsilon
|
|
32
|
+
(None, None), # E
|
|
33
|
+
))
|
|
34
|
+
name = 'Koniferka'
|
|
35
|
+
|
|
36
|
+
def __init__(self,
|
|
37
|
+
x0: float = 0, y0: float = 0, a0: float = 0,
|
|
38
|
+
A: float = 0, F: float = 0,
|
|
39
|
+
V: float = 1, p1: float = 0, r1: float = np.inf, p2: float = 0, r2: float = np.inf,
|
|
40
|
+
epsilon: float = 0, E: float = 0):
|
|
41
|
+
super().__init__()
|
|
42
|
+
self.axis_shifter = TiltShifter(x0=x0, y0=y0, a0=a0, A=A, F=F, E=E)
|
|
43
|
+
self.radial_transform = SaneBiexponentialTransformer(V, p1, r1, p2, r2)
|
|
44
|
+
self.zenith_shifter = ZenithShifter(epsilon=epsilon, E=E)
|
|
45
|
+
|
|
46
|
+
def __call__(self,
|
|
47
|
+
x: Union[float, np.ndarray],
|
|
48
|
+
y: Union[float, np.ndarray]) -> tuple[np.ndarray, np.ndarray]:
|
|
49
|
+
print("Forward")
|
|
50
|
+
print(f"{x=}, {y=}")
|
|
51
|
+
r, b = self.axis_shifter(x, y)
|
|
52
|
+
print(f"{r=}")
|
|
53
|
+
u = self.radial_transform(r)
|
|
54
|
+
print(f"{u=}, {b=}")
|
|
55
|
+
z, a = self.zenith_shifter(u, b)
|
|
56
|
+
print(f"{z=}, {a=}")
|
|
57
|
+
return z, a
|
|
58
|
+
|
|
59
|
+
def invert(self,
|
|
60
|
+
z: NDArray[np.floating],
|
|
61
|
+
a: NDArray[np.floating]) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
|
|
62
|
+
print("Inverted")
|
|
63
|
+
print(f"{z=}, {a=}")
|
|
64
|
+
u, b = self.zenith_shifter.invert(z, a)
|
|
65
|
+
print(f"{u=}, {b=}")
|
|
66
|
+
r = self.radial_transform.invert(u)
|
|
67
|
+
print(f"{r=}")
|
|
68
|
+
x, y = self.axis_shifter.invert(r, b)
|
|
69
|
+
print(f"{x=}, {y=}")
|
|
70
|
+
return x, y
|
|
71
|
+
|
|
72
|
+
def __str__(self):
|
|
73
|
+
return f"Koniferka projection with \n" \
|
|
74
|
+
f" {self.axis_shifter} \n" \
|
|
75
|
+
f" {self.radial_transform} \n" \
|
|
76
|
+
f" {self.zenith_shifter}"
|
|
77
|
+
|
|
78
|
+
def as_dict(self):
|
|
79
|
+
return self.axis_shifter.as_dict() | self.radial_transform.as_dict() | self.zenith_shifter.as_dict()
|
|
80
|
+
|
|
81
|
+
def as_tuple(self):
|
|
82
|
+
return (
|
|
83
|
+
self.axis_shifter.x0, self.axis_shifter.y0, self.axis_shifter.a0,
|
|
84
|
+
self.axis_shifter.A, self.axis_shifter.F,
|
|
85
|
+
self.radial_transform.V,
|
|
86
|
+
self.radial_transform.p1, self.radial_transform.r1,
|
|
87
|
+
self.radial_transform.p1, self.radial_transform.r2,
|
|
88
|
+
self.zenith_shifter.epsilon, self.zenith_shifter.E,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_dotmap(cls, dm):
|
|
93
|
+
return cls(
|
|
94
|
+
dm.x0, dm.y0, dm.a0,
|
|
95
|
+
dm.A, dm.F,
|
|
96
|
+
dm.V, dm.p1, dm.r1, dm.p2, dm.r2,
|
|
97
|
+
dm.epsilon, dm.E,
|
|
98
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Scaler:
|
|
5
|
+
""" Class for scaling pixels to lengths """
|
|
6
|
+
|
|
7
|
+
name = 'scaler'
|
|
8
|
+
|
|
9
|
+
def __init__(self, scale_x: float = 1, scale_y: float = 1):
|
|
10
|
+
self.scale_x = scale_x
|
|
11
|
+
self.scale_y = scale_y
|
|
12
|
+
|
|
13
|
+
def __call__(self, x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
14
|
+
return x * self.scale_x, y * self.scale_y
|
|
15
|
+
|
|
16
|
+
def invert(self, nx: np.ndarray, ny: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
17
|
+
return nx / self.scale_x, ny / self.scale_y
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import scipy as sp
|
|
6
|
+
from numpy.typing import ArrayLike
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Shifter:
|
|
10
|
+
""" Shifts without scaling or rotation """
|
|
11
|
+
def __init__(self, *, x0: float = 0, y0: float = 0):
|
|
12
|
+
self.x0 = x0
|
|
13
|
+
self.y0 = y0
|
|
14
|
+
|
|
15
|
+
def __call__(self, x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
16
|
+
return x - self.x0, y - self.y0
|
|
17
|
+
|
|
18
|
+
def invert(self, nx: np.ndarray, ny: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
19
|
+
return nx + self.x0, ny + self.y0
|
|
20
|
+
|
|
21
|
+
def as_dict(self) -> dict[str, float]:
|
|
22
|
+
return dict(x=self.x0, y=self.y0)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ScalingShifter(Shifter):
|
|
26
|
+
""" Shifts and scales the sensor without rotation """
|
|
27
|
+
def __init__(self, *, x0: float = 0, y0: float = 0, xs: float = 1, ys: float = 1):
|
|
28
|
+
super().__init__(x0=x0, y0=y0)
|
|
29
|
+
self.xs = xs # x scaling factor
|
|
30
|
+
self.ys = ys # y scaling factor
|
|
31
|
+
|
|
32
|
+
def __call__(self, x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
33
|
+
nx, ny = super().__call__(x, y)
|
|
34
|
+
return nx * self.xs, ny * self.ys
|
|
35
|
+
|
|
36
|
+
def invert(self, nx: np.ndarray, ny: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
37
|
+
return super().invert(nx / self.xs, ny / self.ys)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OpticalAxisShifter(Shifter):
|
|
41
|
+
""" Shifts and derotates the optical axis of the sensor """
|
|
42
|
+
def __init__(self, *, x0: float = 0, y0: float = 0, a0: float = 0, E: float = 0):
|
|
43
|
+
super().__init__(x0=x0, y0=y0)
|
|
44
|
+
self.a0 = a0 # rotation of the optical axis
|
|
45
|
+
self.E = E # true azimuth of the centre of FoV
|
|
46
|
+
|
|
47
|
+
def __call__(self, x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
48
|
+
xs, ys = super().__call__(x, y)
|
|
49
|
+
r = np.sqrt(np.square(xs) + np.square(ys))
|
|
50
|
+
b = self.a0 - self.E + np.arctan2(ys, xs)
|
|
51
|
+
b = np.mod(b, math.tau)
|
|
52
|
+
return r, b
|
|
53
|
+
|
|
54
|
+
def invert(self, r: np.ndarray, b: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
55
|
+
xi = b - self.a0 + self.E
|
|
56
|
+
xs, ys = r * np.cos(xi), r * np.sin(xi)
|
|
57
|
+
x, y = super().invert(xs, ys)
|
|
58
|
+
return x, y
|
|
59
|
+
|
|
60
|
+
def __str__(self):
|
|
61
|
+
return f"<{self.__class__} x0={self.x0} y0={self.y0} a0={self.a0} E={self.E}>"
|
|
62
|
+
|
|
63
|
+
def as_dict(self):
|
|
64
|
+
return super().as_dict() | dict(
|
|
65
|
+
a0=float(self.a0),
|
|
66
|
+
E=float(self.E),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TiltShifter(OpticalAxisShifter):
|
|
71
|
+
"""
|
|
72
|
+
Extends OpticalAxisShifter with imaging plane tilt.
|
|
73
|
+
The optical axis is tilted with respect to the sensor normal at angle A in azimuth F.
|
|
74
|
+
|
|
75
|
+
For further details see
|
|
76
|
+
Borovička (1995): A new positional astrometric method for all-sky cameras.
|
|
77
|
+
This method has been optimized for sanity
|
|
78
|
+
"""
|
|
79
|
+
def __init__(self, *, x0: float = 0, y0: float = 0, a0: float = 0, A: float = 0, F: float = 0, E: float = 0):
|
|
80
|
+
super().__init__(x0=x0, y0=y0, a0=a0, E=E)
|
|
81
|
+
assert -1 <= A <= 1, f"Invalid parameter {A=}: must be -1 <= A <= 1."
|
|
82
|
+
|
|
83
|
+
self.A = A # imaging plane tilt, amplitude
|
|
84
|
+
self.F = F # imaging plane tilt, phase
|
|
85
|
+
self._phi = self.F - self.a0
|
|
86
|
+
self._cos_term = np.cos(self._phi)
|
|
87
|
+
self._sin_term = np.sin(self._phi)
|
|
88
|
+
|
|
89
|
+
def __call__(self,
|
|
90
|
+
x: np.ndarray,
|
|
91
|
+
y: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
92
|
+
xs = x - self.x0
|
|
93
|
+
ys = y - self.y0
|
|
94
|
+
r, b = super().__call__(x, y)
|
|
95
|
+
r += self.A * (ys * self._cos_term - xs * self._sin_term)
|
|
96
|
+
return r, b
|
|
97
|
+
|
|
98
|
+
def invert(self,
|
|
99
|
+
r: ArrayLike,
|
|
100
|
+
b: ArrayLike) -> tuple[ArrayLike, ArrayLike]:
|
|
101
|
+
xi = b - self.a0 + self.E
|
|
102
|
+
denom = 1 + self.A * np.sin(xi - self._phi)
|
|
103
|
+
x = self.x0 + r * np.cos(xi) / denom
|
|
104
|
+
y = self.y0 + r * np.sin(xi) / denom
|
|
105
|
+
return x, y
|
|
106
|
+
|
|
107
|
+
def __str__(self) -> str:
|
|
108
|
+
return f"<{self.__class__.__name__} x0={self.x0} y0={self.y0} a0={self.a0} A={self.A} F={self.F} E={self.E}>"
|
|
109
|
+
|
|
110
|
+
def as_dict(self):
|
|
111
|
+
return super().as_dict() | dict(
|
|
112
|
+
A=float(self.A),
|
|
113
|
+
F=float(self.F),
|
|
114
|
+
)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
import numpy as np
|
|
3
|
+
import scipy as sp
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RadialTransformer(ABC):
|
|
7
|
+
""" Class for transforming radial distances in all-sky projections """
|
|
8
|
+
|
|
9
|
+
def __call__(self, r):
|
|
10
|
+
raise NotImplementedError("Radial transformers must implement __call__(r: np.ndarray) -> np.ndarray")
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def fprime(self, u):
|
|
14
|
+
""" Derivative function for the Newton method, not implemented in the abstract base class """
|
|
15
|
+
raise NotImplementedError("Radial transformers must implement df/dr as fprime(u: np.ndarray) -> np.ndarray")
|
|
16
|
+
|
|
17
|
+
def invert(self, u):
|
|
18
|
+
""" Numerically approximate the inverse function using the Newton method """
|
|
19
|
+
return sp.optimize.newton(lambda r: self.__call__(r) - u, np.zeros_like(u), self.fprime)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LinearTransformer(RadialTransformer):
|
|
23
|
+
""" Linear radial transform, u = Vr """
|
|
24
|
+
|
|
25
|
+
def __init__(self, V: float = 1):
|
|
26
|
+
assert V > 0, "Radial linear scale V must be > 0"
|
|
27
|
+
self.V = V # radial stretch, linear coefficient
|
|
28
|
+
|
|
29
|
+
def __call__(self, r):
|
|
30
|
+
return self.V * r
|
|
31
|
+
|
|
32
|
+
def fprime(self, r):
|
|
33
|
+
""" du / dr = V """
|
|
34
|
+
return self.V * np.ones_like(r)
|
|
35
|
+
|
|
36
|
+
def as_dict(self):
|
|
37
|
+
return dict(
|
|
38
|
+
V=float(self.V),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ExponentialTransformer(LinearTransformer):
|
|
43
|
+
""" Linear + exponential radial correction, u = Vr + S(e^(Dr) - 1) """
|
|
44
|
+
|
|
45
|
+
def __init__(self, V: float = 1, S: float = 0, D: float = 0):
|
|
46
|
+
super().__init__(V)
|
|
47
|
+
self.S = S # radial stretch, exponential term, linear coefficient
|
|
48
|
+
self.D = D # radial stretch, exponential term, exponent coefficient
|
|
49
|
+
|
|
50
|
+
def __call__(self, r):
|
|
51
|
+
return super().__call__(r) + self.S * (np.exp(self.D * r) - 1)
|
|
52
|
+
|
|
53
|
+
def fprime(self, r):
|
|
54
|
+
""" du/dr = V + SDe^(Dr) """
|
|
55
|
+
return super().fprime(r) + self.S * self.D * np.exp(self.D * r)
|
|
56
|
+
|
|
57
|
+
def as_dict(self):
|
|
58
|
+
return super().as_dict() | dict(
|
|
59
|
+
S=float(self.S),
|
|
60
|
+
D=float(self.D),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class BiexponentialTransformer(ExponentialTransformer):
|
|
65
|
+
""" Bi-exponential radial fitting procedure, u = Vr + S(e^(Dr) - 1) + P(e^(Qr^2) - 1) """
|
|
66
|
+
|
|
67
|
+
def __init__(self, V: float = 0,
|
|
68
|
+
S: float = 0, D: float = 0,
|
|
69
|
+
P: float = 0, Q: float = 0):
|
|
70
|
+
super().__init__(V, S, D)
|
|
71
|
+
self.P = P # radial stretch, square-exponential term, linear coefficient
|
|
72
|
+
self.Q = Q # radial stretch, square-exponential term, exponent coefficient
|
|
73
|
+
|
|
74
|
+
def __call__(self, r):
|
|
75
|
+
return super().__call__(r) + self.P * (np.exp(self.Q * r * r) - 1)
|
|
76
|
+
|
|
77
|
+
def fprime(self, r):
|
|
78
|
+
""" du/dr = V + SDe^(Dr) + 2 PQr e^(Qr^2) """
|
|
79
|
+
return super().fprime(r) + 2 * self.P * self.Q * r * np.exp(self.Q * r * r)
|
|
80
|
+
|
|
81
|
+
def __str__(self):
|
|
82
|
+
return f"<{self.__class__.__name__} {self.V=} {self.S=} {self.D=} {self.P=} {self.Q=}>"
|
|
83
|
+
|
|
84
|
+
def as_dict(self):
|
|
85
|
+
return super().as_dict() | dict(
|
|
86
|
+
P=float(self.P),
|
|
87
|
+
Q=float(self.Q),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class SaneExponentialTransformer(LinearTransformer):
|
|
92
|
+
""" Sanitized linear + exponential radial correction, u = Vr + k_1 * (e^(r/r_1) - 1) """
|
|
93
|
+
|
|
94
|
+
def __init__(self, V: float = 1, p1: float = 0, r1: float = 0):
|
|
95
|
+
super().__init__(V)
|
|
96
|
+
assert abs(r1) > 0.1, "Exponent scale r1 must be > 0.1"
|
|
97
|
+
self.p1 = p1 # radial stretch, exponential term, linear coefficient
|
|
98
|
+
self.r1 = r1 # radial stretch, exponential term, exponent coefficient
|
|
99
|
+
|
|
100
|
+
def __call__(self, r):
|
|
101
|
+
return super().__call__(r) + self.p1 * (np.exp(r / self.r1) - 1)
|
|
102
|
+
|
|
103
|
+
def fprime(self, r):
|
|
104
|
+
""" du/dr = V + S / r_1 * e^(r / r_1) """
|
|
105
|
+
return super().fprime(r) + self.p1 / self.r1 * np.exp(r / self.r1)
|
|
106
|
+
|
|
107
|
+
def as_dict(self):
|
|
108
|
+
return super().as_dict() | dict(
|
|
109
|
+
p1=float(self.p1),
|
|
110
|
+
r1=float(self.r1),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class SaneBiexponentialTransformer(SaneExponentialTransformer):
|
|
115
|
+
def __init__(self, V: float = 0,
|
|
116
|
+
p1: float = 0, r1: float = np.inf,
|
|
117
|
+
p2: float = 0, r2: float = np.inf):
|
|
118
|
+
super().__init__(V, p1, r1)
|
|
119
|
+
assert abs(r2) > 0.1, "Exponent scale r2 must be > 0.1 mm"
|
|
120
|
+
self.p2 = p2 # radial stretch, square-exponential term, linear coefficient
|
|
121
|
+
self.r2 = r2 # radial stretch, square-exponential term, exponent coefficient
|
|
122
|
+
|
|
123
|
+
def __call__(self, r):
|
|
124
|
+
return super().__call__(r) + self.p2 * (np.exp((r / self.r2)**2) - 1)
|
|
125
|
+
|
|
126
|
+
def fprime(self, r):
|
|
127
|
+
""" du/dr = V + SDe^(Dr) + 2 PQr e^(Qr^2) """
|
|
128
|
+
return super().fprime(r) + 2 * self.p2 / self.r2**2 * r * np.exp((r / self.r2)**2)
|
|
129
|
+
|
|
130
|
+
def __str__(self):
|
|
131
|
+
return f"<{self.__class__.__name__} {self.V=} {self.p1=} {self.r1=} {self.p2=} {self.r2=}>"
|
|
132
|
+
|
|
133
|
+
def as_dict(self):
|
|
134
|
+
return super().as_dict() | dict(
|
|
135
|
+
p2=float(self.p2),
|
|
136
|
+
r2=float(self.r2),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import numpy as np
|
|
3
|
+
from numpy.typing import NDArray
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .base import Projection
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ZenithShifter(Projection):
|
|
11
|
+
"""
|
|
12
|
+
ZenithShifter is a spherical -> spherical projection that shifts the zenith to a different
|
|
13
|
+
position at true zenith distance `epsilon` and rotated such that true zenith is at 180°.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
name = 'zenith-shifter'
|
|
17
|
+
|
|
18
|
+
def __init__(self, epsilon: float = 0, E: float = 0):
|
|
19
|
+
super().__init__()
|
|
20
|
+
self.epsilon = epsilon
|
|
21
|
+
self.E = E
|
|
22
|
+
|
|
23
|
+
def __call__(self, u, b):
|
|
24
|
+
"""
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
u : Union[float, ArrayLike] radial distance from origin in camera coordinates
|
|
28
|
+
b : Union[float, ArrayLike] azimuth in camera coordinates
|
|
29
|
+
|
|
30
|
+
Returns
|
|
31
|
+
-------
|
|
32
|
+
tuple[Union[float, ArrayLike], Union[float, ArrayLike]]
|
|
33
|
+
z : Union[float, ArrayLike] zenith distance in sky coordinates
|
|
34
|
+
a : Union[float, ArrayLike] azimuth in sky coordinates
|
|
35
|
+
"""
|
|
36
|
+
if abs(self.epsilon) < 1e-14: # for tiny epsilon there is no displacement
|
|
37
|
+
z = u # and we are able to calculate the coordinates immediately
|
|
38
|
+
a = self.E + b
|
|
39
|
+
else:
|
|
40
|
+
cosz = np.cos(u) * np.cos(self.epsilon) - np.sin(u) * np.sin(self.epsilon) * np.cos(b)
|
|
41
|
+
sna = np.sin(b) * np.sin(u)
|
|
42
|
+
cna = (np.cos(u) - np.cos(self.epsilon) * cosz) / np.sin(self.epsilon)
|
|
43
|
+
z = np.arccos(cosz)
|
|
44
|
+
a = self.E + np.arctan2(sna, cna)
|
|
45
|
+
|
|
46
|
+
return z, np.mod(a, math.tau) # wrap around to [0, 2pi)
|
|
47
|
+
|
|
48
|
+
def invert(self, z: NDArray, a: NDArray) -> tuple[NDArray, NDArray]:
|
|
49
|
+
if abs(self.epsilon) < 1e-14:
|
|
50
|
+
u = z
|
|
51
|
+
b = a - self.E
|
|
52
|
+
else:
|
|
53
|
+
cosu = np.cos(z) * np.cos(self.epsilon) + np.sin(z) * np.sin(self.epsilon) * np.cos(a - self.E)
|
|
54
|
+
sna = np.sin(a - self.E) * np.sin(z)
|
|
55
|
+
cna = -(np.cos(z) - np.cos(self.epsilon) * cosu) / np.sin(self.epsilon)
|
|
56
|
+
u = np.arccos(cosu)
|
|
57
|
+
b = np.arctan2(sna, cna)
|
|
58
|
+
|
|
59
|
+
return u, np.mod(b, math.tau) # wrap around to [0, 2pi)
|
|
60
|
+
|
|
61
|
+
def __str__(self) -> str:
|
|
62
|
+
return f"<{self.__class__.__name__} {self.epsilon=} {self.E=}>"
|
|
63
|
+
|
|
64
|
+
def as_dict(self) -> dict[str, float]:
|
|
65
|
+
return dict(
|
|
66
|
+
epsilon=float(self.epsilon),
|
|
67
|
+
E=float(self.E),
|
|
68
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .ray import RayMinimizer
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import scipy as sp
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RayMinimizer:
|
|
6
|
+
"""
|
|
7
|
+
Class that evaluates the distance of a point from a set of lines, and finds the point
|
|
8
|
+
from which squared distance to all the lines is smallest.
|
|
9
|
+
|
|
10
|
+
Has two constructors: __init__, which expectes two ndarrays of same shape, points and vectors defining the line,
|
|
11
|
+
and a static `from_points` that expects two arrays of points.
|
|
12
|
+
"""
|
|
13
|
+
def __init__(self, points: np.ndarray, vectors: np.ndarray, *, normalize: bool = False):
|
|
14
|
+
assert points.shape == vectors.shape, "Points and vectors must have the same shape"
|
|
15
|
+
|
|
16
|
+
self.points = points
|
|
17
|
+
self.vectors = vectors
|
|
18
|
+
|
|
19
|
+
if normalize:
|
|
20
|
+
self.vectors /= np.linalg.norm(self.vectors)
|
|
21
|
+
|
|
22
|
+
assert np.min(np.abs(np.linalg.norm(self.vectors)) != 0), "All vectors must be non-zero!"
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def from_points(points: np.ndarray, points2: np.ndarray):
|
|
26
|
+
return RayMinimizer(points, points2 - points, normalize=False)
|
|
27
|
+
|
|
28
|
+
def sum_distance(self, point: np.array) -> float:
|
|
29
|
+
p = point - self.points
|
|
30
|
+
dist = np.linalg.norm(np.cross(p, self.vectors), axis=1, ord=2) / np.linalg.norm(self.vectors, axis=1, ord=2)
|
|
31
|
+
return np.sum(dist)
|
|
32
|
+
|
|
33
|
+
def sum_quad_distance(self, point: np.ndarray) -> float:
|
|
34
|
+
p = point - self.points
|
|
35
|
+
dist = np.linalg.norm(np.cross(p, self.vectors), axis=1, ord=2) / np.linalg.norm(self.vectors, axis=1, ord=2)
|
|
36
|
+
return np.sum(np.square(dist))
|
|
37
|
+
|
|
38
|
+
def nearest(self) -> np.ndarray:
|
|
39
|
+
return sp.optimize.minimize(self.sum_quad_distance, np.array([0, 0, 0]).T, method="L-BFGS-B").x
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "demeteor"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A collection of utility functions for all-sky and spectral meteor cameras"
|
|
5
|
+
authors = ["Martin Odokienko <martin.balaz@fmph.uniba.sk>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
|
|
8
|
+
[tool.poetry.dependencies]
|
|
9
|
+
python = "^3.11"
|
|
10
|
+
numpy = "*"
|
|
11
|
+
scipy = "*"
|
|
12
|
+
pyyaml = "*"
|
|
13
|
+
dotmap = "*"
|
|
14
|
+
pandas = "*"
|
|
15
|
+
astropy = "*"
|
|
16
|
+
|
|
17
|
+
[tool.poetry.group.test.dependencies]
|
|
18
|
+
pytest = "^8.2.0"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["poetry-core"]
|
|
22
|
+
build-backend = "poetry.core.masonry.api"
|