Coverage for wsimod/nodes/wtw.py: 14%

170 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 

5Converted to totals on 2022-05-03 

6""" 

7from wsimod.core import constants 

8from wsimod.nodes.nodes import Node, Tank 

9 

10 

11class WTW(Node): 

12 """""" 

13 

14 def __init__( 

15 self, 

16 name, 

17 treatment_throughput_capacity=10, 

18 process_parameters={}, 

19 liquor_multiplier={}, 

20 percent_solids=0.0002, 

21 ): 

22 """Generic treatment processes that apply temperature a sensitive transform of 

23 pollutants into liquor and solids (behaviour depends on subclass). Push requests 

24 are stored in the current_input state variable, but treatment must be triggered 

25 with treat_current_input. This treated water is stored in the discharge_holder 

26 state variable, which will be sent different depending on FWTW/WWTW. 

27 

28 Args: 

29 name (str): Node name 

30 treatment_throughput_capacity (float, optional): Amount of volume per 

31 timestep of water that can be treated. Defaults to 10. 

32 process_parameters (dict, optional): Dict of dicts for each pollutant. 

33 Top level key describes pollutant. Next level key describes the 

34 constant portion of the transform and the temperature sensitive 

35 exponent portion (see core.py/DecayObj for more detailed 

36 explanation). Defaults to {}. 

37 liquor_multiplier (dict, optional): Keys for each pollutant that 

38 describes how much influent becomes liquor. Defaults to {}. 

39 percent_solids (float, optional): Proportion of volume that becomes solids. 

40 All pollutants that do not become effluent or liquor become solids. 

41 Defaults to 0.0002. 

42 

43 Functions intended to call in orchestration: 

44 None (use FWTW or WWTW subclass) 

45 

46 Key assumptions: 

47 - Throughput can be modelled entirely with a set capacity. 

48 - Pollutant reduction for the entire treatment process can be modelled 

49 primarily with a single (temperature sensitive) transformation for 

50 each pollutant. 

51 - Liquor and solids are tracked and calculated with proportional 

52 multiplier parameters. 

53 

54 Input data and parameter requirements: 

55 - `treatment_throughput_capacity` 

56 _Units_: cubic metres/timestep 

57 - `process_parameters` contains the constant (non temperature 

58 sensitive) and exponent (temperature sensitive) transformations 

59 applied to treated water for each pollutant. 

60 _Units_: - 

61 - `liquor_multiplier` and `percent_solids` describe the proportion of 

62 throughput that goes to liquor/solids. 

63 """ 

64 # Set/Default parameters 

65 self.treatment_throughput_capacity = treatment_throughput_capacity 

66 if len(process_parameters) > 0: 

67 self.process_parameters = process_parameters 

68 else: 

69 self.process_parameters = { 

70 x: {"constant": 0.01, "exponent": 1.001} 

71 for x in constants.ADDITIVE_POLLUTANTS 

72 } 

73 if len(liquor_multiplier) > 0: 

74 self.liquor_multiplier = liquor_multiplier 

75 else: 

76 self.liquor_multiplier = {x: 0.7 for x in constants.ADDITIVE_POLLUTANTS} 

77 self.liquor_multiplier["volume"] = 0.03 

78 

79 self.percent_solids = percent_solids 

80 

81 # Update args 

82 super().__init__(name) 

83 

84 self.process_parameters["volume"] = { 

85 "constant": 1 - self.percent_solids - self.liquor_multiplier["volume"] 

86 } 

87 

88 # Update handlers 

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

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

91 

92 # Initialise parameters 

93 self.current_input = self.empty_vqip() 

94 self.treated = self.empty_vqip() 

95 self.liquor = self.empty_vqip() 

96 self.solids = self.empty_vqip() 

97 

98 def get_excess_throughput(self): 

99 """How much excess treatment capacity is there. 

100 

101 Returns: 

102 (float): Amount of volume that can still be treated this timestep 

103 """ 

104 return max(self.treatment_throughput_capacity - self.current_input["volume"], 0) 

105 

106 def treat_current_input(self): 

107 """Run treatment processes this timestep, including temperature sensitive 

