Skip to content

Commit 5210638

Browse files
authored
Merge pull request #14 from cmccomb/codex/fix-divide-by-zero-warnings
Fix divide-by-zero handling in truss computations
2 parents 4cb944a + ef9cffe commit 5210638

6 files changed

Lines changed: 155 additions & 14 deletions

File tree

‎tests/test_member_math.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import warnings
2+
3+
import numpy
4+
import pytest
5+
6+
import trussme
7+
8+
from tests.helpers import build_triangle_truss
9+
10+
11+
def test_member_safety_factors_handle_zero_force_without_runtime_warnings() -> None:
12+
truss = build_triangle_truss()
13+
member = truss.members[0]
14+
15+
with warnings.catch_warnings(record=True) as captured:
16+
warnings.simplefilter("always")
17+
yielding = member.fos_yielding
18+
buckling = member.fos_buckling
19+
20+
runtime_warnings = [
21+
warning for warning in captured if issubclass(warning.category, RuntimeWarning)
22+
]
23+
24+
assert numpy.isinf(yielding)
25+
assert numpy.isinf(buckling)
26+
assert not runtime_warnings
27+
28+
29+
def test_zero_length_member_direction_returns_zero_vector() -> None:
30+
truss = trussme.Truss()
31+
truss.add_free_joint([0.0, 0.0, 0.0])
32+
truss.add_free_joint([0.0, 0.0, 0.0])
33+
truss.add_member(0, 1)
34+
35+
with warnings.catch_warnings(record=True) as captured:
36+
warnings.simplefilter("always")
37+
direction = truss.members[0].direction
38+
39+
runtime_warnings = [
40+
warning for warning in captured if issubclass(warning.category, RuntimeWarning)
41+
]
42+
43+
assert numpy.allclose(direction, numpy.zeros(3))
44+
assert not runtime_warnings
45+
46+
47+
def test_zero_length_member_stiffness_raises_value_error() -> None:
48+
truss = trussme.Truss()
49+
truss.add_free_joint([0.0, 0.0, 0.0])
50+
truss.add_free_joint([0.0, 0.0, 0.0])
51+
truss.add_member(0, 1)
52+
53+
with pytest.raises(ValueError, match="length must be greater than zero"):
54+
_ = truss.members[0].stiffness

