77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253 | @define(slots=False)
class DynamicNumber(NumberDunder, Number):
"""
Simulate on time domain a progressive second order system
### Sources:
- Control System classes on my university which I got 6/10 final grade but survived
- https://www.youtube.com/watch?v=KPoeNZZ6H4s <- Math mostly took from here, thanks @t3ssel8r
- https://en.wikipedia.org/wiki/Semi-implicit_Euler_method
This is a Python-port of the video's math, with custom implementation and extras
"""
# # Base system values
def _ensure_numpy(self, value) -> numpy.ndarray:
if isinstance(value, numpy.ndarray):
return value
return numpy.array(value, dtype=getattr(value, "dtype", self.dtype))
def _ensure_numpy_setattr(self, attribute, value) -> numpy.ndarray:
return self._ensure_numpy(value)
value: DynType = field(default=0, on_setattr=_ensure_numpy_setattr)
"""The current value of the system. Prefer explicitly using it over the object itself"""
target: DynType = field(default=0, on_setattr=_ensure_numpy_setattr)
"""The target value the system is trying to reach, modeled by the parameters"""
dtype: numpy.dtype = field(default=numpy.float64)
"""Data type of the NumPy vectorized data"""
initial: DynType = field(default=None)
"""Initial value of the system, defaults to first value set"""
def __attrs_post_init__(self):
self.set(self.target or self.value)
def set(self, value: DynType, *, instant: bool=True) -> None:
value = self._ensure_numpy(value)
self.value = deepcopy(value) if (instant) else self.value
self.target = deepcopy(value)
self.initial = deepcopy(value)
self.previous = deepcopy(value) if (instant) else self.previous
zeros = numpy.zeros_like(value)
self.integral = deepcopy(zeros)
self.derivative = deepcopy(zeros)
self.acceleration = deepcopy(zeros)
def reset(self, instant: bool=False):
self.set(self.initial, instant=instant)
# # Dynamics system parameters
frequency: float = 1.0
"""Natural frequency of the system in Hertz, "the speed the system responds to a change in input".
Also, the frequency it tends to vibrate at, doesn't affect shape of the resulting motion"""
zeta: float = 1.0
"""Damping coefficient, z=0 vibration never dies, z=1 is the critical limit where the system
does not overshoot, z>1 increases this effect and the system takes longer to settle"""
response: float = 0.0
"""Defines the initial response "time" of the system, when r=1 the system responds instantly
to changes on the input, when r=0 the system takes a bit to respond (smoothstep like), when r<0
the system "anticipates" motion"""
precision: float = 1e-6
"""If `max(target - value) < precision`, the system stops updating to save computation"""
# # Auxiliary intrinsic variables
integral: DynType = 0.0
"""Integral of the system, the sum of all values over time"""
integrate: bool = False
"""Whether to integrate the system's value over time"""
derivative: DynType = 0.0
"""Derivative of the system, the rate of change of the value in ($unit/second)"""
acceleration: DynType = 0.0
"""Acceleration of the system, the rate of change of the derivative in ($unit/second^2)"""
previous: DynType = 0.0
"""Previous target value"""
@property
def instant(self) -> bool:
"""Update the system immediately to the target value"""
return (self.frequency >= INSTANT_FREQUENCY)
@property
def k1(self) -> float:
"""Y velocity coefficient"""
return self.zeta / (pi * self.frequency)
@property
def k2(self) -> float:
"""Y acceleration coefficient"""
return 1.0 / (self.radians*self.radians)
@property
def k3(self) -> float:
"""X velocity coefficient"""
return (self.response * self.zeta) / (tau * self.frequency)
@property
def radians(self) -> float:
"""Natural resonance frequency in radians per second"""
return (tau * self.frequency)
@property
def damping(self) -> float:
"""Damping ratio of some sort"""
return self.radians * (abs(self.zeta*self.zeta - 1.0))**0.5
def next(self, target: Optional[DynType]=None, dt: float=1.0) -> DynType:
"""
Update the system to the next time step, optionally with a new target value
# Fixme: There is a HUGE potential for speed gains if we don't create many temporary ndarray
Args:
target: Next target value to reach, None for previous
dt: Time delta since last update
Returns:
The system's self.value
"""
if (not dt):
return self.value
# Update target and recreate if necessary
if (target is not None):
self.target = self._ensure_numpy(target)
if (self.target.shape != self.value.shape):
self.set(target)
# Todo: instant mode
# Optimization: Do not compute if within precision to target
if (numpy.abs(self.target - self.value).max() < self.precision):
if (self.integrate):
self.integral += (self.value * dt)
return self.value
# "Estimate velocity"
velocity = (self.target - self.previous)/dt
self.previous = self.target
# "Clamp k2 to stable values without jitter"
if (self.radians*dt < self.zeta):
k1 = self.k1
k2 = max(k1*dt, self.k2, 0.5*(k1+dt)*dt)
# "Use pole matching when the system is very fast"
else:
t1 = math.exp(-1 * self.zeta * self.radians * dt)
a1 = 2 * t1 * (math.cos if self.zeta <= 1 else math.cosh)(self.damping*dt)
t2 = 1/(1 + t1*t1 - a1) * dt
k1 = t2 * (1 - t1*t1)
k2 = t2 * dt
# Integrate values
self.value += (self.derivative * dt)
self.acceleration = (self.target + self.k3*velocity - self.value - k1*self.derivative)/k2
self.derivative += (self.acceleration * dt)
if (self.integrate):
self.integral += (self.value * dt)
return self.value
@staticmethod
def extract(*objects: Union[Number, Self]) -> tuple[Number]:
"""Extract the values from DynamicNumbers objects or return the same object"""
return tuple(obj.value if isinstance(obj, DynamicNumber) else obj for obj in objects)
|