108 transforms, liquoring, solids.""" 

109 # Treat current input 

110 influent = self.copy_vqip(self.current_input) 

111 

112 # Calculate effluent, liquor and solids 

113 discharge_holder = self.empty_vqip() 

114 

115 # Assume non-additive pollutants are unchanged in discharge and are 

116 # proportionately mixed in liquor 

117 for key in constants.NON_ADDITIVE_POLLUTANTS: 

118 discharge_holder[key] = influent[key] 

119 self.liquor[key] = ( 

120 self.liquor[key] * self.liquor["volume"] 

121 + influent[key] * influent["volume"] * self.liquor_multiplier["volume"] 

122 ) / ( 

123 self.liquor["volume"] 

124 + influent["volume"] * self.liquor_multiplier["volume"] 

125 ) 

126 

127 # TODO this should probably just be for process_parameters.keys() to avoid 

128 # having to declare non changing parameters 

129 # TODO should the liquoring be temperature sensitive too? As it is the solids 

130 # will take the brunt of the temperature variability which maybe isn't sensible 

131 for key in constants.ADDITIVE_POLLUTANTS + ["volume"]: 

132 if key != "volume": 

133 # Temperature sensitive transform 

134 temp_factor = self.process_parameters[key]["exponent"] ** ( 

135 constants.DECAY_REFERENCE_TEMPERATURE - influent["temperature"] 

136 ) 

137 else: 

138 temp_factor = 1 

139 # Calculate discharge 

140 discharge_holder[key] = ( 

141 influent[key] * self.process_parameters[key]["constant"] * temp_factor 

142 ) 

143 # Calculate liquor 

144 self.liquor[key] = influent[key] * self.liquor_multiplier[key] 

145 

146 # Calculate solids volume 

147 self.solids["volume"] = influent["volume"] * self.percent_solids 

148 

149 # All remaining pollutants go to solids 

150 for key in constants.ADDITIVE_POLLUTANTS: 

151 self.solids[key] = influent[key] - discharge_holder[key] - self.liquor[key] 

152 

153 # Blend with any existing discharge 

154 self.treated = self.sum_vqip(self.treated, discharge_holder) 

155 

156 if self.treated["volume"] > self.current_input["volume"]: 

157 print("more treated than input") 

158 

159 def end_timestep(self): 

160 """""" 

161 # Reset state variables 

162 self.current_input = self.empty_vqip() 

163 self.treated = self.empty_vqip() 

164 

165 

166class WWTW(WTW): 

167 """""" 

168 

169 def __init__( 

170 self, 

171 stormwater_storage_capacity=10, 

172 stormwater_storage_area=1, 

173 stormwater_storage_elevation=10, 

174 **kwargs, 

175 ): 

176 """A wastewater treatment works wrapper for WTW. Contains a temporary stormwater 

177 storage tank. Liquor is combined with current_effluent and re- treated while 

178 solids leave the model. 

179 

180 Args: 

181 stormwater_storage_capacity (float, optional): Capacity of stormwater tank. 

182 Defaults to 10. 

183 stormwater_storage_area (float, optional): Area of stormwater tank. 

184 Defaults to 1. 

185 stormwater_storage_elevation (float, optional): Datum of stormwater tank. 

186 Defaults to 10. 

187 

188 Functions intended to call in orchestration: 

189 calculate_discharge 

190 

191 make_discharge 

192 

193 Key assumptions: 

194 - See `wtw.py/WTW` for treatment. 

195 - When `treatment_throughput_capacity` is exceeded, water is first sent 

196 to a stormwater storage tank before denying pushes. Leftover water 

197 in this tank aims to be treated in subsequent timesteps. 

198 - Can be pulled from to simulate active wastewater effluent use. 

199 

200 Input data and parameter requirements: 

201 - See `wtw.py/WTW` for treatment. 

202 - Stormwater tank `capacity`, `area`, and `datum`. 

203 _Units_: cubic metres, squared metres, metres 

