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

2Functions and classes for finding the steady state of a system. 

3 

4To determine a steady state, set up a :class:`SteadyStateConfiguration` object 

5and pass it to :func:`find_steady_state`. 

6""" 

7from itertools import accumulate 

8 

9import numpy as np 

10from collections.abc import Mapping 

11from functools import partial 

12from modypy.model import InputSignal, Port, State, System, SystemState 

13from scipy import optimize as opt 

14from typing import Union 

15 

16 

17class SteadyStateConfiguration: 

18 """Represents the configuration for the steady state determination 

19 

20 Attributes: 

21 system 

22 The system for which a steady state analysis shall be carried out 

23 time 

24 The system time for which the steady state analysis shall be carried 

25 out. 

26 objective 

27 An objective to minimize. This is either `None` (default), a 

28 callable or a `Port`. If no objective is specified, any steady state 

29 that satisfies the other constraints may be returned. 

30 states 

31 A read-only dictionary mapping states to :class:`StateConstraint` 

32 instances for the respective state. The state constraint can be 

33 configured by modifying its properties. 

34 ports 

35 A read-only dictionary mapping ports to :class:`PortConstraint` 

36 instances for the respective port. The port constraint can be 

37 configured by modifying its properties. 

38 inputs 

39 A read-only dictionary mapping ports to :class:`InputConstraint` 

40 instances for the respective port. The port constraint can be 

41 configured by modifying its properties. 

42 initial_condition 

43 The initial estimate for the states. The default is the initial 

44 condition of the system. 

45 initial_input 

46 The initial estimate for the inputs. The default is the initial 

47 values specified for the inputs in the system. 

48 input_bounds 

49 An array of shape (n,2), where n is the number of inputs for the 

50 system. Each entry ``input_bounds[k]`` is a tuple ``(lb, ub)``, with 

51 ``lb`` giving the lower and ``ub`` giving the upper bound for the 

52 respective input line. The initial value for all bounds is 

53 ``(-np.inf, np.inf)``, representing an unconstrained input line. 

54 state_bounds 

55 An array of shape (n,2), where n is the number of states for the 

56 system. The format is the same as for `input_bounds`. 

57 steady_states 

58 An array of shape (n,), where n is the number of states for the 

59 system. The entry ``steady_states[k]`` is a boolean indicating 

60 whether the state ``k`` shall be steady, i.e. whether its derivative 

61 shall be zero. By default, all states are set to be steady. 

62 solver_options 

63 Dictionary with additional keyword options for the solver. 

64 """ 

65 

66 def __init__(self, 

67 system: System, 

68 time: float = 0, 

69 objective: Union[callable, Port] = None): 

70 self.system = system 

71 self.time = time 

72 

73 self.objective = objective 

74 

75 # Set up the initial state estimates 

76 self.initial_condition = self.system.initial_condition 

77 # Set up the initial state bounds 

78 self.state_bounds = np.full(shape=(self.system.num_states, 2), 

79 fill_value=(-np.inf, np.inf)) 

80 # Set up the set of steady states 

81 self.steady_states = np.full(shape=self.system.num_states, 

82 fill_value=True) 

83 

84 # Set up the initial input estimates 

85 self.initial_input = self.system.initial_input 

86 # Set up the initial input bounds 

87 self.input_bounds = np.full(shape=(self.system.num_inputs, 2), 

88 fill_value=(-np.inf, np.inf)) 

89 

90 # Set up the dictionary for solver options 

91 self.solver_options = dict() 

92 

93 # Set up the dictionaries for the specific constraints 

94 self.ports = _ConstraintDictionary(PortConstraint, self) 

95 self.states = _ConstraintDictionary(StateConstraint, self) 

96 self.inputs = _ConstraintDictionary(InputConstraint, self) 

97 

98 

99class PortConstraint(opt.NonlinearConstraint): 

100 """A ``PortConstraint`` represents constraints on a single port. 

101 

102 Properties: 

103 port 

104 The port to be constrained 

105 lower_bounds 

106 A numerical value or an array representing the lower limit for 

107 the port. The default is negative infinity, i.e., no lower limit. 

108 upper_bounds 

