Hide keyboard shortcuts

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 

5 

6import numpy as np 

7import scipy.integrate 

8import scipy.optimize 

9 

10from modypy.model import State, InputSignal, SystemState 

11from modypy.model.events import ClockQueue 

12from modypy.model.system import System 

13 

14INITIAL_RESULT_SIZE = 16 

15RESULT_SIZE_EXTENSION = 16 

16 

17DEFAULT_INTEGRATOR = scipy.integrate.DOP853 

18 

19 

20class SimulationError(RuntimeError): 

21 """Exception raised when an error occurs during simulation""" 

22 

23 

24class IntegrationError(SimulationError): 

25 """Exception raised when an error is reported by the integrator""" 

26 

27 

28class ExcessiveEventError(SimulationError): 

29 """ 

30 Exception raised when an excessive number of successive events occurs. 

31 """ 

32 

33 

34class SimulationResult: 

35 """The results provided by a simulation. 

36 

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 """ 

41 

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)) 

47 

48 self.current_idx = 0 

49 

50 if source is not None: 

51 self.collect_from(source) 

52 

53 @property 

54 def time(self): 

55 """The time vector of the simulation result""" 

56 return self._t[0:self.current_idx] 

57 

58 @property 

59 def inputs(self): 

60 """The input vector of the simulation result""" 

61 return self._inputs[:, 0:self.current_idx] 

62 

63 @property 

64 def state(self): 

65 """The state vector of the simulation result""" 

66 return self._state[:, 0:self.current_idx] 

67 

68 def collect_from(self, source): 

69 """Collect data points from the given source 

70 

71 The source must be an iterable providing a series system states 

72 representing the system states at the individual time points""" 

73 

74 for state in source: 

75 self.append(state) 

76 

77 def append(self, system_state): 

78 """Append an entry to the result vectors. 

79 

80 Args: 

81 system_state: The system state to append 

82 """ 

83 self._append(system_state.time, system_state.inputs, system_state.state) 

84 

85 def _append(self, time, inputs, state): 

86 """Append an entry to the result vectors. 

87 

88 Args: 

89 time: The time tag for the entry 

90 inputs: The input vector 

91 state: The state vector 

92 """ 

93 

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 

99 

100 self.current_idx += 1 

101 

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))] 

112 

113 def get_state_value(self, state: State): 

114 """Determine the value of the given state in this result object""" 

115 

116 return self.state[state.state_slice].reshape(state.shape + (-1,)) 

117 

118 def get_input_value(self, signal: InputSignal): 

119 """Determine the value of the given input in this result object""" 

120 

121 return self.inputs[signal.input_slice].reshape(signal.shape + (-1,)) 

122 

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) 

138 

139 

140class Simulator: 

141 """Simulator for dynamic systems. 

142 

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. 

148 

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 """ 

170 

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.""" 

181 

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 

189 

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 

197 

198 # Reset the count of successive events 

199 self.successive_event_count = 0 

200 

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]) 

206 

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 ) 

212 

213 # Create the clock queue 

214 self.clock_queue = ClockQueue(start_time=start_time, 

215 clocks=self.system.clocks) 

216 

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() 

221 

222 def run_until(self, time_boundary, include_last=True): 

223 """Run the simulation 

224 

225 Yields a series of :class:`modypy.model.system.SystemState` objects with 

226 each element representing one time sample of the process. 

227 

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. 

237 

238 Raises: 

239 SimulationError: if an error occurs during simulation 

240 """ 

241 

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) 

246 

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) 

252 

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 

258 

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) 

273 

274 while self.current_time < time_boundary: 

275 terminated = False 

276 

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 

282 

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) 

290 

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) 

298 

299 # Perform a solver step 

300 msg = solver.step() 

301 if msg is not None: 

302 raise IntegrationError(msg) 

303 

304 # Get interpolation functions for state and inputs 

305 state_interpolator = solver.dense_output() 

306 

307 def _input_interpolator(_t): 

308 return self.current_inputs 

309 

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 

318 

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] 

323 

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 ) 

331 

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 

341 

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 

358 

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() 

363 

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) 

373 

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 

380 

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() 

385 

386 def _run_clock_ticks(self): 

387 """Run all the pending clock ticks.""" 

388 

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) 

392 

393 # Run all the event listeners 

394 self._run_event_listeners(clocks_to_tick) 

395 

396 def _run_event_listeners(self, event_sources): 

397 """Run the event listeners on the given events. 

398 """ 

399 

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() 

406 

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) 

412 

413 # Determine the values of all event functions before running the 

414 # event listeners. 

415 last_event_values = self.system.event_values(state_updater) 

416 

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) 

423 

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) 

431 

432 # Update the state 

433 self.current_state = state_updater.state 

434 

435 # Determine the value of event functions after running the event 

436 # listeners 

437 new_event_values = self.system.event_values(state_updater) 

438 

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 = [] 

451 

452 def _state_derivative(self, time, state): 

453 """The state derivative function used for integrating the state over 

454 time. 

455 

456 Args: 

457 time: The current time 

458 state: The current state vector 

459 

460 Returns: 

461 The time-derivative of the state vector 

462 """ 

463 

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 

469 

470 

471class _EventDetector: 

472 """Helper class for detecting and localizing events""" 

473 

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]) 

481 

482 def localize_first_event(self, start_time, end_time, state, inputs): 

483 """Localize the first event occurring in the given time frame. 

484 

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 

506 

507 def localize_events(self, start_time, end_time, state, inputs): 

508 """Localize the all events occurring in the given time frame. 

509 

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)) 

533 

534 # Determine the list of active events 

535 active_events = self._get_active_events(start_state, end_state) 

536 

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 

547 

548 def _get_active_events(self, start_state, end_state): 

549 """Determine which events are active in the given time frame. 

550 

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]) 

559 

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] 

565 

566 def _find_event_time(self, event, start_time, end_time, state, inputs): 

567 """ 

568 Find the time when the sign change occurs. 

569 

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 """ 

584 

585 assert start_time <= end_time 

586 

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)) 

595 

596 start_value = event(start_state) 

597 end_value = event(end_state) 

598 

599 assert (((start_value < -event.tolerance) ^ 

600 (end_value < -event.tolerance)) | 

601 ((event.tolerance < start_value) ^ 

602 (event.tolerance < end_value))) 

603 

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 

620 

621 

622class _SystemStateUpdater(SystemState): 

623 """A ``_SystemStateUpdater`` is a system state in which the states can be 

624 updated""" 

625 

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() 

630 

631 def set_state_value(self, state: State, value): 

632 """Update the value of the given state""" 

633 

634 self.state[state.state_slice] = np.asarray(value).ravel() 

635 

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) 

651 

652 

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. 

656 

657 Returns: 

658 An array of booleans, indicating for each event whether it has seen a 

659 sign change or not. 

660 """ 

661 

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