204 """ 

205 # Set parameters 

206 self.stormwater_storage_capacity = stormwater_storage_capacity 

207 self.stormwater_storage_area = stormwater_storage_area 

208 self.stormwater_storage_elevation = stormwater_storage_elevation 

209 

210 # Update args 

211 super().__init__(**kwargs) 

212 

213 self.end_timestep = self.end_timestep_ 

214 

215 # Update handlers 

216 self.pull_set_handler["default"] = self.pull_set_reuse 

217 self.pull_check_handler["default"] = self.pull_check_reuse 

218 self.push_set_handler["Sewer"] = self.push_set_sewer 

219 self.push_check_handler["Sewer"] = self.push_check_sewer 

220 self.push_check_handler["default"] = self.push_check_sewer 

221 self.push_set_handler["default"] = self.push_set_sewer 

222 

223 # Create tank 

224 self.stormwater_tank = Tank( 

225 capacity=self.stormwater_storage_capacity, 

226 area=self.stormwater_storage_area, 

227 datum=self.stormwater_storage_elevation, 

228 ) 

229 

230 # Initialise states 

231 self.liquor_ = self.empty_vqip() 

232 self.previous_input = self.empty_vqip() 

233 self.current_input = self.empty_vqip() # TODO is this not done in WTW? 

234 

235 # Mass balance 

236 self.mass_balance_out.append(lambda: self.solids) # Assume these go to landfill 

237 self.mass_balance_ds.append(lambda: self.stormwater_tank.ds()) 

238 self.mass_balance_ds.append( 

239 lambda: self.ds_vqip(self.liquor, self.liquor_) 

240 ) # Change in liquor 

241 

242 def calculate_discharge(self): 

243 """Clear stormwater tank if possible, and call treat_current_input.""" 

244 # Run WWTW model 

245 

246 # Try to clear stormwater 

247 # TODO (probably more tidy to use push_set_sewer? though maybe less 

248 # computationally efficient) 

249 excess = self.get_excess_throughput() 

250 if (self.stormwater_tank.get_avail()["volume"] > constants.FLOAT_ACCURACY) & ( 

251 excess > constants.FLOAT_ACCURACY 

252 ): 

253 to_pull = min(excess, self.stormwater_tank.get_avail()["volume"]) 

254 to_pull = self.v_change_vqip(self.stormwater_tank.storage, to_pull) 

255 cleared_stormwater = self.stormwater_tank.pull_storage(to_pull) 

256 self.current_input = self.sum_vqip(self.current_input, cleared_stormwater) 

257 

258 # Run processes 

259 self.current_input = self.sum_vqip(self.current_input, self.liquor) 

260 self.treat_current_input() 

261 

262 def make_discharge(self): 

263 """Discharge treated effluent.""" 

264 reply = self.push_distributed(self.treated) 

265 self.treated = self.empty_vqip() 

266 if reply["volume"] > constants.FLOAT_ACCURACY: 

267 _ = self.stormwater_tank.push_storage(reply, force=True) 

268 print("WWTW couldnt push") 

269 

270 def push_check_sewer(self, vqip=None): 

271 """Check throughput and stormwater tank capacity. 

272 

273 Args: 

274 vqip (dict, optional): A VQIP that can be used to limit the volume in 

275 the return value (only volume key is used). Defaults to None. 

276 

277 Returns: 

278 (dict): excess 

279 """ 

280 # Get excess 

281 excess_throughput = self.get_excess_throughput() 

282 excess_tank = self.stormwater_tank.get_excess() 

283 # Combine tank and throughput 

284 vol = excess_tank["volume"] + excess_throughput 

285 # Update volume 

286 if vqip is None: 

287 vqip = self.empty_vqip() 

288 else: 

289 vol = min(vol, vqip["volume"]) 

290 

291 return self.v_change_vqip(vqip, vol) 

292 

293 def push_set_sewer(self, vqip): 

294 """Receive water, first try to update current_input, and then stormwater tank. 

295 

296 Args: 

297 vqip (dict): A VQIP amount to be treated and then stored 

298 

299 Returns: 

300 (dict): A VQIP amount of water that was not treated 

301 """ 

302 # Receive water from sewers 

303 vqip = self.copy_vqip(vqip) 

304 # Check if can directly be treated 

305 sent_direct = self.get_excess_throughput() 

306 

307 sent_direct = min(sent_direct, vqip["volume"]) 

308 

309 sent_direct = self.v_change_vqip(vqip, sent_direct) 

310 

311 self.current_input = self.sum_vqip(self.current_input, sent_direct) 

312 

313 if sent_direct["volume"] == vqip["volume"]: 

314 # If all added to input, no problem 

315 return self.empty_vqip() 

316 

317 # Next try temporary storage 

318 vqip = self.v_change_vqip(vqip, vqip["volume"] - sent_direct["volume"]) 

319 

320 vqip = self.stormwater_tank.push_storage(vqip) 

321 

322 if vqip["volume"] < constants.FLOAT_ACCURACY: 

323 return self.empty_vqip() 

324 else: 

325 # TODO what to do here ??? 

326 return vqip 

327 

328 def pull_set_reuse(self, vqip): 

329 """Enables WWTW to receive pulls of the treated water (i.e., for wastewater 

330 reuse or satisfaction of environmental flows). Intended to be called in between 

331 calculate_discharge and make_discharge. 

332 

333 Args: 

334 vqip (dict): A VQIP amount to be pulled (only 'volume' key is used) 

