Coverage for D:\Ralf Gerlich\git\modypy\modypy\simulation.py : 0%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""
2Provide classes for simulation.
3"""
4import warnings
6import numpy as np
7import scipy.integrate
8import scipy.optimize
10from modypy.model import State, InputSignal, SystemState
11from modypy.model.events import ClockQueue
12from modypy.model.system import System
14INITIAL_RESULT_SIZE = 16
15RESULT_SIZE_EXTENSION = 16
17DEFAULT_INTEGRATOR = scipy.integrate.DOP853
20class SimulationError(RuntimeError):
21 """Exception raised when an error occurs during simulation"""
24class IntegrationError(SimulationError):
25 """Exception raised when an error is reported by the integrator"""
28class ExcessiveEventError(SimulationError):
29 """
30 Exception raised when an excessive number of successive events occurs.
31 """
34class SimulationResult:
35 """The results provided by a simulation.
37 A `SimulationResult` object captures the time series provided by a
38 simulation. It has properties `t`, `state` and `inputs` representing the
39 time, state vector and inputs vector for each individual sample.
40 """
42 def __init__(self, system: System, source=None):
43 self.system = system
44 self._t = np.empty(INITIAL_RESULT_SIZE)
45 self._inputs = np.empty((self.system.num_inputs, INITIAL_RESULT_SIZE))
46 self._state = np.empty((self.system.num_states, INITIAL_RESULT_SIZE))
48 self.current_idx = 0
50 if source is not None:
51 self.collect_from(source)
53 @property
54 def time(self):
55 """The time vector of the simulation result"""
56 return self._t[0:self.current_idx]
58 @property
59 def inputs(self):
60 """The input vector of the simulation result"""
61 return self._inputs[:, 0:self.current_idx]
63 @property
64 def state(self):
65 """The state vector of the simulation result"""
66 return self._state[:, 0:self.current_idx]
68 def collect_from(self, source):
69 """Collect data points from the given source
71 The source must be an iterable providing a series system states
72 representing the system states at the individual time points"""
74 for state in source:
75 self.append(state)
77 def append(self, system_state):
78 """Append an entry to the result vectors.
80 Args:
81 system_state: The system state to append
82 """
83 self._append(system_state.time, system_state.inputs, system_state.state)
85 def _append(self, time, inputs, state):
86 """Append an entry to the result vectors.
88 Args:
89 time: The time tag for the entry
90 inputs: The input vector
91 state: The state vector
92 """
94 if self.current_idx >= self._t.size:
95 self.extend_space()
96 self._t[self.current_idx] = time
97 self._inputs[:, self.current_idx] = inputs
98 self._state[:, self.current_idx] = state
100 self.current_idx += 1
102 def extend_space(self):
103 """Extend the storage space for the vectors"""
104 self._t = np.r_[self._t,
105 np.empty(RESULT_SIZE_EXTENSION)]
106 self._inputs = np.c_[self._inputs,
107 np.empty((self.system.num_inputs,
108 RESULT_SIZE_EXTENSION))]
109 self._state = np.c_[self._state,
110 np.empty((self.system.num_states,
111 RESULT_SIZE_EXTENSION))]
113 def get_state_value(self, state: State):
114 """Determine the value of the given state in this result object"""
116 return self.state[state.state_slice].reshape(state.shape + (-1,))
118 def get_input_value(self, signal: InputSignal):
119 """Determine the value of the given input in this result object"""
121 return self.inputs[signal.input_slice].reshape(signal.shape + (-1,))
123 def __getitem__(self, key):
124 warnings.warn("The dictionary access interface is deprecated",
125 DeprecationWarning)
126 if isinstance(key, tuple):
127 # In case of a tuple, the first entity is the actual object to
128 # access and the remainder is the index into the object
129 obj = key[0]
130 idx = key[1:]
131 value = obj(self)
132 if len(idx) > 1:
133 return value[idx]
134 return value[idx[0]]
135 # Otherwise, the item is an object to access, and we simply defer to
136 # the callable interface
137 return key(self)
140class Simulator:
141 """Simulator for dynamic systems.
143 The simulator is written with the interface of
144 `scipy.integrate.OdeSolver` in mind for the solver, specifically using
145 the constructor, the `step` and the `dense_output` functions as well as
146 the `status` property of the return value. However, it is possible to
147 use other integrators if they honor this interface.
149 Args:
150 system:
151 The system to be simulated
152 start_time:
153 The start time of the simulation (optional, default=0)
154 initial_condition:
155 The initial condition (optional, overrides initial condition
156 specified in the states)
157 event_xtol:
158 The absolute tolerance for identifying the time of a
159 zero-crossing event.
160 event_maxiter:
161 The maximum number of iterations for identifying the time of a
162 zero-crossing event.
163 solver:
164 The solver to be used for integrating continuous-time systems.
165 The default is the :class:`DOP853 <scipy.integrate.DOP853>`
166 solver.
167 solver_options:
168 Options to be passed to the solver constructor.
169 """
171 def __init__(self,
172 system: System,
173 start_time=0,
174 initial_condition=None,
175 max_successive_event_count=1000,
176 event_xtol=1.E-12,
177 event_maxiter=1000,
178 solver_method=DEFAULT_INTEGRATOR,
179 **solver_options):
180 """Construct a simulator for the system."""
182 # Store the parameters
183 self.system = system
184 self.max_successive_event_count = max_successive_event_count
185 self.event_xtol = event_xtol
186 self.event_maxiter = event_maxiter
187 self.solver_method = solver_method
188 self.solver_options = solver_options
190 # Initialize the simulation state
191 self.current_time = start_time
192 if initial_condition is not None:
193 self.current_state = initial_condition
194 else:
195 self.current_state = self.system.initial_condition
196 self.current_inputs = self.system.initial_input
198 # Reset the count of successive events
199 self.successive_event_count = 0
201 # Register event tolerances and directions for easier access
202 self.event_tolerances = np.array([event.tolerance
203 for event in self.system.events])
204 self.event_directions = np.array([event.direction
205 for event in self.system.events])
207 # Check if we have continuous-time states
208 self.have_continuous_time_states = any(
209 state.derivative_function is not None
210 for state in self.system.states
211 )
213 # Create the clock queue
214 self.clock_queue = ClockQueue(start_time=start_time,
215 clocks=self.system.clocks)
217 # The current state is the left-sided limit of the time-dependent state
218 # function at the current time. To proceed, we need the right-sided
219 # limit, which requires us to apply all pending clock tick handlers.
220 self._run_clock_ticks()
222 def run_until(self, time_boundary, include_last=True):
223 """Run the simulation
225 Yields a series of :class:`modypy.model.system.SystemState` objects with
226 each element representing one time sample of the process.
228 Args:
229 time_boundary:
230 The end time of the simulation.
231 include_last:
232 Flag indicating whether the state at the end of simulation shall
233 be yielded as well. In case of multiple calls to `run_until`
234 this should be set to `False`. Otherwise, the last system state
235 provided at the end of one call will be repeated at the
236 beginning of the next call.
238 Raises:
239 SimulationError: if an error occurs during simulation
240 """
242 if self.have_continuous_time_states:
243 yield from self._run_mixed_model_simulation(time_boundary)
244 else:
245 yield from self._run_discrete_model_simulation(time_boundary)
247 if include_last:
248 yield SystemState(system=self.system,
249 time=self.current_time,
250 state=self.current_state,
251 inputs=self.current_inputs)
253 def _run_mixed_model_simulation(self, time_boundary):
254 # The outer loop iterates over solver instances as necessary.
255 # Events leading to state changes will invalidate the solver, so
256 # a new one will have to be created. However, we'll run as long as
257 # possible on a single solver to save instantiation time
259 # Split events into two partitions:
260 # - terminating events
261 # - non-terminating events
262 # We will handle them separately later.
263 terminating_events = [event
264 for event in self.system.events
265 if len(event.listeners) > 0]
266 non_terminating_events = [event
267 for event in self.system.events
268 if len(event.listeners) == 0]
269 terminating_detector = _EventDetector(system=self.system,
270 events=terminating_events)
271 non_terminating_detector = _EventDetector(system=self.system,
272 events=non_terminating_events)
274 while self.current_time < time_boundary:
275 terminated = False
277 # The solver can run to the time boundary or the next clock tick,
278 # whichever comes first.
279 solver_bound = self.clock_queue.next_clock_tick
280 if solver_bound is None or solver_bound > time_boundary:
281 solver_bound = time_boundary
283 # Create the solver
284 solver = self.solver_method(fun=self._state_derivative,
285 t0=self.current_time,
286 y0=self.current_state,
287 t_bound=solver_bound,
288 vectorized=True,
289 **self.solver_options)
291 # Run the integration until the determined time limit
292 while self.current_time < solver_bound and not terminated:
293 # Yield the current state (after running the clock ticks)
294 yield SystemState(system=self.system,
295 time=self.current_time,
296 inputs=self.current_inputs,
297 state=self.current_state)
299 # Perform a solver step
300 msg = solver.step()
301 if msg is not None:
302 raise IntegrationError(msg)
304 # Get interpolation functions for state and inputs
305 state_interpolator = solver.dense_output()
307 def _input_interpolator(_t):
308 return self.current_inputs
310 # Check for occurrence of a terminating event and determine the
311 # time of the earliest terminating event.
312 first_term = terminating_detector.localize_first_event(
313 start_time=self.current_time,
314 end_time=solver.t,
315 state=state_interpolator,
316 inputs=_input_interpolator)
317 search_end_time = solver.t
319 # Restrict the search time for non-terminating events
320 if first_term is not None:
321 assert first_term[0] >= self.current_time
322 search_end_time = first_term[0]
324 # Find non-terminating events
325 non_term_occs = non_terminating_detector.localize_events(
326 start_time=self.current_time,
327 end_time=search_end_time,
328 state=state_interpolator,
329 inputs=_input_interpolator
330 )
332 # Yield intermediate states for non-terminating events in the
333 # order in which they occur
334 non_term_occs.sort(key=lambda v: v[0])
335 for time, event in non_term_occs:
336 event_state = SystemState(system=self.system,
337 time=time,
338 state=state_interpolator(time),
339 inputs=_input_interpolator(time))
340 yield event_state
342 # In case of a terminating event, advance time to the time of
343 # the event and execute its handlers.
344 # Otherwise, advance time to the end of the integration step
345 # and update the state.
346 if first_term is not None:
347 first_term_time, first_term_event = first_term
348 self.current_time = first_term_time
349 self.current_state = state_interpolator(first_term_time)
350 self._run_event_listeners([first_term_event])
351 terminated = True
352 else:
353 # No terminating event occurred
354 self.current_time = solver.t
355 self.current_state = solver.y
356 # Reset the successive event counter
357 self.successive_event_count = 0
359 # The current state is the left-side limit of the state function
360 # over time. However, to properly proceed, we need the right-side
361 # limit, so we execute all pending clock ticks now.
362 self._run_clock_ticks()
364 def _run_discrete_model_simulation(self, time_boundary):
365 # For discrete-only systems we only need to run the clocks and advance
366 # the time accordingly until we reach the time boundary.
367 while self.current_time < time_boundary:
368 # Yield the current state
369 yield SystemState(system=self.system,
370 time=self.current_time,
371 inputs=self.current_inputs,
372 state=self.current_state)
374 # Advance time to the next clock tick
375 next_clock_tick = self.clock_queue.next_clock_tick
376 if next_clock_tick is None or next_clock_tick > time_boundary:
377 self.current_time = time_boundary
378 else:
379 self.current_time = next_clock_tick
381 # The current state is the left-side limit of the state function
382 # over time. However, to properly proceed, we need the right-side
383 # limit, so we execute all pending clock ticks now.
384 self._run_clock_ticks()
386 def _run_clock_ticks(self):
387 """Run all the pending clock ticks."""
389 # We collect the clocks to tick here and executed all their listeners
390 # later.
391 clocks_to_tick = self.clock_queue.tick(self.current_time)
393 # Run all the event listeners
394 self._run_event_listeners(clocks_to_tick)
396 def _run_event_listeners(self, event_sources):
397 """Run the event listeners on the given events.
398 """
400 while len(event_sources) > 0:
401 # Check for excessive counts of successive events
402 self.successive_event_count += 1
403 if (self.successive_event_count >
404 self.max_successive_event_count):
405 raise ExcessiveEventError()
407 # Prepare the system state for the state updater
408 state_updater = _SystemStateUpdater(system=self.system,
409 time=self.current_time,
410 state=self.current_state,
411 inputs=self.current_inputs)
413 # Determine the values of all event functions before running the
414 # event listeners.
415 last_event_values = self.system.event_values(state_updater)
417 # Collect all listeners associated with the events
418 # Note that we run each listener only once, even if it is associated
419 # with multiple events
420 listeners = set(listener
421 for event_source in event_sources
422 for listener in event_source.listeners)
424 # Run the event listeners
425 # Note that we do not guarantee any specific order of execution
426 # here. Listeners thus must be written in such a way that their
427 # effects are the same independent of the order in which they are
428 # run.
429 for listener in listeners:
430 listener(state_updater)
432 # Update the state
433 self.current_state = state_updater.state
435 # Determine the value of event functions after running the event
436 # listeners
437 new_event_values = self.system.event_values(state_updater)
439 # Determine which events occurred as a result of the changed state
440 event_mask = _find_active_events(start_values=last_event_values,
441 end_values=new_event_values,
442 tolerances=self.event_tolerances,
443 directions=self.event_directions)
444 if any(event_mask):
445 event_sources = [event
446 for event, flag in zip(self.system.events,
447 event_mask)
448 if flag]
449 else:
450 event_sources = []
452 def _state_derivative(self, time, state):
453 """The state derivative function used for integrating the state over
454 time.
456 Args:
457 time: The current time
458 state: The current state vector
460 Returns:
461 The time-derivative of the state vector
462 """
464 system_state = SystemState(system=self.system,
465 time=time,
466 state=state)
467 state_derivative = self.system.state_derivative(system_state)
468 return state_derivative
471class _EventDetector:
472 """Helper class for detecting and localizing events"""
474 def __init__(self, system, events, xtol=1E-12, maxiter=1000):
475 self.system = system
476 self.events = events
477 self.xtol = xtol
478 self.maxiter = maxiter
479 self.event_tolerances = np.array([event.tolerance for event in events])
480 self.event_directions = np.array([event.direction for event in events])
482 def localize_first_event(self, start_time, end_time, state, inputs):
483 """Localize the first event occurring in the given time frame.
485 Args:
486 start_time: The start time of the time frame.
487 end_time: The end time of the time_frame.
488 state:
489 A callable, with `state(t)` being the state vector at time `t`
490 for any scalar or one-dimensional array `t` with
491 `start_time <= t <= end_time`.
492 inputs:
493 A callable, with `state(t)` being the input vector at time `t`
494 for any scalar or one-dimensional array `t` with
495 `start_time <= t <= end_time`.
496 Returns:
497 A tuple `(time, event)`, giving time and event object for the first
498 event having occurred in the given time frame, or `None`, if none of
499 the events has occurred in the time frame.
500 """
501 event_locs = self.localize_events(start_time, end_time, state, inputs)
502 if len(event_locs) > 0:
503 # At least one of the events has occurred, so we find the first one
504 return min(event_locs, key=lambda evt: evt[0])
505 return None
507 def localize_events(self, start_time, end_time, state, inputs):
508 """Localize the all events occurring in the given time frame.
510 Args:
511 start_time: The start time of the time frame.
512 end_time: The end time of the time_frame.
513 state:
514 A callable, with `state(t)` being the state vector at time `t`
515 for any scalar or one-dimensional array `t` with
516 `start_time <= t <= end_time`.
517 inputs:
518 A callable, with `state(t)` being the input vector at time `t`
519 for any scalar or one-dimensional array `t` with
520 `start_time <= t <= end_time`.
521 Returns:
522 A list of tuples `(time, event)`, giving time and event object for
523 each event having occurred in the given time frame.
524 """
525 start_state = SystemState(system=self.system,
526 time=start_time,
527 state=state(start_time),
528 inputs=inputs(start_time))
529 end_state = SystemState(system=self.system,
530 time=end_time,
531 state=state(end_time),
532 inputs=inputs(end_time))
534 # Determine the list of active events
535 active_events = self._get_active_events(start_state, end_state)
537 # For each of the active events, localize the zero-crossing
538 locations = list()
539 for event in active_events:
540 event_time = self._find_event_time(event,
541 start_time,
542 end_time,
543 state,
544 inputs)
545 locations.append((event_time, event))
546 return locations
548 def _get_active_events(self, start_state, end_state):
549 """Determine which events are active in the given time frame.
551 Args:
552 start_state: The state at the beginning of the time frame.
553 end_state: The state at the end of the time frame.
554 Returns:
555 List of events that have occurred in the given time frame.
556 """
557 start_values = np.array([event(start_state) for event in self.events])
558 end_values = np.array([event(end_state) for event in self.events])
560 mask = _find_active_events(start_values,
561 end_values,
562 self.event_tolerances,
563 self.event_directions)
564 return [event for event, flag in zip(self.events, mask) if flag]
566 def _find_event_time(self, event, start_time, end_time, state, inputs):
567 """
568 Find the time when the sign change occurs.
570 Args:
571 start_time: The start time of the time frame.
572 end_time: The end time of the time_frame.
573 state:
574 A callable, with `state(t)` being the state vector at time `t`
575 for any scalar or one-dimensional array `t` with
576 `start_time <= t <= end_time`.
577 inputs:
578 A callable, with `state(t)` being the input vector at time `t`
579 for any scalar or one-dimensional array `t` with
580 `start_time <= t <= end_time`.
581 Returns:
582 A time at or after the sign change occurs
583 """
585 assert start_time <= end_time
587 start_state = SystemState(system=self.system,
588 time=start_time,
589 state=state(start_time),
590 inputs=inputs(start_time))
591 end_state = SystemState(system=self.system,
592 time=end_time,
593 state=state(end_time),
594 inputs=inputs(end_time))
596 start_value = event(start_state)
597 end_value = event(end_state)
599 assert (((start_value < -event.tolerance) ^
600 (end_value < -event.tolerance)) |
601 ((event.tolerance < start_value) ^
602 (event.tolerance < end_value)))
604 iter_count = 0
605 time_diff = end_time - start_time
606 while iter_count < self.maxiter and time_diff > self.xtol:
607 time_diff /= 2
608 mid_time = start_time + time_diff
609 mid_state = SystemState(system=self.system,
610 time=mid_time,
611 state=state(mid_time),
612 inputs=inputs(mid_time))
613 mid_value = event(mid_state)
614 mid_value = 0 if np.abs(mid_value) < event.tolerance else mid_value
615 if np.sign(mid_value) == np.sign(start_value):
616 # The sign change happens after mid_time
617 start_time = mid_time
618 iter_count += 1
619 return start_time
622class _SystemStateUpdater(SystemState):
623 """A ``_SystemStateUpdater`` is a system state in which the states can be
624 updated"""
626 def __init__(self, time, system: System, state=None, inputs=None):
627 super().__init__(time, system, state, inputs)
628 # Make a copy of the state
629 self.state = self.state.copy()
631 def set_state_value(self, state: State, value):
632 """Update the value of the given state"""
634 self.state[state.state_slice] = np.asarray(value).ravel()
636 def __setitem__(self, key, value):
637 warnings.warn("The dictionary access interface is deprecated",
638 DeprecationWarning)
639 if isinstance(key, tuple):
640 # In case the key is a tuple, its first element is the object to
641 # access, and the remainder is the index of the element to address
642 obj = key[0]
643 idx = key[1:]
644 if len(idx) > 1:
645 obj(self)[idx] = value
646 else:
647 obj(self)[idx[0]] = value
648 else:
649 # Otherwise, we'll fall back to the set_value interface
650 key.set_value(self, value)
653def _find_active_events(start_values, end_values, tolerances, directions):
654 """Determine the events for which a matching sign change has occurred
655 between the start- and the end-value.
657 Returns:
658 An array of booleans, indicating for each event whether it has seen a
659 sign change or not.
660 """
662 up = (((start_values < -tolerances) &
663 (end_values >= -tolerances)) |
664 ((start_values <= tolerances) &
665 (end_values > tolerances)))
666 down = (((start_values >= -tolerances) &
667 (end_values < -tolerances)) |
668 ((start_values > tolerances) &
669 (end_values <= tolerances)))
670 mask = ((up & (directions >= 0)) |
671 (down & (directions <= 0)))
672 return mask