Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
junzis
GitHub Repository: junzis/openap
Path: blob/master/tests/test_aero.py
592 views
1
"""Comprehensive tests for the aero module.
2
3
This module tests all aeronautical calculation functions in openap.aero,
4
including atmospheric properties, airspeed conversions, and navigation functions.
5
"""
6
7
import numpy as np
8
import pytest
9
10
from openap import aero
11
from openap.aero import Aero
12
from openap.backends import CasadiBackend, JaxBackend, NumpyBackend
13
14
15
# Tolerance for floating point comparisons
16
RTOL = 1e-4 # 0.01% relative tolerance
17
18
# Expected values (computed with the openap implementation)
19
EXPECTED = {
20
# Atmospheric properties at h=10000m
21
"temperature_10km": 223.15, # K
22
"density_10km": 0.412604, # kg/m³
23
"pressure_10km": 26429.70, # Pa
24
"vsound_10km": 299.4632, # m/s
25
# Atmospheric properties at h=0m (sea level)
26
"temperature_0m": 288.15, # K
27
"density_0m": 1.225, # kg/m³
28
"pressure_0m": 101325.0, # Pa
29
"vsound_0m": 340.294, # m/s
30
# Stratosphere (h=15000m)
31
"temperature_15km": 216.65, # K (constant in stratosphere)
32
"pressure_15km": 12041.15, # Pa
33
# ISA altitude from pressure
34
"h_isa_sealevel": 0.0, # m (p=101325 Pa)
35
"h_isa_10km": 10000.0, # m (p≈26500 Pa)
36
# Airspeed conversions at h=10000m
37
"tas_from_eas": 344.6127, # m/s (EAS=200 m/s at 10km)
38
"eas_from_tas": 116.0723, # m/s (TAS=200 m/s at 10km)
39
"tas_from_cas": 314.5946, # m/s (CAS=200 m/s at 10km)
40
"cas_from_tas": 120.7405, # m/s (TAS=200 m/s at 10km)
41
"mach_from_tas": 0.6679, # (TAS=200 m/s at 10km)
42
"mach_from_cas": 1.0505, # (CAS=200 m/s at 10km)
43
# Crossover altitude (CAS=150 m/s, Mach=0.78)
44
"crossover_alt": 9335.23, # m
45
}
46
47
48
class TestAeroAtmospheric:
49
"""Tests for atmospheric property calculations."""
50
51
def test_atmos_sea_level(self):
52
"""Test atmospheric properties at sea level."""
53
aero_obj = Aero()
54
p, rho, T = aero_obj.atmos(0)
55
56
assert T == pytest.approx(EXPECTED["temperature_0m"], rel=RTOL)
57
assert rho == pytest.approx(EXPECTED["density_0m"], rel=RTOL)
58
assert p == pytest.approx(EXPECTED["pressure_0m"], rel=RTOL)
59
60
def test_atmos_troposphere(self):
61
"""Test atmospheric properties in troposphere (10km)."""
62
aero_obj = Aero()
63
p, rho, T = aero_obj.atmos(10000)
64
65
assert T == pytest.approx(EXPECTED["temperature_10km"], rel=RTOL)
66
assert rho == pytest.approx(EXPECTED["density_10km"], rel=RTOL)
67
assert p == pytest.approx(EXPECTED["pressure_10km"], rel=RTOL)
68
69
def test_atmos_stratosphere(self):
70
"""Test atmospheric properties in stratosphere (15km)."""
71
aero_obj = Aero()
72
p, rho, T = aero_obj.atmos(15000)
73
74
# Temperature is constant in stratosphere (216.65 K)
75
assert T == pytest.approx(EXPECTED["temperature_15km"], rel=RTOL)
76
assert p == pytest.approx(EXPECTED["pressure_15km"], rel=RTOL)
77
78
def test_atmos_with_isa_deviation(self):
79
"""Test atmospheric properties with ISA temperature deviation."""
80
aero_obj = Aero()
81
82
# +10K ISA deviation
83
p, rho, T = aero_obj.atmos(10000, dT=10)
84
assert T == pytest.approx(223.15 + 10, rel=RTOL)
85
86
# -10K ISA deviation
87
p, rho, T = aero_obj.atmos(10000, dT=-10)
88
assert T == pytest.approx(223.15 - 10, rel=RTOL)
89
90
def test_temperature(self):
91
"""Test temperature calculation."""
92
aero_obj = Aero()
93
T = aero_obj.temperature(10000)
94
assert T == pytest.approx(EXPECTED["temperature_10km"], rel=RTOL)
95
96
def test_pressure(self):
97
"""Test pressure calculation."""
98
aero_obj = Aero()
99
p = aero_obj.pressure(10000)
100
assert p == pytest.approx(EXPECTED["pressure_10km"], rel=RTOL)
101
102
def test_density(self):
103
"""Test density calculation."""
104
aero_obj = Aero()
105
rho = aero_obj.density(10000)
106
assert rho == pytest.approx(EXPECTED["density_10km"], rel=RTOL)
107
108
def test_vsound(self):
109
"""Test speed of sound calculation."""
110
aero_obj = Aero()
111
a = aero_obj.vsound(10000)
112
assert a == pytest.approx(EXPECTED["vsound_10km"], rel=RTOL)
113
114
a_sl = aero_obj.vsound(0)
115
assert a_sl == pytest.approx(EXPECTED["vsound_0m"], rel=RTOL)
116
117
def test_h_isa_troposphere(self):
118
"""Test ISA altitude calculation in troposphere."""
119
aero_obj = Aero()
120
121
# Sea level pressure should give h=0
122
h = aero_obj.h_isa(101325)
123
assert h == pytest.approx(0.0, abs=1.0)
124
125
# Pressure at 10km should give h≈10000
126
h = aero_obj.h_isa(26429.70)
127
assert h == pytest.approx(10000.0, rel=0.01)
128
129
def test_h_isa_stratosphere(self):
130
"""Test ISA altitude calculation in stratosphere."""
131
aero_obj = Aero()
132
133
# Pressure in stratosphere (p < 22630 Pa)
134
h = aero_obj.h_isa(12044.57)
135
assert h == pytest.approx(15000.0, rel=0.01)
136
137
def test_h_isa_with_isa_deviation(self):
138
"""Test ISA altitude with temperature deviation."""
139
aero_obj = Aero()
140
141
# With ISA deviation, altitude for same pressure changes
142
h_std = aero_obj.h_isa(26429.70, dT=0)
143
h_warm = aero_obj.h_isa(26429.70, dT=10)
144
145
# Warmer atmosphere means same pressure at higher altitude
146
assert h_warm > h_std
147
148
149
class TestAeroAirspeedConversions:
150
"""Tests for airspeed conversion functions."""
151
152
def test_tas2mach(self):
153
"""Test TAS to Mach conversion."""
154
aero_obj = Aero()
155
mach = aero_obj.tas2mach(200, 10000) # 200 m/s TAS at 10km
156
assert mach == pytest.approx(EXPECTED["mach_from_tas"], rel=RTOL)
157
158
def test_mach2tas(self):
159
"""Test Mach to TAS conversion."""
160
aero_obj = Aero()
161
tas = aero_obj.mach2tas(0.8, 10000) # Mach 0.8 at 10km
162
expected_tas = 0.8 * EXPECTED["vsound_10km"]
163
assert tas == pytest.approx(expected_tas, rel=RTOL)
164
165
def test_tas_mach_roundtrip(self):
166
"""Test TAS <-> Mach roundtrip consistency."""
167
aero_obj = Aero()
168
169
tas_orig = 250.0
170
h = 10000
171
172
mach = aero_obj.tas2mach(tas_orig, h)
173
tas_back = aero_obj.mach2tas(mach, h)
174
175
assert tas_back == pytest.approx(tas_orig, rel=RTOL)
176
177
def test_eas2tas(self):
178
"""Test EAS to TAS conversion."""
179
aero_obj = Aero()
180
tas = aero_obj.eas2tas(200, 10000) # 200 m/s EAS at 10km
181
assert tas == pytest.approx(EXPECTED["tas_from_eas"], rel=RTOL)
182
183
def test_tas2eas(self):
184
"""Test TAS to EAS conversion."""
185
aero_obj = Aero()
186
eas = aero_obj.tas2eas(200, 10000) # 200 m/s TAS at 10km
187
assert eas == pytest.approx(EXPECTED["eas_from_tas"], rel=RTOL)
188
189
def test_eas_tas_roundtrip(self):
190
"""Test EAS <-> TAS roundtrip consistency."""
191
aero_obj = Aero()
192
193
eas_orig = 200.0
194
h = 10000
195
196
tas = aero_obj.eas2tas(eas_orig, h)
197
eas_back = aero_obj.tas2eas(tas, h)
198
199
assert eas_back == pytest.approx(eas_orig, rel=RTOL)
200
201
def test_cas2tas(self):
202
"""Test CAS to TAS conversion."""
203
aero_obj = Aero()
204
tas = aero_obj.cas2tas(200, 10000) # 200 m/s CAS at 10km
205
assert tas == pytest.approx(EXPECTED["tas_from_cas"], rel=RTOL)
206
207
def test_tas2cas(self):
208
"""Test TAS to CAS conversion."""
209
aero_obj = Aero()
210
cas = aero_obj.tas2cas(200, 10000) # 200 m/s TAS at 10km
211
assert cas == pytest.approx(EXPECTED["cas_from_tas"], rel=RTOL)
212
213
def test_cas_tas_roundtrip(self):
214
"""Test CAS <-> TAS roundtrip consistency."""
215
aero_obj = Aero()
216
217
cas_orig = 200.0
218
h = 10000
219
220
tas = aero_obj.cas2tas(cas_orig, h)
221
cas_back = aero_obj.tas2cas(tas, h)
222
223
assert cas_back == pytest.approx(cas_orig, rel=RTOL)
224
225
def test_cas2mach(self):
226
"""Test CAS to Mach conversion."""
227
aero_obj = Aero()
228
mach = aero_obj.cas2mach(200, 10000) # 200 m/s CAS at 10km
229
assert mach == pytest.approx(EXPECTED["mach_from_cas"], rel=RTOL)
230
231
def test_mach2cas(self):
232
"""Test Mach to CAS conversion."""
233
aero_obj = Aero()
234
235
# Round trip test
236
cas_orig = 200.0
237
h = 10000
238
239
mach = aero_obj.cas2mach(cas_orig, h)
240
cas_back = aero_obj.mach2cas(mach, h)
241
242
assert cas_back == pytest.approx(cas_orig, rel=RTOL)
243
244
def test_crossover_altitude(self):
245
"""Test crossover altitude calculation."""
246
aero_obj = Aero()
247
248
# Typical climb: CAS=150 m/s (~290 kts), Mach=0.78
249
h = aero_obj.crossover_alt(150, 0.78)
250
assert h == pytest.approx(EXPECTED["crossover_alt"], rel=0.01)
251
252
def test_crossover_altitude_consistency(self):
253
"""Test that CAS and Mach match at crossover altitude."""
254
aero_obj = Aero()
255
256
v_cas = 150.0
257
mach = 0.78
258
259
h_cross = aero_obj.crossover_alt(v_cas, mach)
260
261
# At crossover, converting CAS to Mach should give target Mach
262
mach_at_cross = aero_obj.cas2mach(v_cas, h_cross)
263
assert mach_at_cross == pytest.approx(mach, rel=0.01)
264
265
def test_airspeed_with_isa_deviation(self):
266
"""Test airspeed conversions with ISA temperature deviation."""
267
aero_obj = Aero()
268
269
# Standard conditions
270
tas_std = aero_obj.cas2tas(200, 10000, dT=0)
271
272
# Warm atmosphere (+10K)
273
tas_warm = aero_obj.cas2tas(200, 10000, dT=10)
274
275
# ISA deviation affects the result (values should differ)
276
assert tas_warm != tas_std
277
# The relationship depends on altitude and temperature profile
278
# Just verify the function handles dT parameter correctly
279
assert tas_warm > 0 and tas_std > 0
280
281
282
class TestAeroModuleFunctions:
283
"""Tests for module-level wrapper functions."""
284
285
def test_module_atmos(self):
286
"""Test module-level atmos function."""
287
p, rho, T = aero.atmos(10000)
288
289
assert T == pytest.approx(EXPECTED["temperature_10km"], rel=RTOL)
290
assert rho == pytest.approx(EXPECTED["density_10km"], rel=RTOL)
291
assert p == pytest.approx(EXPECTED["pressure_10km"], rel=RTOL)
292
293
def test_module_temperature(self):
294
"""Test module-level temperature function."""
295
T = aero.temperature(10000)
296
assert T == pytest.approx(EXPECTED["temperature_10km"], rel=RTOL)
297
298
def test_module_pressure(self):
299
"""Test module-level pressure function."""
300
p = aero.pressure(10000)
301
assert p == pytest.approx(EXPECTED["pressure_10km"], rel=RTOL)
302
303
def test_module_density(self):
304
"""Test module-level density function."""
305
rho = aero.density(10000)
306
assert rho == pytest.approx(EXPECTED["density_10km"], rel=RTOL)
307
308
def test_module_vsound(self):
309
"""Test module-level vsound function."""
310
a = aero.vsound(10000)
311
assert a == pytest.approx(EXPECTED["vsound_10km"], rel=RTOL)
312
313
def test_module_h_isa(self):
314
"""Test module-level h_isa function."""
315
h = aero.h_isa(26429.70)
316
assert h == pytest.approx(10000.0, rel=0.01)
317
318
def test_module_tas2mach(self):
319
"""Test module-level tas2mach function."""
320
mach = aero.tas2mach(200, 10000)
321
assert mach == pytest.approx(EXPECTED["mach_from_tas"], rel=RTOL)
322
323
def test_module_mach2tas(self):
324
"""Test module-level mach2tas function."""
325
tas = aero.mach2tas(0.8, 10000)
326
expected_tas = 0.8 * EXPECTED["vsound_10km"]
327
assert tas == pytest.approx(expected_tas, rel=RTOL)
328
329
def test_module_eas2tas(self):
330
"""Test module-level eas2tas function."""
331
tas = aero.eas2tas(200, 10000)
332
assert tas == pytest.approx(EXPECTED["tas_from_eas"], rel=RTOL)
333
334
def test_module_tas2eas(self):
335
"""Test module-level tas2eas function."""
336
eas = aero.tas2eas(200, 10000)
337
assert eas == pytest.approx(EXPECTED["eas_from_tas"], rel=RTOL)
338
339
def test_module_cas2tas(self):
340
"""Test module-level cas2tas function."""
341
tas = aero.cas2tas(200, 10000)
342
assert tas == pytest.approx(EXPECTED["tas_from_cas"], rel=RTOL)
343
344
def test_module_tas2cas(self):
345
"""Test module-level tas2cas function."""
346
cas = aero.tas2cas(200, 10000)
347
assert cas == pytest.approx(EXPECTED["cas_from_tas"], rel=RTOL)
348
349
def test_module_mach2cas(self):
350
"""Test module-level mach2cas function."""
351
# Roundtrip test
352
cas_orig = 200.0
353
mach = aero.cas2mach(cas_orig, 10000)
354
cas_back = aero.mach2cas(mach, 10000)
355
assert cas_back == pytest.approx(cas_orig, rel=RTOL)
356
357
def test_module_cas2mach(self):
358
"""Test module-level cas2mach function."""
359
mach = aero.cas2mach(200, 10000)
360
assert mach == pytest.approx(EXPECTED["mach_from_cas"], rel=RTOL)
361
362
def test_module_crossover_alt(self):
363
"""Test module-level crossover_alt function."""
364
h = aero.crossover_alt(150, 0.78)
365
assert h == pytest.approx(EXPECTED["crossover_alt"], rel=0.01)
366
367
368
class TestAeroArrayInputs:
369
"""Tests for array inputs."""
370
371
def test_atmos_array(self):
372
"""Test atmospheric properties with array inputs."""
373
aero_obj = Aero()
374
h = np.array([0, 5000, 10000, 15000])
375
p, rho, T = aero_obj.atmos(h)
376
377
assert isinstance(T, np.ndarray)
378
assert T.shape == (4,)
379
assert T[0] == pytest.approx(EXPECTED["temperature_0m"], rel=RTOL)
380
assert T[2] == pytest.approx(EXPECTED["temperature_10km"], rel=RTOL)
381
382
def test_airspeed_conversion_array(self):
383
"""Test airspeed conversions with array inputs."""
384
aero_obj = Aero()
385
386
v_cas = np.array([150, 200, 250])
387
h = np.array([5000, 10000, 12000])
388
389
tas = aero_obj.cas2tas(v_cas, h)
390
391
assert isinstance(tas, np.ndarray)
392
assert tas.shape == (3,)
393
# TAS should be greater than CAS at altitude
394
assert np.all(tas > v_cas)
395
396
397
class TestAeroCasadiBackend:
398
"""Tests for aero functions with CasADi backend."""
399
400
@pytest.fixture
401
def casadi(self):
402
"""Import casadi if available."""
403
return pytest.importorskip("casadi")
404
405
def test_h_isa_symbolic(self, casadi):
406
"""Test h_isa calculation with symbolic inputs."""
407
aero_obj = Aero(backend=CasadiBackend())
408
409
p = casadi.SX.sym("p")
410
h = aero_obj.h_isa(p)
411
assert isinstance(h, casadi.SX)
412
413
# Evaluate
414
f = casadi.Function("f", [p], [h])
415
result = float(f(26429.70))
416
assert result == pytest.approx(10000.0, rel=0.01)
417
418
def test_cas2tas_symbolic(self, casadi):
419
"""Test CAS to TAS with symbolic inputs."""
420
aero_obj = Aero(backend=CasadiBackend())
421
422
v_cas = casadi.SX.sym("v_cas")
423
h = casadi.SX.sym("h")
424
425
tas = aero_obj.cas2tas(v_cas, h)
426
assert isinstance(tas, casadi.SX)
427
428
# Evaluate
429
f = casadi.Function("f", [v_cas, h], [tas])
430
result = float(f(200, 10000))
431
assert result == pytest.approx(EXPECTED["tas_from_cas"], rel=RTOL)
432
433
def test_crossover_alt_symbolic(self, casadi):
434
"""Test crossover altitude with symbolic inputs."""
435
aero_obj = Aero(backend=CasadiBackend())
436
437
v_cas = casadi.SX.sym("v_cas")
438
mach = casadi.SX.sym("mach")
439
440
h = aero_obj.crossover_alt(v_cas, mach)
441
assert isinstance(h, casadi.SX)
442
443
# Evaluate
444
f = casadi.Function("f", [v_cas, mach], [h])
445
result = float(f(150, 0.78))
446
assert result == pytest.approx(EXPECTED["crossover_alt"], rel=0.01)
447
448
449
class TestAeroJaxBackend:
450
"""Tests for aero functions with JAX backend."""
451
452
@pytest.fixture
453
def jax(self):
454
"""Import jax if available."""
455
return pytest.importorskip("jax")
456
457
@pytest.fixture
458
def jnp(self, jax):
459
"""Import jax.numpy."""
460
return jax.numpy
461
462
def test_h_isa_jax(self, jnp):
463
"""Test h_isa calculation with JAX."""
464
aero_obj = Aero(backend=JaxBackend())
465
466
h = aero_obj.h_isa(jnp.array(26429.70))
467
assert float(h) == pytest.approx(10000.0, rel=0.01)
468
469
def test_cas2tas_jax(self, jnp):
470
"""Test CAS to TAS with JAX."""
471
aero_obj = Aero(backend=JaxBackend())
472
473
tas = aero_obj.cas2tas(jnp.array(200.0), jnp.array(10000.0))
474
assert float(tas) == pytest.approx(EXPECTED["tas_from_cas"], rel=RTOL)
475
476
def test_crossover_alt_jax(self, jnp):
477
"""Test crossover altitude with JAX."""
478
aero_obj = Aero(backend=JaxBackend())
479
480
h = aero_obj.crossover_alt(jnp.array(150.0), jnp.array(0.78))
481
assert float(h) == pytest.approx(EXPECTED["crossover_alt"], rel=0.01)
482
483
def test_jit_aero_functions(self, jax, jnp):
484
"""Test JIT compilation of aero functions."""
485
aero_obj = Aero(backend=JaxBackend())
486
487
@jax.jit
488
def compute_tas(cas, h):
489
return aero_obj.cas2tas(cas, h)
490
491
tas = compute_tas(jnp.array(200.0), jnp.array(10000.0))
492
assert float(tas) == pytest.approx(EXPECTED["tas_from_cas"], rel=RTOL)
493
494
495
class TestAeroConstants:
496
"""Tests for aero constants."""
497
498
def test_module_constants(self):
499
"""Test that module constants are correct."""
500
assert aero.kts == pytest.approx(0.514444, rel=1e-5)
501
assert aero.ft == pytest.approx(0.3048, rel=1e-5)
502
assert aero.fpm == pytest.approx(0.00508, rel=1e-5)
503
assert aero.nm == pytest.approx(1852.0, rel=1e-5)
504
assert aero.lbs == pytest.approx(0.453592, rel=1e-5)
505
assert aero.g0 == pytest.approx(9.80665, rel=1e-5)
506
assert aero.R == pytest.approx(287.05287, rel=1e-5)
507
assert aero.p0 == pytest.approx(101325.0, rel=1e-5)
508
assert aero.rho0 == pytest.approx(1.225, rel=1e-5)
509
assert aero.T0 == pytest.approx(288.15, rel=1e-5)
510
assert aero.gamma == pytest.approx(1.40, rel=1e-5)
511
512
def test_class_constants(self):
513
"""Test that class constants match module constants."""
514
assert Aero.kts == aero.kts
515
assert Aero.ft == aero.ft
516
assert Aero.fpm == aero.fpm
517
assert Aero.nm == aero.nm
518
assert Aero.g0 == aero.g0
519
assert Aero.R == aero.R
520
assert Aero.p0 == aero.p0
521
assert Aero.rho0 == aero.rho0
522
assert Aero.T0 == aero.T0
523
524
525
if __name__ == "__main__":
526
pytest.main([__file__, "-v"])
527
528