‎tests/test_optimization.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,46 @@ def test_make_truss_generator_function_scaled_updates_other_shapes(
276276
assert generated.members[0].shape._params[key] == pytest.approx(value)
277277

278278

279+
@pytest.mark.parametrize(
280+
("shape_factory", "expected"),
281+
[
282+
(
283+
lambda: trussme.Pipe(r=0.0, t=0.0),
284+
{"r": 0.04, "t": 0.0},
285+
),
286+
(
287+
lambda: trussme.Box(w=0.0, h=0.0, t=0.0),
288+
{"w": 0.04, "h": 0.0, "t": 0.0},
289+
),
290+
(
291+
lambda: trussme.Square(w=0.0, h=0.0),
292+
{"w": 0.04, "h": 0.0},
293+
),
294+
],
295+
)
296+
def test_make_truss_generator_function_scaled_handles_zero_reference_dimensions(
297+
shape_factory,
298+
expected,
299+
) -> None:
300+
truss = build_triangle_truss(shape=shape_factory)
301+
generator = trussme.make_truss_generator_function(
302+
truss,
303+
joint_optimization=None,
304+
member_optimization="scaled",
305+
)
306+
x0 = optimize.make_x0(
307+
truss,
308+
joint_optimization=None,
309+
member_optimization="scaled",
310+
)
311+
x0[0] = 0.04
312+
313+
generated = generator(x0)
314+
315+
for key, value in expected.items():
316+
assert generated.members[0].shape._params[key] == pytest.approx(value)
317+
318+
279319
def test_make_truss_generator_function_full_updates_shapes() -> None:
280320
truss = build_optimization_truss()
281321
generator = trussme.make_truss_generator_function(

‎tests/test_visualize.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import warnings
2+
13
import matplotlib
24

35
matplotlib.use("Agg")
@@ -38,6 +40,22 @@ def test_plot_truss_supports_force_coloring() -> None:
3840
assert figure.axes
3941

4042

43+
def test_plot_truss_supports_force_coloring_with_zero_forces() -> None:
44+
truss = build_triangle_truss()
45+
46+
with warnings.catch_warnings(record=True) as captured:
47+
warnings.simplefilter("always")
48+
figure = plot_truss(truss, starting_shape="force", deflected_shape="force")
49+
50+
runtime_warnings = [
51+
warning for warning in captured if issubclass(warning.category, RuntimeWarning)
52+
]
53+
54+
assert isinstance(figure, Figure)
55+
assert figure.axes
56+
assert not runtime_warnings
57+
58+
4159
def test_plot_truss_allows_hidden_shapes() -> None:
4260
truss = build_triangle_truss()
4361
truss.analyze()

‎trussme/components.py‎

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -516,12 +516,18 @@ def direction(self) -> NDArray[numpy.float64]:
516516
vector_length: NDArray[numpy.float64] = numpy.array(
517517
self.end_joint.coordinates
518518
) - numpy.array(self.begin_joint.coordinates)
519-
return vector_length / numpy.linalg.norm(vector_length)
519+
magnitude = float(numpy.linalg.norm(vector_length))
520+
if magnitude == 0.0:
521+
return numpy.zeros_like(vector_length, dtype=numpy.float64)
522+
return cast(NDArray[numpy.float64], vector_length / magnitude)
520523

521524
@property
522525
def stiffness(self) -> float:
523526
"""float: The axial stiffness of the member"""
524-
return self.elastic_modulus * self.area / self.length
527+
length = self.length
528+
if length == 0.0:
529+
raise ValueError("Member length must be greater than zero")
530+
return self.elastic_modulus * self.area / length
525531

526532
@property
527533
def stiffness_vector(self) -> NDArray[numpy.float64]:
@@ -554,19 +560,28 @@ def force(self, new_force: float):
554560
@property
555561
def fos_yielding(self) -> float:
556562
"""float: The factor of safety against yielding"""
557-
return self.yield_strength / abs(self.force / self.area)
563+
applied_force = abs(self.force)
564+
if applied_force == 0.0:
565+
return numpy.inf
566+
return self.yield_strength * abs(self.area) / applied_force
558567

559568
@property
560569
def fos_buckling(self) -> float:
561570
"""float: The factor of safety against buckling"""
571+
compressive_force = -self.force
572+
if compressive_force <= 0.0:
573+
return numpy.inf
574+
575+
length = self.length
576+
if length == 0.0:
577+
return numpy.inf
578+
562579
fos = (
563-
-(
564-
(numpy.pi**2)
565-
* self.elastic_modulus
566-
* self.moment_of_inertia
567-
/ (self.length**2)
568-
)
569-
/ self.force
580+
(numpy.pi**2)
581+
* self.elastic_modulus
582+
* self.moment_of_inertia
583+
/ (length**2)
584+
/ compressive_force
570585
)
571586

572587
return fos if fos > 0 else numpy.inf

‎trussme/optimize.py‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
from trussme import Truss, Goals, read_json, Pipe, Box, Square, Bar
66

77

8+
def _safe_ratio(numerator: float, denominator: float) -> float:
9+
if denominator == 0.0:
10+
return 0.0
11+
return numerator / denominator
12+
13+
814
def make_x0(
915
truss: Truss,
1016
joint_optimization: Optional[Literal["full"]] = "full",
@@ -223,21 +229,27 @@ def truss_generator(x: list[float]) -> Truss:
223229
shape_name: str = configured_truss.members[i].shape.name()
224230
p = configured_truss.members[i].shape._params
225231
if shape_name == "pipe":
232+
thickness_ratio = _safe_ratio(p["t"], p["r"])
226233
configured_truss.members[i].shape = Pipe(
227-
r=x[idx], t=x[idx] * p["t"] / p["r"]
234+
r=x[idx], t=x[idx] * thickness_ratio
228235
)
229236
idx += 1
230237
elif shape_name == "box":
238+
height_ratio = _safe_ratio(p["h"], p["w"])
239+
thickness_ratio = _safe_ratio(p["t"], p["w"])
231240
configured_truss.members[i].shape = Box(
232-
w=x[idx], h=x[idx] * p["h"] / p["w"], t=x[idx] * p["t"] / p["w"]
241+
w=x[idx],
242+
h=x[idx] * height_ratio,
243+
t=x[idx] * thickness_ratio,
233244
)
234245
idx += 1
235246
elif shape_name == "bar":
236247
configured_truss.members[i].shape = Bar(r=x[idx])
237248
idx += 1
238249
elif shape_name == "square":
250+
height_ratio = _safe_ratio(p["h"], p["w"])
239251
configured_truss.members[i].shape = Square(
240-
w=x[idx], h=x[idx] * p["h"] / p["w"]
252+
w=x[idx], h=x[idx] * height_ratio
241253
)
242254
idx += 1
243255

‎trussme/visualize.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ def plot_truss(
5252
ax.axis("equal")
5353
ax.set_axis_off()
5454

55-
scaler: float = numpy.max(numpy.abs([member.force for member in truss.members]))
55+
scaler = max((abs(member.force) for member in truss.members), default=0.0)
56+
if scaler == 0.0:
57+
scaler = 1.0
5658

5759
force_colormap = matplotlib.colors.LinearSegmentedColormap.from_list(
5860
"force",

0 commit comments

Comments
 (0)