Skip to content

Commit 63ab589

Browse files
Merge pull request #17 from dnv-opensource/eis
Damping_time correction
2 parents d6f9098 + e2e9b72 commit 63ab589

5 files changed

Lines changed: 248 additions & 11 deletions

File tree

src/py_crane/boom.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -419,10 +419,21 @@ def damping(self, q_factor: float | None = None, damping_time: float | None = No
419419
length = self.length if isnan(self.newlen) else (self.length + self.newlen) / 2
420420
if q_factor is not None:
421421
self.q_factor = q_factor
422-
self._damping_time = sqrt(length / 9.81 * (self.q_factor**2 + 0.25))
422+
if q_factor >= 100: # use approximate formula
423+
self._damping_time = 2 * q_factor / sqrt(9.81 / length)
424+
elif q_factor > 0: # use the exact formula
425+
self._damping_time = sqrt(1 + 4 * q_factor**2) / sqrt(9.81 / length)
426+
else:
427+
raise ValueError(f"The Q-factor shall be >0. Found {q_factor}") from None
423428
elif damping_time is not None: # new damping time. Change q_factor
424-
self.q_factor = sqrt(damping_time**2 * 9.81 / length - 0.25)
425429
self._damping_time = damping_time
430+
w0_tau = sqrt(9.81 / length) * damping_time
431+
if w0_tau >= 200: # use approximation
432+
self.q_factor = 0.5 * w0_tau
433+
elif w0_tau > 1.0:
434+
self.q_factor = 0.5 * sqrt(w0_tau**2 - 1)
435+
else:
436+
raise ValueError(f"Damping time < {sqrt(9.81 / length)} is not allowed. Found {damping_time}") from None
426437
return self._damping_time
427438

428439
def pendulum_instantaneous(self):
@@ -545,7 +556,7 @@ def ivp_fun(
545556
relative to origin
546557
r2 (float): the squared pendulum radius with respect to COM
547558
g (float): gravitational acceleration as ndarray
548-
s_v (ndarray): Velocity of thesuspension
559+
s_v (ndarray): Velocity of the suspension
549560
s_acc (ndarray): Acceleration of the suspension
550561
l0 (float): start length of wire. Used only if wire length changes
551562
dl_dt (float): Optional change of wire length through dt: l(t) = l0 + dl_dt* t
@@ -601,6 +612,7 @@ def ivp_fun(
601612
self.damping(q_factor=self.q_factor) # re-calculate due to new length
602613
self.boom[0] = self.newlen
603614
# print(f"@{self.model.current_time}. rx:{r[0]} * vx:{v[0]}, s_v_x:{s_v[0]}, s_a_x:{s_acc[0]}.")
615+
604616
sol = solve_ivp( # type: ignore
605617
ivp_fun,
606618
t_span=[0, dt],
@@ -623,10 +635,14 @@ def ivp_fun(
623635

624636
if self.additional_checks and self.model.current_time > 0.0: # check for free fall conditions
625637
update_r2_rel_diff(abs(np.dot(position, position) / r2 - 1.0))
638+
626639
if dt >= self._damping_time: # pendulum stops within dt
627640
v = np.array((0, 0, 0), float)
628641
else:
629-
v *= 1 - dt / self._damping_time # see note
642+
if self.q_factor >= 100:
643+
v *= 1 - dt / (self._damping_time / 2)
644+
else:
645+
v *= sqrt(1 - 2 / (self._damping_time / 2) * dt)
630646
# v -= s_v
631647
# print("PEND", position[0], v[0], s_v[0], s_acc[0])
632648

tests/test_animation.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,9 @@ def update(crane: Crane):
110110

111111
_ = FuncAnimation(
112112
fig,
113-
update, # type: ignore ## this is a function!
113+
update,
114114
frames=animate_sequence(crane, seq=((p, -90), (b1, -45))), # yields crane object as frame
115-
init_func=init, # type: ignore ## this is a function!
115+
init_func=init,
116116
interval=1000,
117117
blit=False,
118118
cache_frame_data=False,

tests/test_crane.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,7 @@ def _move_crane(
474474
elif idx == 4: # low frequency sine
475475
check_sin(list(time), x_pos, speed, 0.001, 0.1 * wd, tau=20)
476476
elif idx == 5: # resonant oscillation
477-
check_sin(list(time), x_pos, speed, 0.00005, wd, tau=20)
477+
check_sin(list(time), x_pos, speed, 0.00003, wd, tau=20)
478478
elif idx == 6: # high frequency sine
479479
check_sin(list(time), x_pos, speed, 0.00005, 10 * wd, tau=20)
480480
elif idx == 7:
@@ -604,7 +604,7 @@ def _circular(
604604
wd = np.sqrt(9.81 / 1.0 - gamma**2)
605605

606606
##?? Deactivated tests to be checked and updated
607-
_b2 = (2**2 / 9.81) ** 2
607+
_b2: float = (2**2 / 9.81) ** 2
608608
stable = np.degrees(np.arccos(np.sqrt(np.sqrt(_b2 + _b2**2 / 4) - _b2 / 2)))
609609
_move_crane(te=50, v0=0.1, c_pos=None, tau=20, show=True, idx=0)
610610
_move_crane(v0=0.0, c_pos=lambda t: 0.1 * t, tau=10, te=20.0, show=True, idx=1)
@@ -1023,7 +1023,7 @@ def test_force_torque(crane: Crane, show: bool = False):
10231023
retcode = pytest.main(["-rA", "-v", "--rootdir", "../", "--show", "False", __file__])
10241024
assert retcode == 0, f"Non-zero return code {retcode}"
10251025
logging.basicConfig(level=logging.DEBUG)
1026-
plt.set_loglevel(level="warning")
1026+
plt.set_loglevel(level="WARNING")
10271027
parsolog = logging.getLogger("parso")
10281028
parsolog.setLevel(logging.WARNING)
10291029
pillog = logging.getLogger("PIL")

tests/test_crane_configurations.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,5 +215,5 @@ def test_knuckle_boom_crane(show: bool = False):
215215
pillog = logging.getLogger("PIL")
216216
pillog.setLevel(logging.WARNING)
217217

218-
test_mobile_crane(show=True)
219-
# test_knuckle_boom_crane(show=True)
218+
# test_mobile_crane(show=True)
219+
test_knuckle_boom_crane(show=True)

tests/test_pendulum_damping.py

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
"""
2+
test_pendulum_damping.py
3+
4+
Physics ground-truth test for Wire pendulum damping in py_crane.
5+
6+
Validates that a freely swinging pendulum (zero crane acceleration) damps its
7+
amplitude at the rate predicted by the Q-factor definition:
8+
9+
t_half = 2 * Q * ln(2) / omega_n
10+
11+
For default parameters (L=10 m, Q=50), amplitude should halve after ~70 simulation
12+
steps at dt=1.0 s. The buggy implementation (boom.py line 629 pre-fix) produces
13+
half-amplitude at ~35 steps — a factor-of-2 error that this test detects.
14+
"""
15+
16+
import logging
17+
import math
18+
19+
import matplotlib.pyplot as plt
20+
import numpy as np
21+
import pytest
22+
23+
from py_crane.boom import Wire
24+
from py_crane.crane import Crane
25+
26+
logger = logging.getLogger(__name__)
27+
logger.setLevel(logging.INFO)
28+
np.set_printoptions(precision=4, suppress=True)
29+
30+
31+
def build_test_crane(length: float = 10.0, q_factor: float = 50.0) -> Crane:
32+
"""Build a minimal crane with a single pendulum wire for physics testing."""
33+
crane = Crane()
34+
crane.add_boom(
35+
"pedestal",
36+
description="Fixed support",
37+
mass=100.0,
38+
boom=(length, 0.0, 0.0),
39+
)
40+
crane.add_boom(
41+
"wire",
42+
description="Pendulum wire under test",
43+
mass=1.0,
44+
mass_center=1.0,
45+
boom=(length, np.pi, 0.0),
46+
q_factor=q_factor,
47+
)
48+
crane.calc_statics_dynamics(None)
49+
return crane
50+
51+
52+
@pytest.mark.parametrize(
53+
"length,q_factor",
54+
[
55+
(10.0, 50.0), # default crane parameters
56+
(5.0, 30.0), # shorter wire, lower Q
57+
(20.0, 80.0), # longer wire, higher Q
58+
],
59+
)
60+
def test_free_decay_half_amplitude(length: float, q_factor: float) -> None:
61+
"""
62+
Free-decay test: pendulum with zero crane acceleration should halve its
63+
angular velocity amplitude in t_half = 2*Q*ln2/omega_n seconds.
64+
65+
This test is the physics ground truth for boom.py line 629. It catches the
66+
known bug where `v *= 1 - dt/damping_time` was used instead of
67+
`v *= exp(-dt / (2 * damping_time))`, which caused the pendulum to damp
68+
approximately 2x faster than the Q-factor definition requires.
69+
70+
Parameters
71+
----------
72+
length : float
73+
Wire length in metres.
74+
q_factor : float
75+
Quality factor Q (energy stored / energy lost per radian).
76+
"""
77+
# --- Setup ---
78+
dt = 1.0 # simulation timestep (seconds)
79+
g = 9.81 # gravity (m/s²)
80+
start_speed = 1.0 # initial load velocity (m/s) -> theta_dot = start_speed / length
81+
82+
crane = build_test_crane(length=length, q_factor=q_factor)
83+
wire = crane.boom_by_name("wire")
84+
assert isinstance(wire, Wire), "Not only Boom"
85+
86+
# Kick the pendulum: set initial load velocity, leave crane stationary
87+
wire.cm_v[0] = start_speed
88+
crane.d_velocity = np.zeros(3) # zero crane acceleration throughout
89+
90+
# --- Analytically derived expected half-amplitude step ---
91+
omega_n = math.sqrt(g / length)
92+
# gamma = omega_n / (2*Q) is the amplitude decay rate
93+
# t_half = ln(2) / gamma = 2*Q*ln(2) / omega_n
94+
t_half_expected = 2.0 * q_factor * math.log(2.0) / omega_n
95+
tolerance = 0.15 # ±15% — wide enough for discrete peak-detection error (~5%),
96+
# narrow enough to catch the factor-of-2 bug (~100% off)
97+
98+
# Run for 3× the expected half-amplitude time to ensure we observe the crossing
99+
n_steps = int(3 * t_half_expected / dt) + 1
100+
101+
# --- Simulate free decay ---
102+
theta_dot_peaks: list[tuple[int, float]] = []
103+
theta_dots: list[float] = []
104+
105+
for step in range(n_steps):
106+
crane.do_step(step * dt, dt)
107+
theta_dot = abs(wire.cm_v[0]) / wire.length
108+
theta_dots.append(theta_dot)
109+
110+
# Detect local maxima (oscillation peaks)
111+
if step >= 2:
112+
prev, curr, _ = theta_dots[-3], theta_dots[-2], theta_dots[-1]
113+
if curr >= prev and curr >= theta_dots[-1]:
114+
theta_dot_peaks.append((step - 1, curr))
115+
116+
assert len(theta_dot_peaks) >= 3, (
117+
f"Too few oscillation peaks detected ({len(theta_dot_peaks)}) — "
118+
f"check that the pendulum is actually oscillating. "
119+
f"L={length}, Q={q_factor}"
120+
)
121+
122+
# --- Find step at which peak amplitude first drops below half of initial peak ---
123+
initial_peak_amplitude = theta_dot_peaks[0][1]
124+
half_amplitude = initial_peak_amplitude / 2.0
125+
126+
half_amplitude_step = None
127+
for step_i, peak_v in theta_dot_peaks:
128+
if peak_v < half_amplitude:
129+
half_amplitude_step = step_i
130+
break
131+
132+
assert half_amplitude_step is not None, (
133+
f"Amplitude never halved within {n_steps} steps. "
134+
f"L={length}, Q={q_factor}, expected at step ~{t_half_expected:.0f}"
135+
)
136+
137+
# --- Assert within tolerance of analytical prediction ---
138+
lower = t_half_expected * (1.0 - tolerance)
139+
upper = t_half_expected * (1.0 + tolerance)
140+
141+
assert lower <= half_amplitude_step <= upper, (
142+
f"Pendulum amplitude halved at step {half_amplitude_step}, "
143+
f"but expected between {lower:.0f} and {upper:.0f} steps "
144+
f"(analytical: {t_half_expected:.1f} steps = 2*Q*ln2/omega_n). "
145+
f"L={length}, Q={q_factor}. "
146+
f"If half-amplitude step is ~{t_half_expected / 2:.0f}, "
147+
f"boom.py line 629 is using the energy decay constant instead of the "
148+
f"amplitude decay constant — see bug report."
149+
)
150+
151+
152+
def test_free_decay_default_parameters(*, show: bool) -> None:
153+
"""
154+
Regression test for default crane parameters (L=10 m, Q=50).
155+
156+
For these parameters, amplitude should halve after approximately 70 simulation
157+
steps (dt=1.0 s). This is the exact configuration in which the original bug
158+
was detected — the buggy code produced half-amplitude at step ~35.
159+
160+
Empirically measured (correct physics): step 67.
161+
Analytical prediction: 70.0 steps.
162+
"""
163+
# This is a dedicated regression test for the specific configuration
164+
# used in crane-controller's reward_comparison.md — do not change parameters.
165+
L = 10.0
166+
Q = 50.0 # 50.0
167+
dt = 1.0
168+
g = 9.81
169+
170+
crane = build_test_crane(length=L, q_factor=Q)
171+
wire = crane.boom_by_name("wire")
172+
assert isinstance(wire, Wire), "Not only Boom"
173+
wire.cm_v[0] = 1.0 # 1 m/s initial load velocity
174+
crane.d_velocity = np.zeros(3)
175+
176+
theta_dot_peaks: list[tuple[int, float]] = []
177+
theta_dots: list[float] = []
178+
times: list[float] = [0.0]
179+
speeds: list[float] = [wire.cm_v[0]]
180+
181+
for step in range(210): # 3× expected half-amplitude time of 70 steps
182+
crane.do_step(step * dt, dt)
183+
times.append(step * dt)
184+
speeds.append(wire.cm_v[0])
185+
theta_dot = abs(wire.cm_v[0]) / wire.length
186+
theta_dots.append(theta_dot)
187+
if step >= 2:
188+
prev, curr = theta_dots[-3], theta_dots[-2]
189+
if curr >= prev and curr >= theta_dots[-1]:
190+
theta_dot_peaks.append((step - 1, curr))
191+
192+
if show:
193+
_, ax = plt.subplots(1)
194+
ax.plot(times, speeds, label="speed")
195+
ax.plot(times, [np.exp(-t / wire.damping_time) for t in times], label="damping")
196+
plt.title(f"Damping time: {wire.damping_time}, Q: {wire.q_factor}")
197+
plt.show()
198+
199+
initial_peak = theta_dot_peaks[0][1]
200+
half_amplitude_step = next((s for s, v in theta_dot_peaks if v < initial_peak / 2), None)
201+
202+
omega_n = math.sqrt(g / L)
203+
expected = 2.0 * Q * math.log(2.0) / omega_n # 70.0 steps
204+
205+
assert half_amplitude_step is not None, "Amplitude never halved — check damping implementation"
206+
207+
assert abs(half_amplitude_step - expected) / expected < 0.15, (
208+
f"Half-amplitude at step {half_amplitude_step}, expected ~{expected:.0f} "
209+
f"(±15%). "
210+
f"Buggy boom.py produces ~35 steps (energy constant used on amplitude). "
211+
f"Correct implementation produces ~70 steps (amplitude constant exp(-dt/(2*tau)))."
212+
)
213+
214+
215+
if __name__ == "__main__":
216+
retcode = pytest.main(["-rA", "-v", "--rootdir", "../", "--show", "False", __file__])
217+
assert retcode == 0, f"Non-zero return code {retcode}"
218+
test_free_decay_default_parameters(show=True)
219+
test_free_decay_half_amplitude(10, 50) # default crane parameters
220+
test_free_decay_half_amplitude(5.0, 30.0) # shorter wire, lower Q
221+
test_free_decay_half_amplitude(20.0, 80.0) # longer wire, higher Q

0 commit comments

Comments
 (0)