Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
junzis
GitHub Repository: junzis/openap
Path: blob/master/tests/test_geo.py
592 views
1
"""Comprehensive tests for the geo module.
2
3
This module tests all geographic calculation functions in openap.geo,
4
including distance, bearing, latlon, and solar zenith angle calculations.
5
"""
6
7
from datetime import datetime
8
9
import numpy as np
10
import pytest
11
12
from openap import geo
13
from openap.backends import CasadiBackend, JaxBackend, NumpyBackend
14
from openap.geo import Geo
15
16
17
# Tolerance for floating point comparisons
18
RTOL = 1e-4 # 0.01% relative tolerance
19
20
# Expected values
21
EXPECTED = {
22
# Distance: London (51.5, -0.1) to Paris (48.85, 2.35)
23
"distance_london_paris": 342400.75, # meters (~342.4 km)
24
# Bearing: London to Paris
25
"bearing_london_paris": 148.4226, # degrees
26
}
27
28
29
class TestGeoDistance:
30
"""Tests for distance calculations."""
31
32
def test_distance_london_paris(self):
33
"""Test Haversine distance calculation."""
34
geo_obj = Geo()
35
36
# London to Paris
37
lat1, lon1 = 51.5, -0.1 # London
38
lat2, lon2 = 48.85, 2.35 # Paris
39
40
dist = geo_obj.distance(lat1, lon1, lat2, lon2)
41
assert dist == pytest.approx(EXPECTED["distance_london_paris"], rel=0.01)
42
43
def test_distance_same_point(self):
44
"""Test distance between same point is zero."""
45
geo_obj = Geo()
46
dist = geo_obj.distance(51.5, -0.1, 51.5, -0.1)
47
assert dist == pytest.approx(0.0, abs=1.0)
48
49
def test_distance_with_altitude(self):
50
"""Test distance calculation at altitude."""
51
geo_obj = Geo()
52
53
lat1, lon1 = 51.5, -0.1
54
lat2, lon2 = 48.85, 2.35
55
56
dist_ground = geo_obj.distance(lat1, lon1, lat2, lon2, h=0)
57
dist_altitude = geo_obj.distance(lat1, lon1, lat2, lon2, h=10000)
58
59
# Distance at altitude should be slightly larger (larger radius)
60
assert dist_altitude > dist_ground
61
62
def test_distance_array(self):
63
"""Test distance calculation with array inputs."""
64
geo_obj = Geo()
65
66
lat1 = np.array([51.5, 40.7])
67
lon1 = np.array([-0.1, -74.0])
68
lat2 = np.array([48.85, 34.05])
69
lon2 = np.array([2.35, -118.25])
70
71
dist = geo_obj.distance(lat1, lon1, lat2, lon2)
72
73
assert isinstance(dist, np.ndarray)
74
assert dist.shape == (2,)
75
assert dist[0] == pytest.approx(EXPECTED["distance_london_paris"], rel=0.01)
76
77
78
class TestGeoBearing:
79
"""Tests for bearing calculations."""
80
81
def test_bearing_london_paris(self):
82
"""Test bearing calculation."""
83
geo_obj = Geo()
84
85
# London to Paris (should be roughly south-southeast)
86
lat1, lon1 = 51.5, -0.1 # London
87
lat2, lon2 = 48.85, 2.35 # Paris
88
89
brg = geo_obj.bearing(lat1, lon1, lat2, lon2)
90
assert brg == pytest.approx(EXPECTED["bearing_london_paris"], rel=0.01)
91
92
def test_bearing_north(self):
93
"""Test bearing due north."""
94
geo_obj = Geo()
95
brg = geo_obj.bearing(0, 0, 10, 0)
96
assert brg == pytest.approx(0.0, abs=0.1)
97
98
def test_bearing_east(self):
99
"""Test bearing due east."""
100
geo_obj = Geo()
101
brg = geo_obj.bearing(0, 0, 0, 10)
102
assert brg == pytest.approx(90.0, abs=0.1)
103
104
def test_bearing_south(self):
105
"""Test bearing due south."""
106
geo_obj = Geo()
107
brg = geo_obj.bearing(10, 0, 0, 0)
108
assert brg == pytest.approx(180.0, abs=0.1)
109
110
def test_bearing_west(self):
111
"""Test bearing due west."""
112
geo_obj = Geo()
113
brg = geo_obj.bearing(0, 10, 0, 0)
114
assert brg == pytest.approx(270.0, abs=0.1)
115
116
117
class TestGeoLatlon:
118
"""Tests for latlon calculations."""
119
120
def test_latlon_forward(self):
121
"""Test lat/lon calculation given distance and bearing."""
122
geo_obj = Geo()
123
124
# Start at origin, go 111km north (roughly 1 degree latitude)
125
lat1, lon1 = 0.0, 0.0
126
d = 111000 # meters (roughly 1 degree at equator)
127
brg = 0 # north
128
129
lat2, lon2 = geo_obj.latlon(lat1, lon1, d, brg)
130
131
# Should be approximately 1 degree north
132
assert lat2 == pytest.approx(1.0, rel=0.01)
133
assert lon2 == pytest.approx(0.0, abs=0.01)
134
135
def test_latlon_east(self):
136
"""Test lat/lon calculation going east."""
137
geo_obj = Geo()
138
139
lat1, lon1 = 0.0, 0.0
140
d = 111000 # meters
141
brg = 90 # east
142
143
lat2, lon2 = geo_obj.latlon(lat1, lon1, d, brg)
144
145
# Should be approximately 1 degree east
146
assert lat2 == pytest.approx(0.0, abs=0.01)
147
assert lon2 == pytest.approx(1.0, rel=0.01)
148
149
def test_latlon_roundtrip(self):
150
"""Test that distance and latlon are consistent."""
151
geo_obj = Geo()
152
153
lat1, lon1 = 51.5, -0.1
154
d = 100000 # 100 km
155
brg = 45 # northeast
156
157
lat2, lon2 = geo_obj.latlon(lat1, lon1, d, brg)
158
159
# Calculate distance back - should match original
160
d_calc = geo_obj.distance(lat1, lon1, lat2, lon2)
161
assert d_calc == pytest.approx(d, rel=0.001)
162
163
164
class TestGeoSolarPosition:
165
"""Tests for solar zenith angle calculations."""
166
167
def test_solar_zenith_angle_noon_equator_equinox(self):
168
"""Test solar zenith angle at solar noon on equator during equinox."""
169
geo_obj = Geo()
170
171
# March 20, 2024 at ~12:00 UTC on the equator at prime meridian
172
# Sun should be nearly overhead (zenith ~0)
173
timestamp = datetime(2024, 3, 20, 12, 0, 0)
174
lat, lon = 0.0, 0.0
175
176
zenith = geo_obj.solar_zenith_angle(lat, lon, timestamp)
177
178
# At equinox, sun is directly overhead at equator at noon
179
# Allow some tolerance for equation of time
180
assert zenith == pytest.approx(0.0, abs=5.0)
181
182
def test_solar_zenith_angle_midnight(self):
183
"""Test solar zenith angle at midnight (should be > 90)."""
184
geo_obj = Geo()
185
186
# Midnight UTC at prime meridian
187
timestamp = datetime(2024, 6, 21, 0, 0, 0)
188
lat, lon = 45.0, 0.0
189
190
zenith = geo_obj.solar_zenith_angle(lat, lon, timestamp)
191
192
# At midnight, sun should be below horizon (zenith > 90)
193
assert zenith > 90
194
195
def test_solar_zenith_angle_summer_solstice(self):
196
"""Test solar zenith angle on summer solstice."""
197
geo_obj = Geo()
198
199
# June 21, 2024 at solar noon at Tropic of Cancer
200
timestamp = datetime(2024, 6, 21, 12, 0, 0)
201
lat, lon = 23.44, 0.0 # Tropic of Cancer
202
203
zenith = geo_obj.solar_zenith_angle(lat, lon, timestamp)
204
205
# Sun should be nearly overhead
206
assert zenith < 10
207
208
def test_solar_zenith_angle_array_input(self):
209
"""Test solar zenith angle with array inputs."""
210
geo_obj = Geo()
211
212
# Multiple locations at same time - use equinox for monotonic increase
213
timestamps = [datetime(2024, 3, 20, 12, 0, 0)] * 3
214
lats = np.array([0.0, 30.0, 60.0])
215
lons = np.array([0.0, 0.0, 0.0])
216
217
zenith = geo_obj.solar_zenith_angle(lats, lons, timestamps)
218
219
# Should return array
220
assert len(zenith) == 3
221
# At equinox, zenith angle should increase with latitude from equator
222
assert zenith[0] < zenith[1] < zenith[2]
223
224
225
class TestGeoModuleFunctions:
226
"""Tests for module-level wrapper functions."""
227
228
def test_module_distance(self):
229
"""Test module-level distance function."""
230
dist = geo.distance(51.5, -0.1, 48.85, 2.35)
231
assert dist == pytest.approx(EXPECTED["distance_london_paris"], rel=0.01)
232
233
def test_module_bearing(self):
234
"""Test module-level bearing function."""
235
brg = geo.bearing(51.5, -0.1, 48.85, 2.35)
236
assert brg == pytest.approx(EXPECTED["bearing_london_paris"], rel=0.01)
237
238
def test_module_latlon(self):
239
"""Test module-level latlon function."""
240
lat2, lon2 = geo.latlon(0, 0, 111000, 0) # Go 111km north
241
assert lat2 == pytest.approx(1.0, rel=0.01)
242
243
def test_module_solar_zenith_angle(self):
244
"""Test module-level solar_zenith_angle function."""
245
timestamp = datetime(2024, 3, 20, 12, 0, 0)
246
zenith = geo.solar_zenith_angle(0.0, 0.0, timestamp)
247
assert zenith == pytest.approx(0.0, abs=5.0)
248
249
250
class TestGeoCasadiBackend:
251
"""Tests for geo functions with CasADi backend."""
252
253
@pytest.fixture
254
def casadi(self):
255
"""Import casadi if available."""
256
return pytest.importorskip("casadi")
257
258
def test_distance_symbolic(self, casadi):
259
"""Test distance calculation with symbolic inputs."""
260
geo_obj = Geo(backend=CasadiBackend())
261
262
lat1 = casadi.SX.sym("lat1")
263
lon1 = casadi.SX.sym("lon1")
264
lat2 = casadi.SX.sym("lat2")
265
lon2 = casadi.SX.sym("lon2")
266
267
dist = geo_obj.distance(lat1, lon1, lat2, lon2)
268
assert isinstance(dist, casadi.SX)
269
270
# Evaluate
271
f = casadi.Function("f", [lat1, lon1, lat2, lon2], [dist])
272
result = float(f(51.5, -0.1, 48.85, 2.35))
273
assert result == pytest.approx(EXPECTED["distance_london_paris"], rel=0.01)
274
275
def test_bearing_symbolic(self, casadi):
276
"""Test bearing calculation with symbolic inputs."""
277
geo_obj = Geo(backend=CasadiBackend())
278
279
lat1 = casadi.SX.sym("lat1")
280
lon1 = casadi.SX.sym("lon1")
281
lat2 = casadi.SX.sym("lat2")
282
lon2 = casadi.SX.sym("lon2")
283
284
brg = geo_obj.bearing(lat1, lon1, lat2, lon2)
285
assert isinstance(brg, casadi.SX)
286
287
# Evaluate
288
f = casadi.Function("f", [lat1, lon1, lat2, lon2], [brg])
289
result = float(f(51.5, -0.1, 48.85, 2.35))
290
assert result == pytest.approx(EXPECTED["bearing_london_paris"], rel=0.01)
291
292
def test_latlon_symbolic(self, casadi):
293
"""Test latlon calculation with symbolic inputs."""
294
geo_obj = Geo(backend=CasadiBackend())
295
296
lat1 = casadi.SX.sym("lat1")
297
lon1 = casadi.SX.sym("lon1")
298
d = casadi.SX.sym("d")
299
brg = casadi.SX.sym("brg")
300
301
lat2, lon2 = geo_obj.latlon(lat1, lon1, d, brg)
302
assert isinstance(lat2, casadi.SX)
303
assert isinstance(lon2, casadi.SX)
304
305
306
class TestGeoJaxBackend:
307
"""Tests for geo functions with JAX backend."""
308
309
@pytest.fixture
310
def jax(self):
311
"""Import jax if available."""
312
return pytest.importorskip("jax")
313
314
@pytest.fixture
315
def jnp(self, jax):
316
"""Import jax.numpy."""
317
return jax.numpy
318
319
def test_distance_jax(self, jnp):
320
"""Test distance calculation with JAX."""
321
geo_obj = Geo(backend=JaxBackend())
322
323
dist = geo_obj.distance(
324
jnp.array(51.5),
325
jnp.array(-0.1),
326
jnp.array(48.85),
327
jnp.array(2.35),
328
)
329
assert float(dist) == pytest.approx(EXPECTED["distance_london_paris"], rel=0.01)
330
331
def test_bearing_jax(self, jnp):
332
"""Test bearing calculation with JAX."""
333
geo_obj = Geo(backend=JaxBackend())
334
335
brg = geo_obj.bearing(
336
jnp.array(51.5),
337
jnp.array(-0.1),
338
jnp.array(48.85),
339
jnp.array(2.35),
340
)
341
assert float(brg) == pytest.approx(EXPECTED["bearing_london_paris"], rel=0.01)
342
343
def test_jit_geo_functions(self, jax, jnp):
344
"""Test JIT compilation of geo functions."""
345
geo_obj = Geo(backend=JaxBackend())
346
347
@jax.jit
348
def compute_distance(lat1, lon1, lat2, lon2):
349
return geo_obj.distance(lat1, lon1, lat2, lon2)
350
351
dist = compute_distance(
352
jnp.array(51.5),
353
jnp.array(-0.1),
354
jnp.array(48.85),
355
jnp.array(2.35),
356
)
357
assert float(dist) == pytest.approx(EXPECTED["distance_london_paris"], rel=0.01)
358
359
360
class TestGeoBackwardCompatibility:
361
"""Tests for backward compatibility with aero module."""
362
363
def test_aero_distance_still_works(self):
364
"""Test that aero.distance still works (backward compat)."""
365
from openap import aero
366
367
dist = aero.distance(51.5, -0.1, 48.85, 2.35)
368
assert dist == pytest.approx(EXPECTED["distance_london_paris"], rel=0.01)
369
370
def test_aero_bearing_still_works(self):
371
"""Test that aero.bearing still works (backward compat)."""
372
from openap import aero
373
374
brg = aero.bearing(51.5, -0.1, 48.85, 2.35)
375
assert brg == pytest.approx(EXPECTED["bearing_london_paris"], rel=0.01)
376
377
def test_aero_latlon_still_works(self):
378
"""Test that aero.latlon still works (backward compat)."""
379
from openap import aero
380
381
lat2, lon2 = aero.latlon(0, 0, 111000, 0)
382
assert lat2 == pytest.approx(1.0, rel=0.01)
383
384
385
if __name__ == "__main__":
386
pytest.main([__file__, "-v"])
387
388