109 A numerical value or an array representing the upper limit for 

110 the port. The default is positive infinity, i.e., no upper limit. 

111 """ 

112 

113 def __init__(self, 

114 config: SteadyStateConfiguration, 

115 port: Port, 

116 lower_limit=-np.inf, 

117 upper_limit=np.inf): 

118 self.config = config 

119 self.port = port 

120 

121 super().__init__(fun=self._evaluate, 

122 lb=np.ravel(np.full(self.port.shape, lower_limit)), 

123 ub=np.ravel(np.full(self.port.shape, upper_limit))) 

124 

125 @property 

126 def lower_bounds(self): 

127 """Return the lower bounds currently set for this port""" 

128 return self.lb.reshape(self.port.shape) 

129 

130 @lower_bounds.setter 

131 def lower_bounds(self, value): 

132 self.lb[:] = np.ravel(value) 

133 

134 @property 

135 def upper_bounds(self): 

136 return self.ub.reshape(self.port.shape) 

137 

138 @upper_bounds.setter 

139 def upper_bounds(self, value): 

140 self.ub[:] = np.ravel(value) 

141 

142 def _evaluate(self, x): 

143 """Calculate the value vector of the port""" 

144 

145 state = x[:self.config.system.num_states] 

146 inputs = x[self.config.system.num_states:] 

147 system_state = SystemState(time=self.config.time, 

148 system=self.config.system, 

149 state=state, 

150 inputs=inputs) 

151 return np.ravel(self.port(system_state)) 

152 

153 

154class StateConstraint: 

155 """A ``StateConstraint`` object represents the constraints on a state. 

156 

157 Properties: 

158 lower_bounds: 

159 A matrix with the shape of the state, representing the lower bound 

160 for each component of the state (default: -inf) 

161 upper_bounds: 

162 A matrix with the shape of the state, representing the upper bound 

163 for each component of the state (default: +inf) 

164 steady_state: 

165 A matrix of booleans with the shape of the state, indicating whether 

166 the respective component of the state shall be a steady-state, i.e., 

167 whether its derivative shall be constrained to zero (default: True) 

168 initial_condition: 

169 A matrix with the shape of the state, representing the initial 

170 steady state guess for each component of the state 

171 (default: initial condition of the state)""" 

172 

173 def __init__(self, 

174 config: SteadyStateConfiguration, 

175 state: State): 

176 self.config = config 

177 self.state = state 

178 

179 flat_lower_bounds = self.config.state_bounds[self.state.state_slice, 0] 

180 flat_upper_bounds = self.config.state_bounds[self.state.state_slice, 1] 

181 self._lower_bounds = flat_lower_bounds.reshape(self.state.shape) 

182 self._upper_bounds = flat_upper_bounds.reshape(self.state.shape) 

183 

184 flat_steady_states = self.config.steady_states[self.state.state_slice] 

185 self._steady_states = flat_steady_states.reshape(self.state.shape) 

186 

187 flat_initial_condition = \ 

188 self.config.initial_condition[self.state.state_slice] 

189 self._initial_condition = \ 

190 flat_initial_condition.reshape(self.state.shape) 

191 

192 @property 

193 def lower_bounds(self): 

194 return self._lower_bounds 

195 

196 @lower_bounds.setter 

197 def lower_bounds(self, value): 

198 self.config.state_bounds[self.state.state_slice, 0] = np.ravel(value) 

199 

200 @property 

201 def upper_bounds(self): 

202 return self._upper_bounds 

203 

204 @upper_bounds.setter 

205 def upper_bounds(self, value): 

206 self.config.state_bounds[self.state.state_slice, 1] = np.ravel(value) 

207 

208 @property 

209 def steady_state(self): 

210 return self._steady_states 

211 

212 @steady_state.setter 

213 def steady_state(self, value): 

214 self.config.steady_states[self.state.state_slice] = np.ravel(value) 

215 

216 @property 

217 def initial_condition(self): 

218 return self._initial_condition 

219 

220 @initial_condition.setter 

221 def initial_condition(self, value): 

222 self.config.initial_condition[self.state.state_slice] = np.ravel(value) 

223 

224 

225class InputConstraint: 

226 """A ``InputConstraint`` object represents the constraints on an input port. 

