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

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

## 170 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 Functions and classes for finding the steady state of a system.

3

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

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

6"""

7 from itertools import accumulate

8

9 import numpy as np

10 from collections.abc import Mapping

11 from functools import partial

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

13 from scipy import optimize as opt

14 from typing import Union

15

16

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

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

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

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

287 def 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

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

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

437 class _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)