335 

336 Returns: 

337 reply (dict): Amount of water that has been pulled 

338 """ 

339 # Satisfy request with treated (volume) 

340 reply_vol = min(vqip["volume"], self.treated["volume"]) 

341 # Update pollutants 

342 reply = self.v_change_vqip(self.treated, reply_vol) 

343 # Update treated 

344 self.treated = self.v_change_vqip( 

345 self.treated, self.treated["volume"] - reply_vol 

346 ) 

347 return reply 

348 

349 def pull_check_reuse(self, vqip=None): 

350 """Pull check available water. Simply returns the previous timestep's treated 

351 throughput. This is of course inaccurate (which may lead to slightly longer 

352 calulcations), but it is much more flexible. This hasn't been recently tested so 

353 it might be that returning treated would be fine (and more accurate!). 

354 

355 Args: 

356 vqip (dict, optional): A VQIP that can be used to limit the volume in 

357 the return value (only volume key is used). Defaults to None. 

358 

359 Returns: 

360 (dict): A VQIP amount of water available. Currently just the previous 

361 timestep's treated throughput 

362 """ 

363 # Respond to request of water for reuse/MRF 

364 return self.copy_vqip(self.treated) 

365 

366 def end_timestep_(self): 

367 """End timestep function to update state variables.""" 

368 self.liquor_ = self.copy_vqip(self.liquor) 

369 self.previous_input = self.copy_vqip(self.current_input) 

370 self.current_input = self.empty_vqip() 

371 self.solids = self.empty_vqip() 

372 self.stormwater_tank.end_timestep() 

373 

374 

375class FWTW(WTW): 

376 """""" 

377 

378 def __init__( 

379 self, 

380 service_reservoir_storage_capacity=10, 

381 service_reservoir_storage_area=1, 

382 service_reservoir_storage_elevation=10, 

383 service_reservoir_initial_storage=0, 

384 data_input_dict={}, 

385 **kwargs, 

386 ): 

387 """A freshwater treatment works wrapper for WTW. Contains service reservoirs 

388 that treated water is released to and pulled from. Cannot allow deficit (thus 

389 any deficit is satisfied by water entering the model 'via other means'). Liquor 

390 and solids are sent to sewers. 

391 

392 Args: 

393 service_reservoir_storage_capacity (float, optional): Capacity of service 

394 reservoirs. Defaults to 10. 

395 service_reservoir_storage_area (float, optional): Area of service 

396 reservoirs. Defaults to 1. 

397 service_reservoir_storage_elevation (float, optional): Datum of service 

398 reservoirs. Defaults to 10. 

399 service_reservoir_initial_storage (float or dict, optional): initial 

400 storage of service reservoirs (see nodes.py/Tank for details). 

401 Defaults to 0. 

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

