Coverage for wsimod/nodes/demand.py: 22%

79 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2024-01-11 16:39 +0000

1# -*- coding: utf-8 -*- 

2"""Created on Mon Nov 15 14:20:36 2021. 

3 

4@author: bdobson 

5 

6Converted to totals BD 2022-05-03 

7""" 

8from wsimod.core import constants 

9from wsimod.nodes.nodes import Node 

10 

11 

12class Demand(Node): 

13 """""" 

14 

15 def __init__( 

16 self, 

17 name, 

18 constant_demand=0, 

19 pollutant_load={}, 

20 data_input_dict={}, 

21 ): 

22 """Node that generates and moves water. Currently only subclass 

23 ResidentialDemand is in use. 

24 

25 Args: 

26 name (str): node name constant_demand (float, optional): A constant portion 

27 of demand if no subclass 

28 is used. Defaults to 0. 

29 pollutant_load (dict, optional): Pollutant mass per timestep of 

30 constant_demand. 

31 Defaults to {}. 

32 data_input_dict (dict, optional): Dictionary of data inputs relevant for 

33 the node (temperature). Keys are tuples where first value is the name of 

34 the variable to read from the dict and the second value is the time. 

35 Defaults to {} 

36 

37 Functions intended to call in orchestration: 

38 create_demand 

39 """ 

40 # TODO should temperature be defined in pollutant dict? TODO a lot of this 

41 # should be moved to ResidentialDemand Assign parameters 

42 self.constant_demand = constant_demand 

43 self.pollutant_load = pollutant_load 

44 # Update args 

45 super().__init__(name, data_input_dict=data_input_dict) 

46 # Update handlers 

47 self.push_set_handler["default"] = self.push_set_deny 

48 self.push_check_handler["default"] = self.push_check_deny 

49 self.pull_set_handler["default"] = self.pull_set_deny 

50 self.pull_check_handler["default"] = self.pull_check_deny 

51 

52 # Initialise states 

53 self.total_demand = self.empty_vqip() 

54 self.total_backup = self.empty_vqip() # ew 

55 self.total_received = self.empty_vqip() 

56 

57 # Mass balance Because we assume demand is always satisfied received water 

58 # 'disappears' for mass balance and consumed water 'appears' (this makes) 

59 # introduction of pollutants easy 

60 self.mass_balance_in.append(lambda: self.total_demand) 

61 self.mass_balance_out.append(lambda: self.total_backup) 

62 self.mass_balance_out.append(lambda: self.total_received) 

63 

64 def create_demand(self): 

65 """Function to call get_demand, which should return a dict with keys that match 

66 the keys in directions. 

67 

68 A dict that determines how to push_distributed the generated wastewater/garden 

69 irrigation. Water is drawn from attached nodes. 

70 """ 

71 demand = self.get_demand() 

72 total_requested = 0 

73 for dem in demand.values(): 

74 total_requested += dem["volume"] 

75 

76 self.total_received = self.pull_distributed({"volume": total_requested}) 

77 

78 # TODO Currently just assume all water is received and then pushed onwards 

79 if (total_requested - self.total_received["volume"]) > constants.FLOAT_ACCURACY: 

80 print( 

81 "demand deficit of {2} at {0} on {1}".format( 

82 self.name, self.t, total_requested - self.total_received["volume"] 

83 ) 

84 ) 

85 

86 directions = { 

87 "garden": {"tag": ("Demand", "Garden"), "of_type": "Land"}, 

88 "house": {"tag": "Demand", "of_type": "Sewer"}, 

89 "default": {"tag": "default", "of_type": None}, 

90 } 

91 

92 # Send water where it needs to go 

93 for key, item in demand.items(): 

94 # Distribute 

95 remaining = self.push_distributed( 

96 item, of_type=directions[key]["of_type"], tag=directions[key]["tag"] 

97 ) 

98 self.total_backup = self.sum_vqip(self.total_backup, remaining) 

99 if remaining["volume"] > constants.FLOAT_ACCURACY: 

100 print("Demand not able to push") 

101 

102 # Update for mass balance 

103 for dem in demand.values(): 

104 self.total_demand = self.sum_vqip(self.total_demand, dem) 

105 

106 def get_demand(self): 

107 """Holder function to enable constant demand generation. 

108 

109 Returns: 

110 (dict): A VQIP that will contain constant demand 

111 """ 

112 # TODO read/gen demand 

113 pol = self.v_change_vqip(self.empty_vqip(), self.constant_demand) 

114 for key, item in self.pollutant_load.items(): 

115 pol[key] = item 

116 return {"default": pol} 

117 

118 def end_timestep(self): 

119 """Reset state variable trackers.""" 

120 self.total_demand = self.empty_vqip() 

121 self.total_backup = self.empty_vqip() 

122 self.total_received = self.empty_vqip() 

123 

124 

125class NonResidentialDemand(Demand): 

126 """Holder class to enable non-residential demand generation.""" 

127 

128 def get_demand(self): 

129 """Holder function. 

130 

131 Returns: 

132 (dict): A dict of VQIPs, where the keys match with directions 

133 in Demand/create_demand 

134 """ 

135 return {"house": self.get_demand()} 

136 

137 

138class ResidentialDemand(Demand): 

139 """""" 

140 

141 def __init__( 

142 self, 

143 population=1, 

144 pollutant_load={}, 

145 per_capita=0.12, 

146 gardening_efficiency=0.6 * 0.7, # Watering efficiency by irrigated area 

147 data_input_dict={}, # For temperature 

148 constant_temp=30, 

149 constant_weighting=0.2, 

150 **kwargs, 

151 ): 

