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.
@@ -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
+
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,3 @@
1
+ from .metrics import euclidean, spherical
2
+
3
+ __all__ = [euclidean, spherical] # ToDo More to follow
@@ -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,3 @@
1
+ AttenuationOneAirMassMag: float = 0.28
2
+ AttenuationOneAirMass: float = 0.77268
3
+ MagnitudeOneWattPerSquareMetre: float = -18.9
@@ -0,0 +1,3 @@
1
+ from .base import Projection
2
+ from .equidistant import EquidistantProjection
3
+ from .borovicka import BorovickaProjection
@@ -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"