403 the node (though I don't think it is used). Defaults to {}. 

404 

405 Functions intended to call in orchestration: 

406 treat_water 

407 

408 Key assumptions: 

409 - See `wtw.py/WTW` for treatment. 

410 - Stores treated water in a service reservoir tank, with a single tank 

411 per `FWTW` node. 

412 - Aims to satisfy a throughput that would top up the service reservoirs 

413 until full. 

414 - Currently, will not allow a deficit, thus introducing water from 

415 'other measures' if pulls cannot fulfil demand. Behaviour under a 

416 deficit should be determined and validated before introducing. 

417 

418 Input data and parameter requirements: 

419 - See `wtw.py/WTW` for treatment. 

420 - Service reservoir tank `capacity`, `area`, and `datum`. 

421 _Units_: cubic metres, squared metres, metres 

422 """ 

423 # Default parameters 

424 self.service_reservoir_storage_capacity = service_reservoir_storage_capacity 

425 self.service_reservoir_storage_area = service_reservoir_storage_area 

426 self.service_reservoir_storage_elevation = service_reservoir_storage_elevation 

427 self.service_reservoir_initial_storage = service_reservoir_initial_storage 

428 # TODO don't think data_input_dict is used 

429 self.data_input_dict = data_input_dict 

430 

431 # Update args 

432 super().__init__(**kwargs) 

433 self.end_timestep = self.end_timestep_ 

434 

435 # Update handlers 

436 self.pull_set_handler["default"] = self.pull_set_fwtw 

437 self.pull_check_handler["default"] = self.pull_check_fwtw 

438 

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

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

441 

442 # Initialise parameters 

443 self.total_deficit = self.empty_vqip() 

444 self.total_pulled = self.empty_vqip() 

445 self.previous_pulled = self.empty_vqip() 

446 self.unpushed_sludge = self.empty_vqip() 

447 

448 # Create tanks 

449 self.service_reservoir_tank = Tank( 

450 capacity=self.service_reservoir_storage_capacity, 

451 area=self.service_reservoir_storage_area, 

452 datum=self.service_reservoir_storage_elevation, 

453 initial_storage=self.service_reservoir_initial_storage, 

454 ) 

455 # self.service_reservoir_tank.storage['volume'] = 

456 # self.service_reservoir_inital_storage 

457 # self.service_reservoir_tank.storage_['volume'] = 

458 # self.service_reservoir_inital_storage 

459 

460 # Mass balance 

461 self.mass_balance_in.append(lambda: self.total_deficit) 

462 self.mass_balance_ds.append(lambda: self.service_reservoir_tank.ds()) 

463 self.mass_balance_out.append(lambda: self.unpushed_sludge) 

464 

465 def treat_water(self): 

466 """Pulls water, aiming to fill service reservoirs, calls WTW 

467 treat_current_input, avoids deficit, sends liquor and solids to sewers.""" 

468 # Calculate how much water is needed 

469 target_throughput = self.service_reservoir_tank.get_excess() 

470 target_throughput = min( 

471 target_throughput["volume"], self.treatment_throughput_capacity 

472 ) 

473 

474 # Pull water 

475 throughput = self.pull_distributed({"volume": target_throughput}) 

476 

477 # Calculate deficit (assume is equal to difference between previous treated 

478 # throughput and current throughput) 

479 # TODO think about this a bit more 

480 deficit = max(target_throughput - throughput["volume"], 0) 

481 # deficit = max(self.previous_pulled['volume'] - throughput['volume'], 0) 

482 deficit = self.v_change_vqip(self.previous_pulled, deficit) 

483 

484 # Introduce deficit 

485 self.current_input = self.sum_vqip(throughput, deficit) 

486 

487 # Track deficit 

488 self.total_deficit = self.sum_vqip(self.total_deficit, deficit) 

489 

490 if self.total_deficit["volume"] > constants.FLOAT_ACCURACY: 

491 print( 

492 "Service reservoirs not filled at {0} on {1}".format(self.name, self.t) 

493 ) 

494 

495 # Run treatment processes 

496 self.treat_current_input() 

497 

498 # Discharge liquor and solids to sewers 

499 push_back = self.sum_vqip(self.liquor, self.solids) 

500 rejected = self.push_distributed(push_back, of_type="Sewer") 

501 self.unpushed_sludge = self.sum_vqip(self.unpushed_sludge, rejected) 

502 if rejected["volume"] > constants.FLOAT_ACCURACY: 

503 print("nowhere for sludge to go") 

504 

505 # Send water to service reservoirs 

506 excess = self.service_reservoir_tank.push_storage(self.treated) 

507 _ = self.service_reservoir_tank.push_storage(excess, force=True) 

508 if excess["volume"] > 0: 

509 print("excess treated water") 

510 

511 def pull_check_fwtw(self, vqip=None): 

512 """Pull checks query service reservoirs. 

513 

514 Args: 

515 vqip (dict, optional): A VQIP that can be used to limit the volume in 

516 the return value (only volume key is used). Defaults to None. 

517 

518 Returns: 

519 (dict): A VQIP of availability in service reservoirs 

520 """ 

521 return self.service_reservoir_tank.get_avail(vqip) 

522 

523 def pull_set_fwtw(self, vqip): 

524 """Pull treated water from service reservoirs. 

525 

526 Args: 

527 vqip (dict): a VQIP amount to pull 

528 

529 Returns: 

530 pulled (dict): A VQIP amount that was successfully pulled 

531 """ 

532 # Pull 

533 pulled = self.service_reservoir_tank.pull_storage(vqip) 

534 # Update total_pulled this timestep 

535 self.total_pulled = self.sum_vqip(self.total_pulled, pulled) 

536 return pulled 

537 

538 def end_timestep_(self): 

539 """Update state variables.""" 

540 self.service_reservoir_tank.end_timestep() 

541 self.total_deficit = self.empty_vqip() 

542 self.previous_pulled = self.copy_vqip(self.total_pulled) 

543 self.total_pulled = self.empty_vqip() 

544 self.treated = self.empty_vqip() 

545 self.unpushed_sludge = self.empty_vqip() 

546 

547 def reinit(self): 

548 """Call tank reinit.""" 

549 self.service_reservoir_tank.reinit()