Coverage for src/python/dandeliion/client/solution.py: 98%

1"""

2@file python/dandeliion/client/solution.py

3

4 Module containing class for handling access to solutions

5"""

6

7#

8# Copyright (C) 2024-2025 Dandeliion Team

9#

10# This library is free software; you can redistribute it and/or modify it under

11# the terms of the GNU Lesser General Public License as published by the Free

12# Software Foundation; either version 3.0 of the License, or (at your option)

13# any later version.

14#

15# This library is distributed in the hope that it will be useful, but WITHOUT

16# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS

17# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more

18# details.

19#

20# You should have received a copy of the GNU Lesser General Public License

21# along with this library; if not, write to the Free Software Foundation, Inc.,

22# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA

23#

24

25# built-in modules

26 import logging

27 import copy

28 from typing import Protocol, Optional

29 from collections.abc import Mapping

30

31# custom modules

32 from .exceptions import DandeliionAPIException

33

34# third-party modules

35 import numpy as np

36 import numpy.typing

37

38

39 logger = logging.getLogger(__name__)

40

41

42 class Simulator(Protocol):

43 """ Simulator Protocol """

44 def update_results(self, prefetched_data: dict, keys: list = None, inline: bool = False) -> Optional[dict]: ...

45 def get_status(self, prefetched_data: dict) -> str: ...

46

47

48 class InterpolatedArray(np.ndarray):

49 """

50 Subclass of ndarray providing function call for linear interpolation

51 """

52 def __new__(cls, t: np.typing.ArrayLike, y: np.typing.ArrayLike, **kwargs):

53 instance = np.asarray(y, **kwargs).view(cls)

54 instance.t = np.array(t)

55 return instance

56

57 def __call__(self, t):

58 """

59 function call to return interpolated value

60 """

61 # check that 1-d array

62 if not (len(self.t.shape) == len(self.shape) == 1):

63 raise ValueError('x and y must be 1-d array-like objects')

64 # check that array of same length

65 if self.t.shape != self.shape:

66 raise ValueError('x and y must be of same length')

67 return np.interp(t, self.t, self)

68

69

70 class Solution(Mapping):

71 """Dictionary-style class for the solutions of a simulation run

72 returned by :meth:`solve`

73 """

74

75 _data: dict = None

76 _sim: Simulator = None

77

78 def __init__(self, sim: Simulator, prefetched_data: dict, time_column: str = None):

79 """

80 Constructor

81

82 Args:

83 sim (Simulator): simulator instance for fetching data from server

84 prefetched_data (dict): existing (meta) data

85 time_column (str): label of time column (used for interpolation)

86 """

87 self._sim = sim

88 self._data = prefetched_data

89 self._time_column = time_column

90

91 def _init_solution(self):

92 """

93 inits prefetched solution from simulator if necessary

94 """

95 logger.debug('Initialising solutions')

96 self._sim.update_results(self._data, inline=True)

97 # if solution still not initialised (e.g. because simulation

98 # failed or has not finished yet), raise Exception

99 if self._data['Solution'] is None:

100 raise DandeliionAPIException(

101 'Solution not ready (yet). Check status for details.'

102 )

103

104 def __getitem__(self, key: str):

105 """Returns the results requested by the key.

106

107 Args:

108 key (str): key for results to be returned.

109

110 Returns:

111 object: data as requested by provided key

112 """

113

114 # if solution not initialised yet, try to fetch from server

115 if self._data.get('Solution', None) is None:

116 self._init_solution()

117

118 if key not in self._data['Solution']:

119 raise KeyError(

120 f'Column for {key} does not exist in solution.'

121 )

122 # fetch data if necessary

123 if self._data['Solution'][key] is None:

124 logger.info(f"Fetching '{key}' column from simulator")

125 self._sim.update_results(self._data, keys=[key], inline=True)

126 if self._time_column:

127 return InterpolatedArray(t=self._data['Solution'][self._time_column], y=self._data['Solution'][key])

128 else:

129 return copy.deepcopy(self._data['Solution'][key])

130

131 def __len__(self):

132 """Returns the number of fields in the solutions.

133

134 Returns:

135 int: number of fields

136 """

137 if self._data.get('Solution', None) is None:

138 self._init_solution()

139 return len(self._data['Solution'])

140

141 def __iter__(self):

142 """Returns an iterator on the solutions fields.

143

144 Returns:

145 iterator

146 """

147 if self._data.get('Solution', None) is None:

148 self._init_solution()

149 yield from self._data['Solution']

150

151 @property

152 def status(self):

153 """Returns the status of the simulation run linked to this solutions

154

155 Returns:

156 str: current status of simulation run ('queued', 'running', 'failed', 'success')

157 """

158 return self._sim.get_status(self._data)