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

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

## 222 statements

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

2 Provide classes for simulation.

3"""

4 import warnings

5

6 import numpy as np

7 import scipy.integrate

8 import scipy.optimize

9

10 from modypy.model import State, InputSignal, SystemState

11 from modypy.model.events import ClockQueue

12 from modypy.model.system import System

13

14 INITIAL_RESULT_SIZE = 16

15 RESULT_SIZE_EXTENSION = 16

16

17 DEFAULT_INTEGRATOR = scipy.integrate.DOP853

18

19

20 class SimulationError(RuntimeError):

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

22

23

24 class IntegrationError(SimulationError):

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

26

27

28 class ExcessiveEventError(SimulationError):

29 """

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

31 """

32

33

34 class 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

140 class 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

471 class _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

622 class _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

653 def _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