Skip to content

Commit e1cb754

Browse files
committed
Add unit tests.
Change unit.py to keep angles in the range (-π, π].
1 parent bc94e25 commit e1cb754

8 files changed

Lines changed: 238 additions & 47 deletions

File tree

py_ballisticcalc/unit.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,7 @@ class UnitProps(NamedTuple):
506506
('mil',): Unit.Mil,
507507
('mrad',): Unit.MRad,
508508
('thousandth', 'ths'): Unit.Thousandth,
509-
('inch/100yd', 'in/100yd', 'inch/100yd', 'in/100yard, inper100yd'): Unit.InchesPer100Yd,
509+
('inch/100yd', 'in/100yd', 'in/100yard', 'inper100yd'): Unit.InchesPer100Yd,
510510
('centimeter/100m', 'cm/100m', 'cm/100meter', 'centimeter/100meter', 'cmper100m'): Unit.CmPer100m,
511511
('hour', 'h'): Unit.OClock,
512512

@@ -1001,11 +1001,11 @@ def _rad(self):
10011001
@override
10021002
@classmethod
10031003
def to_raw(cls, value: Number, units: Unit) -> Number:
1004-
"""Avoid going in circles: Truncates to [0, 2π)."""
1004+
"""Normalize angle to (-π, π]."""
10051005
radians = super().to_raw(value, units)
1006-
if radians > 2. * pi:
1007-
radians = radians % (2. * pi)
1008-
return radians
1006+
# Normalize to [-π, π)
1007+
r = (radians + pi) % (2.0 * pi) - pi
1008+
return r if r > -pi else pi # move -π to +π
10091009

10101010
# Angular.* unit aliases
10111011
Radian: Final[Unit] = Unit.Radian

tests/test_computer.py

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
"""Unittests for the py_ballisticcalc library"""
2-
31
import copy
4-
import math
52
import pytest
63

