Skip to content

Commit 872c59c

Browse files
Add test for linearTimeInvariantSystem
1 parent f07398b commit 872c59c

File tree

1 file changed

+263
-0
lines changed

1 file changed

+263
-0
lines changed
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
#
2+
# ISC License
3+
#
4+
# Copyright (c) 2025, Autonomous Vehicle Systems Lab, University of Colorado at Boulder
5+
#
6+
# Permission to use, copy, modify, and/or distribute this software for any
7+
# purpose with or without fee is hereby granted, provided that the above
8+
# copyright notice and this permission notice appear in all copies.
9+
#
10+
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
11+
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
12+
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
13+
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
14+
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
15+
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
16+
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
17+
import pytest
18+
import numpy as np
19+
import numpy.testing as npt
20+
import matplotlib.pyplot as plt
21+
22+
from Basilisk.utilities import SimulationBaseClass
23+
from Basilisk.architecture import messaging
24+
from Basilisk.utilities import macros
25+
26+
try:
27+
from Basilisk.simulation import mujoco
28+
from Basilisk.simulation import MJLinearTimeInvariantSystem
29+
couldImportMujoco = True
30+
except Exception:
31+
couldImportMujoco = False
32+
33+
34+
@pytest.mark.skipif(not couldImportMujoco, reason="Compiled Basilisk without --mujoco")
35+
@pytest.mark.parametrize("usePythonSubclass", [False, True])
36+
def test_linearTimeInvariantSystemFirstOrder(usePythonSubclass: bool,
37+
showPlots: bool = False):
38+
"""
39+
Unit test for LinearTimeInvariantSystem:
40+
41+
- When usePythonSubclass == False:
42+
Uses the C++ SingleActuatorLTI subclass.
43+
44+
- When usePythonSubclass == True:
45+
Uses a Python subclass of LinearTimeInvariantSystem that overrides
46+
readInput and writeOutput via directors.
47+
48+
Both implement the same first order system:
49+
50+
x_dot = -a x + a u, y = x, u = 1 (constant step)
51+
52+
so that in continuous time:
53+
54+
x(t) = 1 - exp(-a t)
55+
"""
56+
# Simulation setup
57+
dt = 0.01 # s
58+
tf = 2.0 # s
59+
60+
# First order dynamics parameter
61+
a = 1.0 # 1/s; time constant tau = 1/a = 1 s
62+
63+
scSim = SimulationBaseClass.SimBaseClass()
64+
dynProcess = scSim.CreateNewProcess("test")
65+
dynProcess.addTask(scSim.CreateNewTask("test", macros.sec2nano(dt)))
66+
67+
# Empty MuJoCo scene: just a container for system models
68+
scene = mujoco.MJScene("<mujoco/>")
69+
scSim.AddModelToTask("test", scene)
70+
71+
# Constant input message u = 1.0
72+
cmdMsg = messaging.SingleActuatorMsg()
73+
cmdPayload = messaging.SingleActuatorMsgPayload()
74+
cmdPayload.input = 1.0
75+
cmdMsg.write(cmdPayload)
76+
77+
if usePythonSubclass:
78+
# Python subclass of LinearTimeInvariantSystem
79+
class PyFirstOrderLTI(MJLinearTimeInvariantSystem.LinearTimeInvariantSystem):
80+
def __init__(self):
81+
super().__init__()
82+
# Input/output messaging
83+
self.inMsg = messaging.SingleActuatorMsgReader()
84+
self.outMsg = messaging.SingleActuatorMsg()
85+
86+
def getInputSize(self) -> int:
87+
return 1
88+
89+
def getOutputSize(self) -> int:
90+
return 1
91+
92+
def readInput(self, CurrentSimNanos):
93+
# Return 1x1 vector from SingleActuatorMsg
94+
payload = self.inMsg()
95+
return np.array([[payload.input]])
96+
97+
def writeOutput(self, CurrentSimNanos, y):
98+
# Write y[0] to SingleActuatorMsg
99+
payload = messaging.SingleActuatorMsgPayload()
100+
payload.input = y[0][0]
101+
self.outMsg.write(payload, self.moduleID, CurrentSimNanos)
102+
103+
system = PyFirstOrderLTI()
104+
105+
else:
106+
# C++ subclass: SingleActuatorLTI
107+
system = MJLinearTimeInvariantSystem.SingleActuatorLTI()
108+
109+
# First order system: x_dot = -a x + a u, y = x
110+
A = np.array([[-a]])
111+
B = np.array([[a]])
112+
C = np.array([[1.0]])
113+
D = np.array([[0.0]])
114+
system.setA(A)
115+
system.setB(B)
116+
system.setC(C)
117+
system.setD(D)
118+
system.inMsg.subscribeTo(cmdMsg)
119+
120+
# Add system to the scene dynamics
121+
scene.AddModelToDynamicsTask(system)
122+
123+
# Recorder for the system output
124+
outRecorder = system.outMsg.recorder()
125+
scSim.AddModelToTask("test", outRecorder)
126+
127+
# Initialize and run
128+
scSim.InitializeSimulation()
129+
130+
# Basic size checks use the LinearTimeInvariantSystem API
131+
assert system.getInputSize() == 1
132+
assert system.getOutputSize() == 1
133+
assert system.getStateSize() == 1
134+
135+
scSim.ConfigureStopTime(macros.sec2nano(tf))
136+
scSim.ExecuteSimulation()
137+
138+
# Extract data
139+
tNanos = outRecorder.times()
140+
yVals = np.asarray(outRecorder.input) # SingleActuatorMsgPayload.input
141+
142+
# Convert times to seconds for plotting / diagnostics
143+
t = tNanos * 1.0e-9
144+
145+
if showPlots:
146+
fig, ax = plt.subplots()
147+
ax.plot(t, yVals, label="y(t)")
148+
ax.set_xlabel("Time [s]")
149+
ax.set_ylabel("Output y")
150+
ax.grid(True)
151+
ax.legend()
152+
plt.show()
153+
154+
# Continuous time target: x(t) = 1 - exp(-a t); y = x
155+
yTargetFinal = 1.0 - np.exp(-a * tf)
156+
157+
# Use the last sample
158+
yFinal = float(yVals[-1])
159+
160+
# Assert that final value is reasonably close to the continuous solution
161+
# Tolerance is relaxed a bit to allow for integration and discretization error
162+
npt.assert_allclose(yFinal, yTargetFinal, rtol=0.02, atol=1e-2)
163+
164+
@pytest.mark.skipif(not couldImportMujoco, reason="Compiled Basilisk without --mujoco")
165+
def test_linearTimeInvariantSystemSecondOrder(showPlots: bool = False):
166+
"""
167+
Unit test for an LTI model configured as a second-order system.
168+
169+
This test sets up a critically damped second-order linear time-invariant (LTI) system
170+
with natural frequency wn = 10 rad/s and damping ratio zeta = 1.0. The system is driven
171+
by a constant unit step input (u = 1.0). The test verifies:
172+
173+
1. The final output value approaches 1.0 (steady-state response).
174+
2. The output does not overshoot beyond a small numerical margin (no overshoot for critical damping).
175+
3. The output time history closely matches the analytic step response for a critically damped system:
176+
y(t) = 1 - exp(-wn * t) * (1 + wn * t)
177+
"""
178+
# Simulation setup
179+
dt = 0.01 # s
180+
tf = 2.0 # s
181+
182+
scSim = SimulationBaseClass.SimBaseClass()
183+
dynProcess = scSim.CreateNewProcess("test")
184+
dynProcess.addTask(scSim.CreateNewTask("test", macros.sec2nano(dt)))
185+
186+
# Empty MuJoCo scene: just a container for system models
187+
scene = mujoco.MJScene("<mujoco/>")
188+
scSim.AddModelToTask("test", scene)
189+
190+
# Constant input message u = 1.0
191+
cmdMsg = messaging.SingleActuatorMsg()
192+
cmdPayload = messaging.SingleActuatorMsgPayload()
193+
cmdPayload.input = 1.0
194+
cmdMsg.write(cmdPayload)
195+
196+
# Second order critically damped LTI: wn = 10 rad/s, zeta = 1, k = 1
197+
wn = 10.0
198+
zeta = 1.0
199+
200+
system = MJLinearTimeInvariantSystem.SingleActuatorLTI()
201+
system.configureSecondOrder(wn, zeta) # default k = 1
202+
system.inMsg.subscribeTo(cmdMsg)
203+
204+
# Add system to the scene dynamics
205+
scene.AddModelToDynamicsTask(system)
206+
207+
# Recorder for the system output
208+
outRecorder = system.outMsg.recorder()
209+
scSim.AddModelToTask("test", outRecorder)
210+
211+
# Initialize and run
212+
scSim.InitializeSimulation()
213+
214+
# Basic size checks use the LinearTimeInvariantSystem API
215+
assert system.getInputSize() == 1
216+
assert system.getOutputSize() == 1
217+
assert system.getStateSize() == 2
218+
219+
scSim.ConfigureStopTime(macros.sec2nano(tf))
220+
scSim.ExecuteSimulation()
221+
222+
# Extract data
223+
tNanos = outRecorder.times()
224+
yVals = np.asarray(outRecorder.input) # SingleActuatorMsgPayload.input
225+
226+
# Convert times to seconds for plotting / diagnostics
227+
t = tNanos * 1.0e-9
228+
229+
if showPlots:
230+
fig, ax = plt.subplots()
231+
ax.plot(t, cmdPayload.input * np.ones_like(t), "--", label="Input")
232+
ax.plot(t, yVals, label="Output")
233+
ax.set_xlabel("Time [s]")
234+
ax.set_ylabel("y(t) [-]")
235+
ax.grid(True)
236+
ax.legend()
237+
plt.show()
238+
239+
# Analytic critically damped step response for:
240+
# G(s) = wn^2 / (s^2 + 2*wn*s + wn^2), unit step input
241+
# y(t) = 1 - exp(-wn t) * (1 + wn t)
242+
yAnalytic = 1.0 - np.exp(-wn * t) * (1.0 + wn * t)
243+
244+
# 1) Final value near 1.0
245+
npt.assert_allclose(yVals[-1], 1.0, atol=0.005)
246+
247+
# 2) No overshoot beyond a small numerical margin
248+
assert np.max(yVals) <= 1.01
249+
250+
# 3) Shape close to analytic critically damped response
251+
npt.assert_allclose(
252+
yVals,
253+
yAnalytic,
254+
rtol=0.1,
255+
atol=0.05
256+
)
257+
258+
259+
if __name__ == "__main__":
260+
assert couldImportMujoco
261+
test_linearTimeInvariantSystemFirstOrder(usePythonSubclass=False, showPlots=True)
262+
test_linearTimeInvariantSystemFirstOrder(usePythonSubclass=True, showPlots=True)
263+
test_linearTimeInvariantSystemSecondOrder(showPlots=True)

0 commit comments

Comments
 (0)