227 

228 Properties: 

229 lower_bounds: 

230 A matrix with the shape of the input, representing the lower bound 

231 for each component of the port (default: -inf) 

232 upper_bounds: 

233 A matrix with the shape of the input, representing the upper bound 

234 for each component of the port (default: +inf) 

235 initial_guess: 

236 A matrix with the shape of the input, representing the initial 

237 steady state guess for each component of the input 

238 (default: value of the input at the time of creation of the 

239 :class:`SteadyStateConfiguration` object)""" 

240 

241 def __init__(self, 

242 config: SteadyStateConfiguration, 

243 input_signal: InputSignal): 

244 self.config = config 

245 self.input_signal = input_signal 

246 

247 flat_lower_bounds = \ 

248 self.config.input_bounds[self.input_signal.input_slice, 0] 

249 flat_upper_bounds = \ 

250 self.config.input_bounds[self.input_signal.input_slice, 1] 

251 self._lower_bounds = flat_lower_bounds.reshape(self.input_signal.shape) 

252 self._upper_bounds = flat_upper_bounds.reshape(self.input_signal.shape) 

253 

254 flat_initial_guess = \ 

255 self.config.initial_input[self.input_signal.input_slice] 

256 self._initial_guess = \ 

257 flat_initial_guess.reshape(self.input_signal.shape) 

258 

259 @property 

260 def lower_bounds(self): 

261 return self._lower_bounds 

262 

263 @lower_bounds.setter 

264 def lower_bounds(self, value): 

265 self.config.input_bounds[self.input_signal.input_slice, 0] = \ 

266 np.ravel(value) 

267 

268 @property 

269 def upper_bounds(self): 

270 return self._upper_bounds 

271 

272 @upper_bounds.setter 

273 def upper_bounds(self, value): 

274 self.config.input_bounds[self.input_signal.input_slice, 1] = \ 

275 np.ravel(value) 

276 

277 @property 

278 def initial_guess(self): 

279 return self._initial_guess 

280 

281 @initial_guess.setter 

282 def initial_guess(self, value): 

283 self.config.initial_input[self.input_signal.input_slice] = \ 

284 np.ravel(value) 

285 

286 

287def find_steady_state(config: SteadyStateConfiguration): 

288 """Run the steady-state determination 

289 

290 Args: 

291 config: The configuration for the steady-state analysis 

292 

293 Returns: 

294 An :class:`OptimizeResult <scipy.optimize.OptimizeResult>` object with 

295 additional fields. 

296 

297 state: ndarray 

298 The state part of the solution 

299 inputs: ndarray 

300 The input part of the solution 

301 system_state: modypy.model.evaluation.SystemState 

302 An :class:`SystemState <modypy.model.evaluation.SystemState>` 

303 object, configured to evaluate the system at the determined 

304 steady-state 