74
from py_ballisticcalc import (DragModel, Ammo, Weapon, Calculator, Shot, Wind, Atmo, TableG7, RangeError, TrajFlag,
@@ -254,24 +251,6 @@ def test_limit_start(self, loaded_engine_instance):
254251
assert len(t.trajectory) >= 1
255252
assert isinstance(t.error, RangeError)
256253

257-
def test_winds_sort(self):
258-
"""Test that the winds are sorted by until_distance"""
259-
winds = [
260-
Wind(Unit.MPS(0), Unit.Degree(90), Unit.Meter(100)),
261-
Wind(Unit.MPS(1), Unit.Degree(60), Unit.Meter(300)),
262-
Wind(Unit.MPS(2), Unit.Degree(30), Unit.Meter(200)),
263-
Wind(Unit.MPS(2), Unit.Degree(30), Unit.Meter(50)),
264-
]
265-
shot = Shot(
266-
self.ammo, None, 0, 0, 0, None,
267-
winds
268-
)
269-
sorted_winds = shot.winds
270-
assert sorted_winds[0] is winds[3]
271-
assert sorted_winds[1] is winds[0]
272-
assert sorted_winds[2] is winds[2]
273-
assert sorted_winds[3] is winds[1]
274-
275254
def test_combined_flags(self):
276255
"""Test that combined flags are correctly set in the trajectory"""
277256
dm = DragModel(bc=0.243, drag_table=TableG7)

tests/test_extremes.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import math
2+
import pytest
3+
4+
from py_ballisticcalc import *
5+
from py_ballisticcalc.helpers import vacuum_range, EARTH_GRAVITY_CONSTANT_IN_SI
6+
from py_ballisticcalc.vector import Vector
7+
8+
9+
class TestAtmoBoundaries:
10+
def test_humidity_accepts_fraction_and_percent(self):
11+
a = Atmo.icao()
12+
# Set as percent -> stored as fraction
13+
a.humidity = 50
14+
assert pytest.approx(a.humidity, abs=1e-12) == 0.5
15+
# Set as fraction -> stored unchanged
16+
a.humidity = 0.25
17+
assert pytest.approx(a.humidity, abs=1e-12) == 0.25
18+
19+
@pytest.mark.parametrize("bad", [-0.01, -10, 100.01, 1e6])
20+
def test_humidity_out_of_range_raises(self, bad):
21+
a = Atmo.icao()
22+
with pytest.raises(ValueError):
23+
a.humidity = bad
24+
25+
def test_altitude_reuse_window_edges(self):
26+
a = Atmo.icao(Distance.Foot(1000))
27+
dr0, m0 = a.get_density_and_mach_for_altitude(1000.0)
28+
# Exactly within 30 ft should reuse cached values
29+
dr1, m1 = a.get_density_and_mach_for_altitude(1000.0 + 29.999)
30+
assert dr1 == pytest.approx(dr0)
31+
assert m1 == pytest.approx(m0)
32+
# Outside window recomputes; values should still be sane
33+
dr2, m2 = a.get_density_and_mach_for_altitude(1000.0 + 31.0)
34+
assert 0 < dr2 < 2 and m2 > 0
35+
36+
def test_temperature_at_altitude_floor(self):
37+
a = Atmo.icao()
38+
with pytest.warns(RuntimeWarning):
39+
t = a.temperature_at_altitude(1e9)
40+
# Not below absolute-zero in Celsius
41+
assert t >= Atmo.cLowestTempC - 1e-9
42+
43+
44+
class TestWindVectors:
45+
@pytest.mark.parametrize(
46+
"deg, expect_x, expect_z",
47+
[
48+
(0.0, 1.0, 0.0), # tailwind
49+
(90.0, 0.0, 1.0), # from left to right -> +z
50+
(180.0, -1.0, 0.0), # headwind
51+
(270.0, 0.0, -1.0), # from right to left -> -z
52+
],
53+
)
54+
def test_cardinal_directions(self, deg, expect_x, expect_z):
55+
w = Wind(velocity=Velocity.FPS(10), direction_from=Angular.Degree(deg))
56+
v = w.vector
57+
# Components scaled by 10 fps
58+
assert v.x == pytest.approx(10 * expect_x, abs=1e-9)
59+
assert v.z == pytest.approx(10 * expect_z, abs=1e-9)
60+
assert v.y == 0.0
61+
62+
63+
class TestVectorEdge:
64+
def test_normalize_near_zero_returns_unchanged(self):
65+
eps = 1e-12
66+
v = Vector(eps, -eps, eps)
67+
n = v.normalize()
68+
# Threshold in implementation is 1e-10
69+
assert n == v
70+
71+
72+
class TestInterpolationTightSpacing:
73+
def test_three_point_pchip_tiny_interval(self):
74+
from py_ballisticcalc.interpolation import interpolate_3_pt
75+
# Very tight spacing between first two points
76+
x0, x1, x2 = 0.0, 1e-12, 1.0
77+
y0, y1, y2 = 0.0, 1e-12, 1.0
78+
# Should not raise and should be within envelope
79+
y = interpolate_3_pt(5e-13, x0, y0, x1, y1, x2, y2)
80+
assert 0.0 - 1e-12 <= y <= 1.0 + 1e-12
81+
82+
83+
class TestHelpersVacuum:
84+
def test_vacuum_range_zero_or_ninety_angle(self):
85+
assert vacuum_range(100.0, 0.0) == pytest.approx(0.0)
86+
assert vacuum_range(100.0, 90.0) == pytest.approx(0.0)
87+
88+
def test_vacuum_range_negative_gravity_handled(self):
89+
# Function flips sign of gravity if negative
90+
g = -EARTH_GRAVITY_CONSTANT_IN_SI
91+
r1 = vacuum_range(50.0, 45.0, gravity=g)
92+
r2 = vacuum_range(50.0, 45.0, gravity=abs(g))
93+
assert r1 == pytest.approx(r2)
94+
95+
def test_vacuum_angle_to_zero_domain_error(self):
96+
# distance too large -> argument to asin > 1 => ValueError
97+
from py_ballisticcalc.helpers import vacuum_angle_to_zero
98+
v = 10.0
99+
g = EARTH_GRAVITY_CONSTANT_IN_SI
100+
# Make distance slightly larger than max range achievable at 45 deg
101+
d = (v**2 / g) * 1.01
102+
with pytest.raises(ValueError):
103+
_ = vacuum_angle_to_zero(v, d, gravity=g)
104+
105+
def test_vacuum_velocity_to_zero_zero_angle_division_by_zero(self):
106+
from py_ballisticcalc.helpers import vacuum_velocity_to_zero
107+
with pytest.raises(ZeroDivisionError):
108+
_ = vacuum_velocity_to_zero(1.0, 0.0)
109+
110+
111+
class TestUnitsDivisionErrors:
112+
def test_division_by_zero_number_raises(self):
113+
d = Distance.Meter(1.0)
114+
with pytest.raises(ZeroDivisionError):
115+
_ = d / 0
116+
117+
def test_division_by_zero_dimension_raises(self):
118+
d = Distance.Meter(1.0)
119+
z = Distance.Meter(0.0)
120+
with pytest.raises(ZeroDivisionError):
121+
_ = d / z
122+
123+
124+
class TestWindMaxDistance:
125+
def test_custom_max_distance_used_for_default_until(self):
126+
w = Wind(velocity=Velocity.FPS(1), direction_from=Angular.Degree(0), max_distance_feet=12345)
127+
assert w.until_distance == Distance.Foot(12345)
128+
129+
130+
class TestVectorLargeMagnitude:
131+
def test_magnitude_large_values(self):
132+
# Should not raise; hypot returns inf for extremely large values
133+
v = Vector(1e308, 1e308, 1e308)
134+
m = v.magnitude()
135+
assert math.isfinite(m) or math.isinf(m)

tests/test_hitresult.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ def test_flags(self):
3737
assert mach is not None, "MACH flag not found in HitResult"
3838
assert pytest.approx(mach.distance >> Distance.Yard, abs=0.5) == 963.0, "MACH distance"
3939

40+
def test_get_at_unrequested_flag(self):
41+
hr = self.calc.fire(self.shot, trajectory_range=Distance.Meter(100), flags=TrajFlag.RANGE)
42+
with pytest.raises(AttributeError):
43+
_ = hr.flag(TrajFlag.ZERO)
44+
4045
def test_danger_space(self):
4146
# Danger space on downward trajectory
4247
danger_space = self.shot_result.danger_space(Distance.Yard(500), Distance.Meter(1.5))

tests/test_interpolation.py

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@
99
from py_ballisticcalc.vector import Vector
1010

1111

12-
def make_base(t: float, pos: float, vel: float, mach: float) -> BaseTrajData:
13-
return BaseTrajData(time=t, position=Vector(pos, 0.0, 0.0), velocity=Vector(vel, 0.0, 0.0), mach=mach)
12+
class TestInterpolationBasic:
1413

14+
@staticmethod
15+
def make_base(t: float, pos: float, vel: float, mach: float) -> BaseTrajData:
16+
return BaseTrajData(time=t, position=Vector(pos, 0.0, 0.0), velocity=Vector(vel, 0.0, 0.0), mach=mach)
1517

16-
class TestInterpolationBasic:
1718
def test_pchip_monotone_preserves_shape_scalar(self):
1819
x0, x1, x2 = 0.0, 1.0, 2.0
1920
y0, y1, y2 = 0.0, 1.0, 1.5
@@ -33,9 +34,9 @@ def test_linear_scalar_agrees_with_exact_line(self):
3334
assert math.isclose(y, y0 + (y1 - y0) * (x - x0) / (x1 - x0), rel_tol=0, abs_tol=1e-12)
3435

3536
def test_basetrajdata_interpolate_method_switch(self):
36-
p0 = make_base(0.0, 0.0, 3000.0, 2.5)
37-
p1 = make_base(1.0, 1.0, 2800.0, 2.3)
38-
p2 = make_base(2.0, 1.5, 2600.0, 2.1)
37+
p0 = self.make_base(0.0, 0.0, 3000.0, 2.5)
38+
p1 = self.make_base(1.0, 1.0, 2800.0, 2.3)
39+
p2 = self.make_base(2.0, 1.5, 2600.0, 2.1)
3940
# mid-point in first interval
4041
res_pchip = BaseTrajData.interpolate('time', 0.5, p0, p1, p2, method='pchip')
4142
res_linear = BaseTrajData.interpolate('time', 0.5, p0, p1, p2, method='linear')
@@ -45,17 +46,29 @@ def test_basetrajdata_interpolate_method_switch(self):
4546

4647
def test_basetrajdata_interpolate_dimension_switch_on_position(self):
4748
# Use position.x as key
48-
p0 = make_base(0.0, 0.0, 3000.0, 2.5)
49-
p1 = make_base(1.0, 100.0, 2800.0, 2.3)
50-
p2 = make_base(2.0, 200.0, 2600.0, 2.1)
49+
p0 = self.make_base(0.0, 0.0, 3000.0, 2.5)
50+
p1 = self.make_base(1.0, 100.0, 2800.0, 2.3)
51+
p2 = self.make_base(2.0, 200.0, 2600.0, 2.1)
5152
res_lin = BaseTrajData.interpolate('position.x', 50.0, p0, p1, p2, method='linear')
5253
res_pc = BaseTrajData.interpolate('position.x', 50.0, p0, p1, p2, method='pchip')
5354
# Ensure times are between neighbor times when keying on position.x
5455
assert p0.time <= res_lin.time <= p1.time
5556
assert p0.time <= res_pc.time <= p1.time
5657

58+
def test_basetraj_linear_chooses_correct_segment(self):
59+
# Create non-uniform key spacing and values; request linear at key in right segment
60+
p0 = self.make_base(0.0, 0.0, 0.0, 0.2)
61+
p1 = self.make_base(0.1, 1.0, 0.0, 0.3)
62+
p2 = self.make_base(2.0, 2.0, 0.0, 0.4)
63+
# Interpolate position.x keyed by time=1.0 (right segment)
64+
r = BaseTrajData.interpolate('time', 1.0, p0, p1, p2, method="linear")
65+
# Expect linear between p1 and p2 positions.x
66+
expected = interpolate_2_pt(1.0, 0.1, p1.position.x, 2.0, p2.position.x)
67+
assert abs(r.position.x - expected) < 1e-12
68+
5769

5870
class TestInterpolationEdge:
71+
5972
def test_pchip_no_overshoot_near_peak_and_valley(self):
6073
# Local peak around x=1: increasing then decreasing
6174
x0, x1, x2 = 0.0, 1.0, 2.0
@@ -147,17 +160,6 @@ def test_pchip_equals_linear_on_colinear_points(self):
147160
y_ref = interpolate_2_pt(x, x0, y0, x1, y1) if x <= x1 else interpolate_2_pt(x, x1, y1, x2, y2)
148161
assert abs(y_p - y_ref) < 1e-12
149162

150-
def test_basetraj_linear_chooses_correct_segment(self):
151-
# Create non-uniform key spacing and values; request linear at key in right segment
152-
p0 = make_base(0.0, 0.0, 0.0, 0.2)
153-
p1 = make_base(0.1, 1.0, 0.0, 0.3)
154-
p2 = make_base(2.0, 2.0, 0.0, 0.4)
155-
# Interpolate position.x keyed by time=1.0 (right segment)
156-
r = BaseTrajData.interpolate('time', 1.0, p0, p1, p2, method="linear")
157-
# Expect linear between p1 and p2 positions.x
158-
expected = interpolate_2_pt(1.0, 0.1, p1.position.x, 2.0, p2.position.x)
159-
assert abs(r.position.x - expected) < 1e-12
160-
161163

162164
class TestTrajectoryDataInterpolation:
163165

tests/test_trajectory.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,20 @@ def test_path_g7(self, loaded_engine_instance, data_point, distance, velocity, m
111111
assert len(data) == 11, "Trajectory Row Count"
112112
self.validate_one(data_point(data), distance, velocity, mach, energy, path, hold, windage, wind_adjustment,
113113
time, ogv, adjustment_unit)
114+
115+
116+
class TestTrajFlagName:
117+
118+
def test_single_and_combined_flags(self):
119+
assert TrajFlag.name(TrajFlag.NONE) == 'NONE'
120+
assert TrajFlag.name(TrajFlag.ZERO_UP) == 'ZERO_UP'
121+
assert TrajFlag.name(TrajFlag.ZERO_DOWN) == 'ZERO_DOWN'
122+
assert TrajFlag.name(TrajFlag.ZERO) == 'ZERO'
123+
combo = TrajFlag.ZERO | TrajFlag.APEX | TrajFlag.MACH
124+
s = TrajFlag.name(combo)
125+
# Ensure ZERO compresses up/down and includes others
126+
assert 'ZERO' in s and 'APEX' in s and 'MACH' in s
127+
assert 'ZERO_UP' not in s and 'ZERO_DOWN' not in s
128+
129+
def test_unknown_flag_returns_unknown(self):
130+
assert TrajFlag.name(1 << 10) == 'UNKNOWN'

tests/test_units.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ def test_parse_unit_mixed_case_and_whitespace(self):
6565
assert _parse_unit(' Ft ') == Unit.Foot
6666
assert _parse_unit(' m / s ') == Unit.MPS
6767
assert _parse_unit('\tinHg\t') == Unit.InHg
68+
# inches per 100 yards variants
69+
assert _parse_unit('in/100yard') == Unit.InchesPer100Yd
70+
assert _parse_unit('inper100yd') == Unit.InchesPer100Yd
6871

6972
def test_parse_unit_pluralization_and_aliases(self):
7073
assert _parse_unit('yards') == Unit.Yard
@@ -102,7 +105,9 @@ def test_angular(self, u):
102105
back_n_forth_pytest(3, u, self.unit_class)
103106

104107
def test_angle_truncation(self):
105-
assert pytest.approx(Angular(720, Angular.Degree).raw_value) == Angular(0, Angular.Degree).raw_value
108+
# Angles should be normalized to the interval (-180, 180] degrees.
109+
assert pytest.approx(Angular(540, Angular.Degree).raw_value) == Angular(180, Angular.Degree).raw_value
110+
assert pytest.approx(Angular(-270, Angular.Degree).raw_value) == Angular(90, Angular.Degree).raw_value
106111

107112

108113
class TestDistance:
@@ -164,6 +169,17 @@ class TestTemperature:
164169
def test_temperature(self, u):
165170
back_n_forth_pytest(3, u, self.unit_class)
166171

172+
def test_temperature_addition_clamps_absolute_zero(self):
173+
t = Temperature.Kelvin(10)
174+
t2 = t + (-1000)
175+
assert (t2 >> Temperature.Kelvin) == pytest.approx(0.0)
176+
177+
def test_temperature_mul_div_raise(self):
178+
with pytest.raises(TypeError):
179+
_ = Temperature.Celsius(10) * 2
180+
with pytest.raises(TypeError):
181+
_ = Temperature.Celsius(10) / 2
182+
167183

168184
class TestVelocity:
169185
unit_class = Velocity

tests/test_wind.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import pytest
2+
3+
from py_ballisticcalc.conditions import Ammo, Shot, Wind
4+
from py_ballisticcalc.drag_model import DragModel
5+
from py_ballisticcalc.drag_tables import TableG7
6+
from py_ballisticcalc.engines.base_engine import _WindSock
7+
from py_ballisticcalc.unit import Distance, Velocity, Angular, Unit
8+
9+
10+
class TestWindSock:
11+
def test_winds_sort(self):
12+
"""Test that the winds are sorted by until_distance"""
13+
winds = [
14+
Wind(Unit.MPS(0), Unit.Degree(90), Unit.Meter(100)),
15+
Wind(Unit.MPS(1), Unit.Degree(60), Unit.Meter(300)),
16+
Wind(Unit.MPS(2), Unit.Degree(30), Unit.Meter(200)),
17+
Wind(Unit.MPS(2), Unit.Degree(30), Unit.Meter(50)),
18+
]
19+
shot = Shot(ammo=Ammo(DragModel(0.22, TableG7), Velocity.FPS(2600)), winds=winds)
20+
sorted_winds = shot.winds
21+
assert sorted_winds[0] is winds[3]
22+
assert sorted_winds[1] is winds[0]
23+
assert sorted_winds[2] is winds[2]
24+
assert sorted_winds[3] is winds[1]
25+
26+
def test_windsock_switches_vectors_at_thresholds(self):
27+
w1 = Wind(velocity=Velocity.FPS(10), direction_from=Angular.Degree(0), until_distance=Distance.Foot(50))
28+
w2 = Wind(velocity=Velocity.FPS(20), direction_from=Angular.Degree(90), until_distance=Distance.Foot(100))
29+
ws = _WindSock((w1, w2))
30+
v0 = ws.vector_for_range(0.0)
31+
assert v0.x > 0 and v0.z == pytest.approx(0.0)
32+
v49 = ws.vector_for_range(49.0)
33+
assert v49.x == pytest.approx(v0.x) and v49.z == pytest.approx(v0.z)
34+
v50 = ws.vector_for_range(50.0)
35+
# Now from left to right (+z), crosswind only
36+
assert v50.x == pytest.approx(0.0, abs=1e-12)
37+
assert v50.z > 0

0 commit comments

Comments
 (0)