152 """Subclass of demand with functions to handle internal and external water use. 

153 

154 Args: 

155 population (float, optional): population of node. Defaults to 1. per_capita 

156 (float, optional): Volume per person per timestep of water 

157 used. Defaults to 0.12. 

158 pollutant_load (dict, optional): Mass per person per timestep of 

159 different pollutants generated. Defaults to {}. 

160 gardening_efficiency (float, optional): Value between 0 and 1 that 

161 translates irrigation demand from GardenSurface into water requested 

162 from the distribution network. Should account for percent of garden that 

163 is irrigated and the efficacy of people in meeting their garden water 

164 demand. Defaults to 0.6*0.7. 

165 data_input_dict (dict, optional): Dictionary of data inputs relevant for 

166 the node (temperature). Keys are tuples where first value is the name of 

167 the variable to read from the dict and the second value is the time. 

168 Defaults to {} 

169 constant_temp (float, optional): A constant temperature associated with 

170 generated water. Defaults to 30 

171 constant_weighting (float, optional): Proportion of temperature that is 

172 made up from by constant_temp. Defaults to 0.2. 

173 

174 Key assumptions: 

175 - Per capita calculations to generate demand based on population. 

176 - Pollutant concentration of generated demand uses a fixed mass per person 

177 per timestep. 

178 - Temperature of generated wastewater is based partially on air temperature 

179 and partially on a constant. 

180 - Can interact with `land.py/GardenSurface` to simulate garden water use. 

181 

182 Input data and parameter requirements: 

183 - `population`. 

184 _Units_: n 

185 - `per_capita`. 

186 _Units_: m3/timestep 

187 - `data_input_dict` should contain air temperature at model timestep. 

188 _Units_: C 

189 """ 

190 self.gardening_efficiency = gardening_efficiency 

191 self.population = population 

192 self.per_capita = per_capita 

193 self.constant_weighting = constant_weighting 

194 self.constant_temp = constant_temp 

195 super().__init__( 

196 data_input_dict=data_input_dict, pollutant_load=pollutant_load, **kwargs 

197 ) 

198 # Label as Demand class so that other nodes treat it the same 

199 self.__class__.__name__ = "Demand" 

200 

201 def get_demand(self): 

202 """Overwrite get_demand and replace with custom functions. 

203 

204 Returns: 

205 (dict): A dict of VQIPs, where the keys match with directions 

206 in Demand/create_demand 

207 """ 

208 water_output = {} 

209 

210 water_output["garden"] = self.get_garden_demand() 

211 water_output["house"] = self.get_house_demand() 

212 

213 return water_output 

214 

215 def get_garden_demand(self): 

216 """Calculate garden water demand in the current timestep by get_connected to all 

217 attached land nodes. This check should return garden water demand. Applies 

218 irrigation coefficient. Can function when a single population node is connected 

219 to multiple land nodes, however, the capacity and preferences of arcs should be 

220 updated to reflect what is possible based on area. 

221 

222 Returns: 

223 vqip (dict): A VQIP of garden water use (including pollutants) to be 

224 pushed to land 

225 """ 

226 # Get garden water demand 

227 excess = self.get_connected( 

228 direction="push", of_type="Land", tag=("Demand", "Garden") 

229 )["avail"] 

230 

231 # Apply garden_efficiency 

232 excess = self.excess_to_garden_demand(excess) 

233 

234 # Apply any pollutants 

235 vqip = self.apply_gardening_pollutants(excess) 

236 return vqip 

237 

238 def apply_gardening_pollutants(self, excess): 

239 """Holder function to apply pollutants (i.e., presumably fertiliser) to the 

240 garden. 

241 

242 Args: 

243 excess (float): A volume of water applied to a garden 

244 

245 Returns: 

246 (dict): A VQIP of water that includes pollutants to be sent to land 

247 """ 

248 # TODO Fertilisers are currently applied in the land node... which is 

249 # preferable? 

250 vqip = self.empty_vqip() 

251 vqip["volume"] = excess 

252 return vqip 

253 

254 def excess_to_garden_demand(self, excess): 

255 """Apply garden_efficiency. 

256 

257 Args: 

258 excess (float): Volume of water required to satisfy garden irrigation 

259 

260 Returns: 

261 (float): Amount of water actually applied to garden 

262 """ 

263 # TODO Anything more than this needed? (yes - population presence if eventually 

264 # included!) 

265 

266 return excess * self.gardening_efficiency 

267 

268 def get_house_demand(self): 

269 """Per capita calculations for household wastewater generation. Applies weighted 

270 temperature calculation. 

271 

272 Returns: 

273 (dict): A VQIP containg foul water 

274 """ 

275 # TODO water that is consumed but not sent onwards as foul Total water required 

276 consumption = self.population * self.per_capita 

277 # Apply pollutants 

278 foul = self.copy_vqip(self.pollutant_load) 

279 # Scale to population 

280 for pol in constants.ADDITIVE_POLLUTANTS: 

281 foul[pol] *= self.population 

282 # Update volume and temperature (which is weighted based on air temperature and 

283 # constant_temp) 

284 foul["volume"] = consumption 

285 foul["temperature"] = ( 

286 self.get_data_input("temperature") * (1 - self.constant_weighting) 

287 + self.constant_temp * self.constant_weighting 

288 ) 

289 return foul