305 """ 

306 

307 # Set up the initial estimate 

308 x0 = np.concatenate((config.initial_condition, config.initial_input)) 

309 # Set up the bounds 

310 bounds = np.concatenate((config.state_bounds, config.input_bounds)) 

311 

312 # Set up the constraints 

313 constraints = list() 

314 

315 constraints += config.ports.values() 

316 

317 if config.objective is not None: 

318 # We have an actual objective function, so we can use the steady-state 

319 # constraint as actual constraint. 

320 if any(config.steady_states): 

321 # Set up the state derivative constraint 

322 steady_state_constraint = _StateDerivativeConstraint(config) 

323 constraints.append(steady_state_constraint) 

324 

325 # Translate the objective function 

326 if callable(config.objective): 

327 objective_function = partial(_general_objective_function, config) 

328 else: 

329 raise ValueError('The objective function must be either a Port or ' 

330 'a callable') 

331 elif any(config.steady_states): 

332 # No objective function was specified, but we can use the steady-state 

333 # constraint function. The value of this function is intended to be 

334 # zero, so the minimum value of its square is zero. 

335 steady_state_constraint = _StateDerivativeConstraint(config) 

336 constraints.append(steady_state_constraint) 

337 objective_function = steady_state_constraint.evaluate_squared 

338 else: 

339 # We have neither an objective function to minimize nor do we have any 

340 # state that is intended to be steady. We cannot use the signal 

341 # constraints to minimize, as these may specify ranges instead of a 

342 # target value. We cannot do anything about this. 

343 raise ValueError('Either an objective function or at least one steady ' 

344 'state is required') 

345 

346 result = opt.minimize(fun=objective_function, 

347 x0=x0, 

348 method='trust-constr', 

349 bounds=bounds, 

350 constraints=constraints, 

351 options=config.solver_options) 

352 

353 result.config = config 

354 result.state = result.x[:config.system.num_states] 

355 result.inputs = result.x[config.system.num_states:] 

356 result.system_state = SystemState(time=config.time, 

357 system=config.system, 

358 state=result.state, 

359 inputs=result.inputs) 

360 

361 return result 

362 

363 

364class _StateDerivativeConstraint(opt.NonlinearConstraint): 

365 """Represents the steady-state constraints on the state derivatives""" 

366 

367 def __init__(self, config: SteadyStateConfiguration): 

368 self.config = config 

369 # Steady-state constraints can be defined on the level of individual 

370 # state components. To optimize evaluation, we only evaluate derivatives 

371 # of those states that have at least one of their components 

372 # constrained. 

373 self.constrained_states = [ 

374 state for state in self.config.system.states 

375 if any(self.config.steady_states[state.state_slice])] 

376 

377 # We will build a vector of the constrained derivatives, and for that 

378 # we assign offsets for the states in that vector 

379 self.state_offsets = \ 

380 [0] + list(accumulate(state.size 

381 for state in self.constrained_states)) 

382 

383 num_states = self.state_offsets[-1] 

384 

385 # Now we set up the bounds for each of these 

386 ub = np.full(num_states, np.inf) 

387 ub[self.config.steady_states] = 0 

388 lb = -ub 

389 

390 opt.NonlinearConstraint.__init__(self, 

391 fun=self.evaluate, 

392 lb=lb, 

393 ub=ub) 

394 

395 def evaluate(self, x): 

396 """Determine the value of the derivatives of the vector of constrained 

397 states""" 

398 

399 state = x[:self.config.system.num_states] 

400 inputs = x[self.config.system.num_states:] 

401 system_state = SystemState(time=self.config.time, 

402 system=self.config.system, 

403 state=state, 

404 inputs=inputs) 

405 derivative_vector = self.config.system.state_derivative(system_state) 

406 return derivative_vector 

407 

408 def evaluate_squared(self, x): 

409 """Determine the 2-norm of the derivatives vector of constrained 

410 states""" 

411 

412 return np.sum(np.square(self.evaluate(x))) 

413 

414 

415def _general_objective_function(config: SteadyStateConfiguration, x): 

416 """Implementation of the general objective function 

417 

418 This calls the objective function with a `DataProvider` as single parameter. 

419 

420 Args: 

421 config: The configuration for the steady-state determination 

422 x: The vector of the current values of states and input 

423 

424 Returns: 

425 The current value of the objective function 

426 """ 

427 

428 state = x[:config.system.num_states] 

429 inputs = x[config.system.num_states:] 

430 system_state = SystemState(time=config.time, 

431 system=config.system, 

432 state=state, 

433 inputs=inputs) 

434 return config.objective(system_state) 

435 

436 

437class _ConstraintDictionary(Mapping): 

438 """Dictionary to hold constraints 

439 

440 When a key is requested for which there is no entry yet, the given 

441 constructor is called with the args, the key and the keyword args to 

442 create a new entry.""" 

443 

444 def __init__(self, constructor, *args, **kwargs): 

445 self.data = dict() 

446 self.constructor = constructor 

447 self.args = args 

448 self.kwargs = kwargs 

449 

450 def __getitem__(self, key): 

451 try: 

452 return self.data[key] 

453 except KeyError: 

454 new_item = self.constructor(*self.args, key, **self.kwargs) 

455 self.data[key] = new_item 

456 return new_item 

457 

458 def __len__(self): 

459 return len(self.data) 

460 

461 def __iter__(self): 

462 return iter(self.data)