Coverage for wsimod/nodes/land.py: 9%

713 statements  

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

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

2"""Created on Fri May 20 08:58:58 2022. 

3 

4@author: Barney 

5""" 

6import sys 

7from bisect import bisect_left 

8from math import exp, log, log10, sin 

9 

10from wsimod.core import constants 

11from wsimod.nodes.nodes import DecayTank, Node, ResidenceTank 

12from wsimod.nodes.nutrient_pool import NutrientPool 

13 

14 

15class Land(Node): 

16 """""" 

17 

18 def __init__( 

19 self, 

20 name, 

21 subsurface_residence_time=5, 

22 percolation_residence_time=50, 

23 surface_residence_time=1, 

24 surfaces=[], 

25 data_input_dict={}, 

26 ): 

27 """An extensive node class that represents land processes (agriculture, soil, 

28 subsurface flow, rural runoff, urban drainage, pollution deposition). The 

29 expected use is that each distinctive type of land cover (different crop types, 

30 gardens, forests, impervious urban drainage, etc.) each have a Surface object, 

31 which is a subclass of Tank. The land node will iterate over its surfaces each 

32 timestep, which will generally (except in the case of an impervious surface) 

33 send water to three common Tanks: surface flow, subsurface flow and percolation. 

34 These tanks will then send flows to rivers or groundwater. 

35 

36 (See wsimod/nodes/land.py/Surface and subclasses for currently available 

37 surfaces) 

38 

39 Args: 

40 name (str): node name. subsurface_residence_time (float, optional): 

41 Residence time for 

42 subsurface flow (see nodes.py/ResidenceTank). Defaults to 5. 

43 percolation_residence_time (int, optional): Residence time for 

44 percolation flow (see nodes.py/ResidenceTank). Defaults to 50. 

45 surface_residence_time (int, optional): Residence time for surface flow 

46 (see nodes.py/ResidenceTank). Defaults to 1. 

47 surfaces (list, optional): list of dicts where each dict describes the 

48 parameters of each surface in the Land node. Each dict also contains an 

49 entry under 'type_' which describes which subclass of surface to use. 

50 Defaults to []. 

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

52 the node (generally, et0, precipitation and temperature). Keys are 

53 tuples where first value is the name of the variable to read from the 

54 dict and the second value is the time. Defaults to {}. 

55 

56 Functions intended to call in orchestration: 

57 run apply_irrigation (if used) 

58 

59 Key assumptions: 

60 - Percolation, surface runoff, and subsurface runoff, can be described with 

61 a residence-time method. 

62 - Flows to percolation, surface runoff, and subsurface runoff are 

63 generated by different hydrological response units (subclasses of 

64 `land.py/Surface`), but aggregated for a given land node. 

65 - Flows to percolation are distributed to `storage.py/Groundwater` 

66 nodes while surface/subsurface runoff to `nodes.py/Node` or 

67 `storage.py/River` nodes. 

68 - Input data associated with the land node (precipitation, 

69 temperature, evapotranspiartion) are the same for every surface. 

70 - Water received from `sewer.py/Sewer` objects is sent to the first 

71 `land.py/ImperviousSurface` in the surfaces list. 

72 

73 Input data and parameter requirements: 

74 - Precipitation and evapotranspiration are in the `data_input_dict` 

75 at the model timestep. _Units_: metres/timestep 

76 - Temperature in the `data_input_dict` at the model timestep. 

77 _Units_: C 

78 - Residence time of surface, subsurface and percolation flows. 

79 _Units_: number of timesteps 

80 """ 

81 # Assign parameters 

82 self.subsurface_residence_time = subsurface_residence_time 

83 self.percolation_residence_time = percolation_residence_time 

84 self.surface_residence_time = surface_residence_time 

85 self.data_input_dict = data_input_dict 

86 

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

88 

89 # This could be a deny but then you would have to know in advance whether a 

90 # demand node has any gardening or not 

91 self.push_check_handler[("Demand", "Garden")] = lambda x: self.empty_vqip() 

92 self.push_set_handler[("Demand", "Garden")] = lambda x: self.empty_vqip() 

93 

94 # Create surfaces 

95 self.irrigation_functions = [lambda: None] 

96 

97 surfaces_ = surfaces.copy() 

98 surfaces = [] 

99 for surface in surfaces_: 

100 # Assign parent (for data reading and to determine where to send flows to) 

101 surface["parent"] = self 

102 

103 # Get surface type 

104 type_ = surface["type_"] 

105 del surface["type_"] 

106 

107 # Instantiate surface and store in list of surfaces 

108 surfaces.append(getattr(sys.modules[__name__], type_)(**surface)) 

109 

110 # Assign ds (mass balance checking) 

111 self.mass_balance_ds.append(surfaces[-1].ds) 

112 

113 # Assign any irrigation functions 

114 if isinstance(surfaces[-1], IrrigationSurface): 

115 # TODO, this should probably be done in the Surface initialisation 

116 self.irrigation_functions.append(surfaces[-1].irrigate) 

117 

118 # Assign garden surface functions 

119 if isinstance(surfaces[-1], GardenSurface): 

120 # TODO, this should probably be done in the Surface initialisation 

121 self.push_check_handler[("Demand", "Garden")] = surfaces[ 

122 -1 

123 ].calculate_irrigation_demand 

124 self.push_set_handler[("Demand", "Garden")] = surfaces[ 

125 -1 

126 ].receive_irrigation_demand 

127 

128 # Update handlers 

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

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

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

132 

133 # Create subsurface runoff, surface runoff and percolation tanks Can also do as 

134 # timearea if this seems dodge (that is how it is done in IHACRES) TODO should 

135 # these be decayresidencetanks? 

136 self.subsurface_runoff = ResidenceTank( 

137 residence_time=self.subsurface_residence_time, 

138 capacity=constants.UNBOUNDED_CAPACITY, 

139 ) 

140 self.percolation = ResidenceTank( 

141 residence_time=self.percolation_residence_time, 

142 capacity=constants.UNBOUNDED_CAPACITY, 

143 ) 

144 self.surface_runoff = ResidenceTank( 

145 residence_time=self.surface_residence_time, 

146 capacity=constants.UNBOUNDED_CAPACITY, 

147 ) 

148 

149 # Store surfaces 

150 self.surfaces = surfaces 

151 

152 # Mass balance checkign vqips and functions 

153 self.running_inflow_mb = self.empty_vqip() 

154 self.running_outflow_mb = self.empty_vqip() 

155 

156 self.mass_balance_in.append(lambda: self.running_inflow_mb) 

157 self.mass_balance_out.append(lambda: self.running_outflow_mb) 

158 self.mass_balance_ds.append(self.surface_runoff.ds) 

159 self.mass_balance_ds.append(self.subsurface_runoff.ds) 

160 self.mass_balance_ds.append(self.percolation.ds) 

161 

162 def apply_irrigation(self): 

163 """Iterate over any irrigation functions (needs further testing.. 

164 

165 maybe). 

166 """ 

167 for f in self.irrigation_functions: 

168 f() 

169 

170 def run(self): 

171 """Call the run function in all surfaces, update surface/subsurface/ percolation 

172 tanks, discharge to rivers/groundwater.""" 

173 # Run all surfaces 

174 for surface in self.surfaces: 

175 surface.run() 

176 

177 # Apply residence time to percolation 

178 percolation = self.percolation.pull_outflow() 

179 

180 # Distribute percolation 

181 reply = self.push_distributed(percolation, of_type=["Groundwater"]) 

182 

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

184 # Update percolation 'tank' 

185 _ = self.percolation.push_storage(reply, force=True) 

186 

187 # Apply residence time to subsurface/surface runoff 

188 surface_runoff = self.surface_runoff.pull_outflow() 

189 subsurface_runoff = self.subsurface_runoff.pull_outflow() 

190 

191 # Total runoff 

192 total_runoff = self.sum_vqip(surface_runoff, subsurface_runoff) 

193 if total_runoff["volume"] > 0: 

194 # Send to rivers (or nodes, which are assumed to be junctions) 

195 reply = self.push_distributed(total_runoff, of_type=["River", "Node"]) 

196 

197 # Redistribute total_runoff not sent 

198 if reply["volume"] > 0: 

199 reply_surface = self.v_change_vqip( 

200 reply, 

201 reply["volume"] * surface_runoff["volume"] / total_runoff["volume"], 

202 ) 

203 reply_subsurface = self.v_change_vqip( 

204 reply, 

205 reply["volume"] 

206 * subsurface_runoff["volume"] 

207 / total_runoff["volume"], 

208 ) 

209 

210 # Update surface/subsurface runoff 'tanks' 

211 if reply_surface["volume"] > 0: 

212 self.surface_runoff.push_storage(reply_surface, force=True) 

213 if reply_subsurface["volume"] > 0: 

214 self.subsurface_runoff.push_storage(reply_subsurface, force=True) 

215 

216 def push_set_sewer(self, vqip): 

217 """Receive water from a sewer and send it to the first ImperviousSurface in 

218 surfaces. 

219 

220 Args: 

221 vqip (dict): A VQIP amount to be sent to the impervious surface 

222 

223 Returns: 

224 vqip (dict): A VQIP amount of water that was not received 

225 """ 

226 # TODO currently just push to the first impervious surface... not sure if people 

227 # will be having multiple impervious surfaces. If people would be only having 

228 # one then it would make sense to store as a parameter... this is probably fine 

229 # for now 

230 for surface in self.surfaces: 

231 if isinstance(surface, ImperviousSurface): 

232 vqip = self.surface.push_storage(vqip, force=True) 

233 break 

234 return vqip 

235 

236 def end_timestep(self): 

237 """Update mass balance and end timestep of all tanks (and surfaces).""" 

238 self.running_inflow_mb = self.empty_vqip() 

239 self.running_outflow_mb = self.empty_vqip() 

240 for tanks in self.surfaces + [ 

241 self.surface_runoff, 

242 self.subsurface_runoff, 

243 self.percolation, 

244 ]: 

245 tanks.end_timestep() 

246 

247 def get_surface(self, surface_): 

248 """Return a surface from the list of surfaces by the 'surface' entry in the 

249 surface. I.e., the name of the surface. 

250 

251 Args: 

252 surface_ (str): Name of the surface 

253 

254 Returns: 

255 surface (Surface): The first surface that matches the name 

256 """ 

257 for surface in self.surfaces: 

258 if surface.surface == surface_: 

259 return surface 

260 return None 

261 

262 def reinit(self): 

263 """""" 

264 self.end_timestep() 

265 for surface in self.surfaces + [ 

266 self.surface_runoff, 

267 self.subsurface_runoff, 

268 self.percolation, 

269 ]: 

270 surface.reinit() 

271 

272 

273class Surface(DecayTank): 

274 """""" 

275 

276 def __init__( 

277 self, 

278 surface="", 

279 area=1, 

280 depth=1, 

281 data_input_dict={}, 

282 pollutant_load={}, 

283 **kwargs, 

284 ): 

285 """A subclass of DecayTank. Each Surface is anticipated to represent a different 

286 land cover type of a Land node. Besides functioning as a Tank, Surfaces have 

287 three lists of functions (inflows, processes and outflows) where behaviour can 

288 be added by appending new functions. We anticipate that customised surfaces 

289 should be a subclass of Surface or its subclasses and add functions to these 

290 lists. These lists are executed (inflows first, then processes, then outflows) 

291 in the run function, which is called by the run function in Land. The lists must 

292 return any model inflows or outflows as a VQIP for mass balance checking. 

293 

294 If a user wishes the DecayTank portion to be active, then can provide 'decays', 

295 which are passed upwards (see wsimod/core/core.py/DecayObj for documentation) 

296 

297 Args: 

298 surface (str, optional): String description of the surface type. Doesn't 

299 serve a modelling purpose, just used for user reference. Defaults to ''. 

300 area (float, optional): Area of surface. Defaults to 1. depth (float, 

301 optional): Depth of tank (this has different physical 

302 implications for different subclasses). Defaults to 1. 

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

304 the surface (generally, deposition). Keys are tuples where first value 

305 is the name of the variable to read from the dict and the second value 

306 is the time. Note that this input should be specific to the surface, and 

307 is not intended to be the same data input as for the land node. Also 

308 note that with each surface having its own timeseries of data inputs, 

309 this can take up a lot of memory, thus the default behavior is to have 

310 this as monthly data where the time variable is a monthyear. Defaults to 

311 {}. 

312 pollutant_load (dict, optional): A dict of different pollutant amounts that 

313 are deposited on the surface (units are mass per area per timestep). 

314 Defaults to {}. 

315 

316 Key assumptions: 

317 - Generic `Surface` that reads data and can apply simple forms of pollution 

318 deposition. 

319 - Formulated as a `Tank` object. 

320 - Ammonia->Nitrite->Nitrate decay takes place if parameters describing this 

321 process are provided in `decays` (see `core.py/DecayObj` for 

322 transformation details). 

323 

324 Input data and parameter requirements: 

325 - `data_input_dict` can contain a variety of pollutant deposition data. 

326 `srp-dry` describes phosphate. `noy-dry` describes nitrogen as nitrates. 

327 `nhx-dry` describes nitrogen as ammonia. `srp/noy/ nhx-wet` can also be 

328 used to specify wet deposition. _Units_: kg/m2/timestep (data is read at 

329 a monthly timestep) 

330 """ 

331 # Assign parameters 

332 self.depth = depth 

333 self.data_input_dict = data_input_dict 

334 self.surface = surface 

335 self.pollutant_load = pollutant_load 

336 # TODO this is a decaytank but growing surfaces don't have decay parameters... 

337 # is it a problem.. we don't even take decays as an explicit argument and insert 

338 # them in kwargs.. 

339 capacity = area * depth 

340 # Parameters 

341 super().__init__(capacity=capacity, area=area, **kwargs) 

342 

343 # Populate function lists TODO.. not sure why I have deposition but no 

344 # precipitation here 

345 if "nhx-dry" in set(x[0] for x in data_input_dict.keys()): 

346 self.inflows = [self.atmospheric_deposition, self.precipitation_deposition] 

347 else: 

348 self.inflows = [] 

349 if len(self.pollutant_load) > 0: 

350 self.inflows.append(self.simple_deposition) 

351 self.processes = [] 

352 self.outflows = [] 

353 

354 def run(self): 

355 """Call run function (called from Land node).""" 

356 if "nitrite" in constants.POLLUTANTS: 

357 # Assume that if nitrite is modelled then nitrification is also modelled You 

358 # will need ammonia->nitrite->nitrate decay to accurate simulate ammonia 

359 # Thus, these decays are simulated here 

360 

361 # NOTE decay in a decaytank happens at start of timestep (confusingly) in 

362 # the end_timestep function 

363 self.storage["nitrate"] += self.total_decayed["nitrite"] 

364 self.parent.running_inflow_mb["nitrate"] += self.total_decayed["nitrite"] 

365 

366 # Decayed ammonia becomes nitrite 

367 self.storage["nitrite"] += self.total_decayed["ammonia"] 

368 self.parent.running_inflow_mb["nitrite"] += self.total_decayed["ammonia"] 

369 

370 for f in self.inflows + self.processes + self.outflows: 

371 # Iterate over function lists, updating mass balance 

372 in_, out_ = f() 

373 self.parent.running_inflow_mb = self.sum_vqip( 

374 self.parent.running_inflow_mb, in_ 

375 ) 

376 self.parent.running_outflow_mb = self.sum_vqip( 

377 self.parent.running_outflow_mb, out_ 

378 ) 

379 

380 def get_data_input(self, var): 

381 """Read data input from parent Land node (i.e., for precipitation/et0/temp). 

382 

383 Args: 

384 var (str): Name of variable 

385 

386 Returns: 

387 Data read 

388 """ 

389 return self.parent.get_data_input(var) 

390 

391 def get_data_input_surface(self, var): 

392 """Read data input from this surface's data_input_dict. 

393 

394 Args: 

395 var (str): Name of variable 

396 

397 Returns: 

398 Data read 

399 """ 

400 return self.data_input_dict[(var, self.parent.monthyear)] 

401 

402 def dry_deposition_to_tank(self, vqip): 

403 """Generic function for allocating dry pollution deposition to the surface. 

404 Simply sends the pollution into the tank (some subclasses overwrite this 

405 behaviour). 

406 

407 Args: 

408 vqip (dict): A VQIP amount of dry deposition to send to tank 

409 

410 Returns: 

411 vqip (dict): A VQIP amount of dry deposition that entered the tank (used 

412 for mass balance checking) 

413 """ 

414 # Default behaviour is to just enter the tank 

415 _ = self.push_storage(vqip, force=True) 

416 return vqip 

417 

418 def wet_deposition_to_tank(self, vqip): 

419 """Generic function for allocating wet pollution deposition to the surface. 

420 Simply sends the pollution into the tank (some subclasses overwrite this 

421 behaviour). 

422 

423 Args: 

424 vqip (dict): A VQIP amount of wet deposition to send to tank 

425 

426 Returns: 

427 vqip (dict): A VQIP amount of wet deposition that entered the tank (used 

428 for mass balance checking) 

429 """ 

430 # Default behaviour is to just enter the tank 

431 _ = self.push_storage(vqip, force=True) 

432 return vqip 

433 

434 def simple_deposition(self): 

435 """Inflow function to cause simple pollution deposition to occur, updating the 

436 surface tank. 

437 

438 Returns: 

439 (tuple): A tuple containing a VQIP amount for model inputs and outputs 

440 for mass balance checking. 

441 """ 

442 pollution = self.empty_vqip() 

443 

444 # Scale by area 

445 for pol, item in self.pollutant_load.items(): 

446 if pol in constants.ADDITIVE_POLLUTANTS: 

447 pollution[pol] = item * self.area 

448 else: 

449 pollution[pol] = item 

450 pollution["volume"] = 0 

451 

452 # Update tank 

453 _ = self.push_storage(pollution, force=True) 

454 

455 return (pollution, self.empty_vqip()) 

456 

457 def atmospheric_deposition(self): 

458 """Inflow function to cause dry atmospheric deposition to occur, updating the 

459 surface tank. 

460 

461 Returns: 

462 (tuple): A tuple containing a VQIP amount for model inputs and outputs 

463 for mass balance checking. 

464 """ 

465 # TODO double check units in preprocessing - is weight of N or weight of 

466 # NHX/noy? 

467 

468 # Read data and scale 

469 nhx = self.get_data_input_surface("nhx-dry") * self.area 

470 noy = self.get_data_input_surface("noy-dry") * self.area 

471 srp = self.get_data_input_surface("srp-dry") * self.area 

472 

473 # Assign pollutants 

474 vqip = self.empty_vqip() 

475 vqip["ammonia"] = nhx 

476 vqip["nitrate"] = noy 

477 vqip["phosphate"] = srp 

478 

479 # Update tank 

480 in_ = self.dry_deposition_to_tank(vqip) 

481 

482 # Return mass balance 

483 return (in_, self.empty_vqip()) 

484 

485 def precipitation_deposition(self): 

486 """Inflow function to cause wet precipitation deposition to occur, updating the 

487 surface tank. 

488 

489 Returns: 

490 (tuple): A tuple containing a VQIP amount for model inputs and outputs 

491 for mass balance checking. 

492 """ 

493 # TODO double check units - is weight of N or weight of NHX/noy? 

494 

495 # Read data and scale 

496 nhx = self.get_data_input_surface("nhx-wet") * self.area 

497 noy = self.get_data_input_surface("noy-wet") * self.area 

498 srp = self.get_data_input_surface("srp-wet") * self.area 

499 

500 # Assign pollutants 

501 vqip = self.empty_vqip() 

502 vqip["ammonia"] = nhx 

503 vqip["nitrate"] = noy 

504 vqip["phosphate"] = srp 

505 

506 # Update tank 

507 in_ = self.wet_deposition_to_tank(vqip) 

508 

509 # Return mass balance 

510 return (in_, self.empty_vqip()) 

511 

512 

513class ImperviousSurface(Surface): 

514 """""" 

515 

516 def __init__(self, pore_depth=0, et0_to_e=1, **kwargs): 

517 """A surface to represent impervious surfaces that drain to storm sewers. Runoff 

518 is generated by the surface tank overflowing, if a user wants all precipitation 

519 to immediately go to runoff then they should reduce 'pore_depth', however 

520 generally this is not what happens and a small (a few mm) depth should be 

521 assigned to the tank. Also includes urban pollution deposition, though this will 

522 only be mobilised if runoff occurs. 

523 

524 Note that the tank does not have a runoff coefficient because it doesn't make 

525 sense from an integrated perspective. If a user wants to mimic runoff 

526 coefficient-like behaviour, then they should reduce the ImperviousSurface tank 

527 size, and increase other surfaces of the parent land node accordingly. 

528 

529 Args: 

530 pore_depth (float, optional): The depth of the tank that must be exceeded 

531 to generate runoff. Intended to represent the pores in ashpalt that 

532 water accumulates in before flowing. Defaults to 0. 

533 et0_to_e (float, optional): Multiplier applied to the parent's data 

534 timeseries of et0 to determine how much evaporation takes place on the 

535 ImperviousSurface. Defaults to 1. 

536 """ 

537 # Assign parameters 

538 self.et0_to_e = et0_to_e # Total evaporation 

539 self.pore_depth = pore_depth 

540 

541 super().__init__(depth=pore_depth, **kwargs) 

542 

543 # Initialise state variables 

544 self.evaporation = self.empty_vqip() 

545 self.precipitation = self.empty_vqip() 

546 

547 # Populate function lists 

548 self.inflows.append(self.precipitation_evaporation) 

549 

550 self.outflows.append(self.push_to_sewers) 

551 

552 def precipitation_evaporation(self): 

553 """Inflow function that is a simple rainfall-evaporation model, updating the. 

554 

555 surface tank. All precipitation that is not evaporated is forced into the tank 

556 (even though some of that will later be pushed to sewers) - this enables runoff 

557 to mix with the accumulated pollutants in the surface pores. 

558 

559 Returns: 

560 (tuple): A tuple containing a VQIP amount for model inputs and outputs 

561 for mass balance checking. 

562 """ 

563 # Read data in length units 

564 precipitation_depth = self.get_data_input("precipitation") 

565 evaporation_depth = self.get_data_input("et0") * self.et0_to_e 

566 

567 if precipitation_depth < evaporation_depth: 

568 # No effective precipitation 

569 net_precipitation = 0 

570 

571 # Calculate how much should be evaporated from pores 

572 evaporation_from_pores = evaporation_depth - precipitation_depth 

573 

574 # Scale 

575 evaporation_from_pores *= self.area 

576 

577 # Pull from tank 

578 evaporation_from_pores = self.evaporate(evaporation_from_pores) 

579 

580 # Scale to get actual evaporation 

581 total_evaporation = evaporation_from_pores + precipitation_depth * self.area 

582 else: 

583 # Effective precipitation 

584 net_precipitation = precipitation_depth - evaporation_depth 

585 

586 # Scale 

587 net_precipitation *= self.area 

588 net_precipitation = self.v_change_vqip(self.empty_vqip(), net_precipitation) 

589 

590 # Assign a temperature value TODO how hot is rain? No idea... just going to 

591 # use surface air temperature 

592 net_precipitation["temperature"] = self.get_data_input("temperature") 

593 

594 # Update tank 

595 _ = self.push_storage(net_precipitation, force=True) 

596 total_evaporation = evaporation_depth * self.area 

597 

598 # Converrt to VQIP 

599 self.evaporation = self.v_change_vqip(self.empty_vqip(), total_evaporation) 

600 self.precipitation = self.v_change_vqip( 

601 self.empty_vqip(), precipitation_depth * self.area 

602 ) 

603 

604 return (self.precipitation, self.evaporation) 

605 

606 def push_to_sewers(self): 

607 """Outflow function that distributes ponded water (i.e., surface runoff) to the 

608 parent node's attached sewers. 

609 

610 Returns: 

611 (tuple): A tuple containing a VQIP amount for model inputs and outputs 

612 for mass balance checking. 

613 """ 

614 # Get runoff 

615 surface_runoff = self.pull_ponded() 

616 

617 # Distribute TODO in cwsd_partition this is done with timearea 

618 reply = self.parent.push_distributed(surface_runoff, of_type=["Sewer"]) 

619 

620 # Update tank (forcing, because if the water can't go to the sewer, where else 

621 # can it go) 

622 _ = self.push_storage(reply, force=True) 

623 # TODO... possibly this could flow to attached river or land nodes.. or other 

624 # surfaces? I expect this doesn't matter for large scale models.. but may need 

625 # to be revisited for detailed sewer models 

626 

627 # Return empty mass balance because outflows are handled by parent 

628 return (self.empty_vqip(), self.empty_vqip()) 

629 

630 

631class PerviousSurface(Surface): 

632 """""" 

633 

634 def __init__( 

635 self, 

636 depth=0.75, 

637 total_porosity=0.4, 

638 field_capacity=0.3, 

639 wilting_point=0.12, 

640 infiltration_capacity=0.5, 

641 surface_coefficient=0.05, 

642 percolation_coefficient=0.75, 

643 et0_coefficient=0.5, 

644 ihacres_p=10, 

645 **kwargs, 

646 ): 

647 """A generic pervious surface that represents hydrology with the IHACRES model. 

648 

649 Args: 

650 depth (float, optional): Soil tank (i.e., root) depth. Defaults to 0.75. 

651 total_porosity (float, optional): The total porosity IHACRES parameter 

652 (i.e., defines the total porouse volume of the soil - the maximum volume 

653 of soil pores can contain when saturated). Defaults to 0.4. 

654 field_capacity (float, optional): The field capacity IHACRES parameter 

655 (i.e., when water content in the soil tank is above this value - flows 

656 of any kind can be generated). Defaults to 0.3. 

657 wilting_point (float, optional): The wilting point IHACRES parameter (i.e., 

658 when water content content in the soil tank is above this value - plants 

659 can uptake water and evaporation from the soil tank can occur). Defaults 

660 to 0.12. 

661 infiltration_capacity (float, optional): Depth of water per day that can 

662 enter the soil tank. Non infiltrated water will pond and travel as 

663 surface runoff from the parent Land node. Defaults to 0.5. 

664 surface_coefficient (float, optional): If flow is generated, the proportion 

665 of flow that goes to surface runoff. Defaults to 0.05. 

666 percolation_coefficient (float, optional): If flow is generated, then the 

667 proportion of water that does not go to surface runoff that goes to 

668 percolation (i.e., groundwater) - the remainder goes to subsurface 

669 runoff. Defaults to 0.75. 

670 et0_coefficient (float, optional): Convert between the parent nodes data 

671 timeseries et0 - and potential evaptranspiration per unit area for this 

672 surface. Defaults to=0.5, 

673 ihacres_p (float, optional): The IHACRES p parameter. Unless it is an 

674 ephemeral stream this parameter probably can stay high. Defaults to 10. 

675 

676 Key assumptions: 

677 - In IHACRES, the maximum infiltration per time step is controlled by an 

678 infiltration capacity, beyond which the precipitation will flow directly 

679 as surface runoff. 

680 - Evapotranspiration and effective precipitation are calculated based on 

681 soil moisture content. 

682 - Effective precipitation is then divided into percolation, surface runoff, 

683 and subsurface runoff by multiplying the corresponding coefficient. 

684 - Percolation, surface runoff, and subsurface runoff are sent into the 

685 corresponding residence tanks for rounting to downstream. 

686 - The mass of pollutants in soil water tank proportionately leaves the 

687 soil water tank into the routing residence tanks. Evapotranspiration can 

688 only bring out water, with pollutants left in the soil tank. 

689 

690 Input data and parameter requirements: 

691 - Field capacity and wilting point. 

692 _Units_: -, both should in [0-1], with field capacity > wilting point 

693 - Infiltration capacity. 

694 _Units_: m/day 

695 - Surface, percolation coefficient. 

696 _Units_: -, both should in [0-1] 

697 - et0 coefficient. 

698 _Units_: - 

699 - ihacres_p. 

700 _Units_: - 

701 """ 

702 # Assign parameters (converting field capacity and wilting point to depth) 

703 self.field_capacity = field_capacity 

704 self.field_capacity_m = field_capacity * depth 

705 self.wilting_point = wilting_point 

706 self.wilting_point_m = wilting_point * depth 

707 self.infiltration_capacity = infiltration_capacity 

708 self.surface_coefficient = surface_coefficient 

709 self.percolation_coefficient = percolation_coefficient 

710 self.et0_coefficient = et0_coefficient 

711 self.ihacres_p = ihacres_p 

712 self.total_porosity = total_porosity 

713 

714 # Parameters to determine how to calculate the temperature of outflowing water 

715 # TODO what should these params be? 

716 self.soil_temp_w_prev = 0.1 # previous timestep weighting 

717 self.soil_temp_w_air = 0.6 # air temperature weighting 

718 self.soil_temp_w_deep = 0.1 # deep soil temperature weighting 

719 self.soil_temp_deep = 10 # deep soil temperature 

720 

721 # IHACRES is a deficit not a tank, so doesn't really have a capacity in this 

722 # way... and if it did.. I don't know if it would be the root depth 

723 super().__init__(depth=depth * total_porosity, **kwargs) 

724 

725 # Calculate subsurface coefficient 

726 self.subsurface_coefficient = 1 - self.percolation_coefficient 

727 

728 # Initiliase state variables 

729 self.infiltration_excess = self.empty_vqip() 

730 self.subsurface_flow = self.empty_vqip() 

731 self.percolation = self.empty_vqip() 

732 self.tank_recharge = 0 

733 self.evaporation = self.empty_vqip() 

734 self.precipitation = self.empty_vqip() 

735 

736 # Populate function lists 

737 self.inflows.append(self.ihacres) # work out runoff 

738 

739 # TODO interception if I hate myself enough? 

740 self.processes.append( 

741 self.calculate_soil_temperature 

742 ) # Calculate soil temp + dependence factor 

743 

744 # self.processes.append(self.decay) #apply generic decay (currently handled by 

745 # decaytank at end of timestep) TODO decaytank uses air temperature not soil 

746 # temperature... probably need to just give it the decay function 

747 

748 self.outflows.append(self.route) 

749 

750 def get_cmd(self): 

751 """Calculate moisture deficit (i.e., the tank excess converted to depth). 

752 

753 Returns: 

754 (float): current moisture deficit 

755 """ 

756 return self.get_excess()["volume"] / self.area 

757 

758 def get_smc(self): 

759 """Calculate moisture content (i.e., the tank volume converted to depth). 

760 

761 Returns: 

762 (float): soil moisture content 

763 """ 

764 # Depth of soil moisture 

765 return self.storage["volume"] / self.area 

766 

767 def get_climate(self): 

768 """ 

769 

770 Returns: 

771 

772 """ 

773 precipitation_depth = self.get_data_input("precipitation") 

774 evaporation_depth = self.get_data_input("et0") * self.et0_coefficient 

775 return precipitation_depth, evaporation_depth 

776 

777 def ihacres(self): 

778 """Inflow function that runs the IHACRES model equations, updates tanks, and 

779 store flows in state variables (which are later sent to the parent land node in 

780 the route function). 

781 

782 Returns: 

783 (tuple): A tuple containing a VQIP amount for model inputs and outputs 

784 for mass balance checking. 

785 """ 

786 # Read data (leave in depth units since that is what IHACRES equations are in) 

787 precipitation_depth, evaporation_depth = self.get_climate() 

788 temperature = self.get_data_input("temperature") 

789 

790 # Apply infiltration 

791 infiltrated_precipitation = min(precipitation_depth, self.infiltration_capacity) 

792 infiltration_excess = max(precipitation_depth - infiltrated_precipitation, 0) 

793 

794 # Get current moisture deficit 

795 current_moisture_deficit_depth = self.get_cmd() 

796 

797 # IHACRES equations (we do (depth - wilting_point_m or field capacity) to 

798 # convert from a deficit to storage tank) 

799 evaporation = evaporation_depth * min( 

800 1, 

801 exp( 

802 2 

803 * ( 

804 1 

805 - current_moisture_deficit_depth 

806 / (self.depth - self.wilting_point_m) 

807 ) 

808 ), 

809 ) 

810 outflow = infiltrated_precipitation * ( 

811 1 

812 - min( 

813 1, 

814 (current_moisture_deficit_depth / (self.depth - self.field_capacity_m)) 

815 ** self.ihacres_p, 

816 ) 

817 ) 

818 

819 # Can't evaporate more than available moisture (presumably the IHACRES equation 

820 # prevents this ever being needed) 

821 evaporation = min(evaporation, precipitation_depth + self.get_smc()) 

822 

823 # Scale to volumes and apply proportions to work out percolation/surface 

824 # runoff/subsurface runoff 

825 surface = outflow * self.surface_coefficient * self.area 

826 percolation = ( 

827 outflow 

828 * (1 - self.surface_coefficient) 

829 * self.percolation_coefficient 

830 * self.area 

831 ) 

832 subsurface_flow = ( 

833 outflow 

834 * (1 - self.surface_coefficient) 

835 * self.subsurface_coefficient 

836 * self.area 

837 ) 

838 tank_recharge = (infiltrated_precipitation - evaporation - outflow) * self.area 

839 infiltration_excess *= self.area 

840 infiltration_excess += surface 

841 evaporation *= self.area 

842 precipitation = precipitation_depth * self.area 

843 

844 # Mix in tank to calculate pollutant concentrations 

845 total_water_passing_through_soil_tank = ( 

846 tank_recharge + subsurface_flow + percolation 

847 ) 

848 

849 if total_water_passing_through_soil_tank > 0: 

850 # Net effective preipitation 

851 total_water_passing_through_soil_tank = self.v_change_vqip( 

852 self.empty_vqip(), total_water_passing_through_soil_tank 

853 ) 

854 # Assign a temperature before sending into tank 

855 total_water_passing_through_soil_tank["temperature"] = temperature 

856 # Assign to tank 

857 _ = self.push_storage(total_water_passing_through_soil_tank, force=True) 

858 # Pull flows (which now have nonzero pollutant concentrations) 

859 subsurface_flow = self.pull_storage({"volume": subsurface_flow}) 

860 percolation = self.pull_storage({"volume": percolation}) 

861 else: 

862 # No net effective precipitation (instead evaporation occurs) 

863 evap = self.evaporate(-total_water_passing_through_soil_tank) 

864 subsurface_flow = self.empty_vqip() 

865 percolation = self.empty_vqip() 

866 

867 if ( 

868 abs( 

869 evap 

870 + infiltrated_precipitation * self.area 

871 - evaporation 

872 - infiltration_excess 

873 ) 

874 > constants.FLOAT_ACCURACY 

875 ): 

876 print( 

877 "inaccurate evaporation calculation of {0}".format( 

878 abs( 

879 evap 

880 + infiltrated_precipitation * self.area 

881 - evaporation 

882 - infiltration_excess 

883 ) 

884 ) 

885 ) 

886 

887 # TODO saturation excess (think it should just be 'pull_ponded' presumably in 

888 # net effective precipitation? ) 

889 

890 # Convert to VQIPs 

891 infiltration_excess = self.v_change_vqip(self.empty_vqip(), infiltration_excess) 

892 infiltration_excess["temperature"] = temperature 

893 precipitation = self.v_change_vqip(self.empty_vqip(), precipitation) 

894 evaporation = self.v_change_vqip(self.empty_vqip(), evaporation) 

895 

896 # Track flows (these are sent onwards in the route function) 

897 self.infiltration_excess = infiltration_excess 

898 self.subsurface_flow = subsurface_flow 

899 self.percolation = percolation 

900 self.tank_recharge = tank_recharge 

901 self.evaporation = evaporation 

902 self.precipitation = precipitation 

903 

904 # Mass balance 

905 in_ = precipitation 

906 out_ = evaporation 

907 

908 return (in_, out_) 

909 

910 def route(self): 

911 """An outflow function that sends percolation, subsurface runoff and surface 

912 runoff to their respective tanks in the parent land node. 

913 

914 Returns: 

915 (tuple): A tuple containing a VQIP amount for model inputs and outputs 

916 for mass balance checking. 

917 """ 

918 self.parent.surface_runoff.push_storage(self.infiltration_excess, force=True) 

919 self.parent.subsurface_runoff.push_storage(self.subsurface_flow, force=True) 

920 self.parent.percolation.push_storage(self.percolation, force=True) 

921 

922 return (self.empty_vqip(), self.empty_vqip()) 

923 

924 def calculate_soil_temperature(self): 

925 """Process function that calculates soil temperature based on a weighted. 

926 

927 average. This equation is from Lindstrom, Bishop & Lofvenius (2002), 

928 hydrological processes - but it is not clear what the parameters should be. 

929 

930 Returns: 

931 (tuple): A tuple containing a VQIP amount for model inputs and outputs 

932 for mass balance checking. 

933 """ 

934 auto = self.storage["temperature"] * self.soil_temp_w_prev 

935 air = self.get_data_input("temperature") * self.soil_temp_w_air 

936 total_weight = ( 

937 self.soil_temp_w_air + self.soil_temp_w_deep + self.soil_temp_w_prev 

938 ) 

939 self.storage["temperature"] = ( 

940 auto + air + self.soil_temp_deep * self.soil_temp_w_deep 

941 ) / total_weight 

942 

943 return (self.empty_vqip(), self.empty_vqip()) 

944 

945 

946class GrowingSurface(PerviousSurface): 

947 """""" 

948 

949 def __init__( 

950 self, 

951 rooting_depth=1, 

952 ET_depletion_factor=1, 

953 crop_factor_stages=[1, 1], 

954 crop_factor_stage_dates=[0, 365], 

955 sowing_day=1, 

956 harvest_day=365, 

957 initial_soil_storage=None, 

958 **kwargs, 

959 ): 

960 """Extensive surface subclass that implements the CatchWat equations (Liu, 

961 Dobson & Mijic (2022) Science of the total environment), which in turn are 

962 primarily based on FAO document: 

963 https://www.fao.org/3/x0490e/x0490e0ehtm#soil%20water%20availability. This 

964 surface is a pervious surface that also has things that grow on it. This 

965 behaviour includes soil nutrient pools, crop planting/harvest calendars, 

966 erosion, crop behaviour. 

967 

968 A key complexity of this surface is the nutrient pool (see wsimod/nodes/ 

969 nutrient_pool.py), which is a class that tracks the amount of phosphorus and 

970 nitrogen in different states and performs transformations that occur in the 

971 phosphorus/nitrogen cycle. It is assumed that the phosphate/nitrate/nitrite/ 

972 ammonia amounts in this Surface tank should track the dissolved inorganic pool 

973 in the nutrient pool. Meanwhile, the org-phosphorus/org-nitrogen amounts in this 

974 tank should track the dissolved organic pool in the nutrient pool. The total 

975 amount of pollutants that enter this tank may not be the same as the total 

976 amount that leave, because pollutants are transformed between inorganic/ organic 

977 and between wet/dry states - these transformations are accounted for in mass 

978 balance. 

979 

980 For users to quickly enable/disable these nutrient processes, which are 

981 computationally intensive (in current case studies they account for about half 

982 of the total runtime), they are only active if 'nitrate' is one of the modelled 

983 pollutants. Note that the code will not check if nitrite/phosphate/ 

984 org-phosphorus/org-nitrogen/ammonia are also included, but they should be if 

985 nitrate is included and otherwise the code will crash with a key error. 

986 

987 Args: 

988 rooting_depth (float, optional): Depth of the soil tank (i.e., how deep do 

989 crop roots go). Defaults to 1. 

990 ET_depletion_factor (float, optional): Average fraction of soil that can be 

991 depleted from the root zone before moisture stress (reduction in ET) 

992 occurs. Defaults to 1. 

993 crop_factor_stages (list, optional): Crop factor is a multiplier on et0, 

994 more grown plants have higher transpiration and higher crop factors. 

995 This list shows changing crop factor at different times of year in 

996 relation to crop_factor_stage_dates. See wsimod/preprocessing/ 

997 england_data_formatting.py/format_surfaces for further details on 

998 formulating these - since the interpolation used to find crop_factors in 

999 between the given values in the list is a bit involved. Defaults to 

1000 [1,1]. 

1001 crop_factor_stage_dates (list, optional): Dates associated with 

1002 crop_factor_stages. Defaults to [0, 365]. 

1003 sowing_day (int, optional): day of year that crops are sown. Defaults to 1. 

1004 harvest_day (int, optional): day of year that crops are harvest. Defaults 

1005 to 365. 

1006 initial_soil_storage (dict or float, optional): Initial mass of solid 

1007 pollutants 

1008 in the soil nutrient pools (fast and adsorbed inorganic pools) 

1009 

1010 Key assumptions: 

1011 - In the soil water module, crop stages and crop coefficients control the 

1012 evapotranspiration. 

1013 - Fertiliser and manure application are the major source of soil nutrients, 

1014 which are added 

1015 into soil nutrient pools, including dissovled inorganic, dissolved 

1016 organic, fast and humus for both nitrogen and phosphorus. 

1017 - Nutrient transformation processes in soil are simulated, including fluxes 

1018 between the soil 

1019 nutrient pools, denitrification for nitrogen, adsorption/desorption for 

1020 phosphorus. These processes are affected by temperature and soil 

1021 moisture. 

1022 - Crop uptake of nutrients are simulated based on crop stages, which is 

1023 different for spring-sown 

1024 and autumn-sown crops. 

1025 - Soil erosion from the growing surface is simulated as one of the major 

1026 sources of suspended solids 

1027 in rivers, which is mainly affected by rainfall energy and crop/ground 

1028 cover. Phosphorus will also be eroded along with the soil particles, in 

1029 both adsorbed inorganic and humus form. 

1030 

1031 Input data and parameter requirements: 

1032 - `data_input_dict` can contain a variety of pollutant deposition data. 

1033 `srp-fertiliser` describes phosphate. `noy-fertiliser` describes 

1034 nitrogen as nitrates. `nhx-fertiliser` describes nitrogen as ammonia. 

1035 `srp/noy/ nhx-manure` can also be used to specify manure application. 

1036 _Units_: kg/m2/timestep (data is read at a monthly timestep) 

1037 - Rooting depth. 

1038 _Units_: m 

1039 - Evapotranspiration depletion factor. 

1040 _Units_: - 

1041 - Sowing day, harvest day and crop calendars. 

1042 _Units_: day number in Julian calendar 

1043 - Crop factor. 

1044 _Units_: - 

1045 - Initial storage for solid pollutants. 

1046 _Units_: kg 

1047 

1048 """ 

1049 # Crop factors (set when creating object) 

1050 self.ET_depletion_factor = ( 

1051 ET_depletion_factor # To do with water availability, p from FAOSTAT 

1052 ) 

1053 self.rooting_depth = ( 

1054 rooting_depth # maximum depth that plants can absorb, Zr from FAOSTAT 

1055 ) 

1056 depth = rooting_depth 

1057 

1058 # Crop parameters 

1059 self.crop_cover_max = 0.9 # [-] 0~1 

1060 self.ground_cover_max = 0.3 # [-] 

1061 # TODO... really I should just have this as an annual profile parameter and do 

1062 # away with interpolation etc. 

1063 self.crop_factor_stages = crop_factor_stages 

1064 self.crop_factor_stage_dates = crop_factor_stage_dates 

1065 self.sowing_day = sowing_day 

1066 self.harvest_day = harvest_day 

1067 

1068 # Soil moisture dependence parameters 

1069 self.satact = 0.6 # [-] for calculating soil_moisture_dependence_factor 

1070 self.thetaupp = 0.12 # [-] for calculating soil_moisture_dependence_factor 

1071 self.thetalow = 0.08 # [-] for calculating soil_moisture_dependence_factor 

1072 self.thetapow = 1 # [-] for calculating soil_moisture_dependence_factorself. 

1073 # satact = 0.6 # [-] for calculating soil_moisture_dependence_factor 

1074 

1075 # Crop uptake parameters 

1076 self.uptake1 = ( 

1077 15 # [g/m2/y] shape factor for crop (Dissolved) Inorganic nitrogen uptake 

1078 ) 

1079 self.uptake2 = ( 

1080 1 # [-] shape factor for crop (Dissolved) Inorganic nitrogen uptake 

1081 ) 

1082 self.uptake3 = ( 

1083 0.02 # [1/day] shape factor for crop (Dissolved) Inorganic nitrogen uptake 

1084 ) 

1085 self.uptake_PNratio = 1 / 7.2 # [-] P:N during crop uptake 

1086 

1087 # Erosion parameters 

1088 self.erodibility = 0.0025 # [g * d / (J * mm)] 

1089 self.sreroexp = 1.2 # [-] surface runoff erosion exponent 

1090 self.cohesion = 1 # [kPa] 

1091 self.slope = 5 # [-] every 100 

1092 self.srfilt = ( 

1093 0.7 # [-] ratio of eroded sediment left in surface runoff after filtration 

1094 ) 

1095 self.macrofilt = 0.01 # [-] ratio of eroded sediment left in subsurface flow 

1096 # after filtration 

1097 

1098 # Denitrification parameters 

1099 self.limpar = 0.7 # [-] above which denitrification begins 

1100 self.exppar = 2.5 # [-] exponential parameter for 

1101 # soil_moisture_dependence_factor_exp calculation 

1102 self.hsatINs = 1 # [mg/l] for calculation of half-saturation concentration 

1103 # dependence factor 

1104 self.denpar = 0.015 # [-] denitrification rate coefficient 

1105 

1106 # Adsorption parameters 

1107 self.adosorption_nr_limit = 0.00001 

1108 self.adsorption_nr_maxiter = 20 

1109 self.kfr = 153.7 # [litter/kg] freundlich adsorption isoterm 

1110 self.nfr = 1 / 2.6 # [-] freundlich exponential coefficient 

1111 self.kadsdes = 0.03 # [1/day] adsorption/desorption coefficient 

1112 

1113 # Other soil parameters 

1114 self.bulk_density = 1300 # [kg/m3] 

1115 super().__init__(depth=depth, **kwargs) 

1116 

1117 # Infer basic sow/harvest calendar 

1118 self.harvest_sow_calendar = [ 

1119 0, 

1120 self.sowing_day, 

1121 self.harvest_day, 

1122 self.harvest_day + 1, 

1123 365, 

1124 ] 

1125 self.ground_cover_stages = [0, 0, self.ground_cover_max, 0, 0] 

1126 self.crop_cover_stages = [0, 0, self.crop_cover_max, 0, 0] 

1127 

1128 # Use day number of 181 to indicate autumn-sown (from HYPE) 

1129 if self.sowing_day > 181: 

1130 self.autumn_sow = True 

1131 else: 

1132 self.autumn_sow = False 

1133 

1134 # State variables 

1135 self.days_after_sow = None 

1136 self.crop_cover = 0 

1137 self.ground_cover = 0 

1138 self.crop_factor = 0 

1139 self.et0_coefficient = 1 

1140 

1141 # Calculate parameters based on capacity/wp 

1142 self.total_available_water = self.field_capacity_m - self.wilting_point_m 

1143 if self.total_available_water < 0: 

1144 print("warning: TAW < 0...") 

1145 self.readily_available_water = ( 

1146 self.total_available_water * self.ET_depletion_factor 

1147 ) 

1148 

1149 # Initiliase nutrient pools 

1150 self.nutrient_pool = NutrientPool() 

1151 

1152 self.inflows.insert(0, self.calc_crop_cover) 

1153 if "nitrate" in constants.POLLUTANTS: 

1154 # Populate function lists 

1155 self.inflows.append(self.effective_precipitation_flushing) 

1156 self.inflows.append(self.fertiliser) 

1157 self.inflows.append(self.manure) 

1158 # self.inflows.append(self.residue) 

1159 

1160 self.processes.append(self.calc_temperature_dependence_factor) 

1161 self.processes.append(self.calc_soil_moisture_dependence_factor) 

1162 self.processes.append(self.soil_pool_transformation) 

1163 self.processes.append(self.calc_crop_uptake) 

1164 

1165 # TODO possibly move these into nutrient pool 

1166 self.processes.append(self.erosion) 

1167 self.processes.append(self.denitrification) 

1168 self.processes.append(self.adsorption) 

1169 

1170 # Reflect initial water concentration in dissolved nutrient pools 

1171 self.nutrient_pool.dissolved_inorganic_pool.storage["P"] = self.storage[ 

1172 "phosphate" 

1173 ] 

1174 self.nutrient_pool.dissolved_inorganic_pool.storage["N"] = ( 

1175 self.storage["nitrate"] 

1176 + self.storage["ammonia"] 

1177 + self.storage["nitrite"] 

1178 ) 

1179 self.nutrient_pool.dissolved_organic_pool.storage["P"] = self.storage[ 

1180 "org-phosphorus" 

1181 ] 

1182 self.nutrient_pool.dissolved_organic_pool.storage["N"] = self.storage[ 

1183 "org-nitrogen" 

1184 ] 

1185 if initial_soil_storage: 

1186 self.initial_soil_storage = initial_soil_storage 

1187 # Reflect initial nutrient stores in solid nutrient pools 

1188 self.nutrient_pool.adsorbed_inorganic_pool.storage[ 

1189 "P" 

1190 ] = initial_soil_storage["phosphate"] 

1191 self.nutrient_pool.adsorbed_inorganic_pool.storage["N"] = ( 

1192 initial_soil_storage["ammonia"] 

1193 + initial_soil_storage["nitrate"] 

1194 + initial_soil_storage["nitrite"] 

1195 ) 

1196 self.nutrient_pool.fast_pool.storage["N"] = initial_soil_storage[ 

1197 "org-nitrogen" 

1198 ] 

1199 self.nutrient_pool.fast_pool.storage["P"] = initial_soil_storage[ 

1200 "org-phosphorus" 

1201 ] 

1202 

1203 def pull_storage(self, vqip): 

1204 """Pull water from the surface, updating the surface storage VQIP. Nutrient pool 

1205 pollutants (nitrate/nitrite/ammonia/phosphate/org- phosphorus/ org-nitrogen) are 

1206 removed in proportion to their amounts in the dissolved nutrient pools, if they 

1207 are simulated. Other pollutants are removed in proportion to their amount in the 

1208 surface tank. 

1209 

1210 Args: 

1211 vqip (dict): VQIP amount to be pulled, (only 'volume' key is needed) 

1212 

1213 Returns: 

1214 reply (dict): A VQIP amount successfully pulled from the tank 

1215 """ 

1216 if self.storage["volume"] == 0: 

1217 return self.empty_vqip() 

1218 

1219 # Adjust based on available volume 

1220 reply = min(vqip["volume"], self.storage["volume"]) 

1221 

1222 # Update reply to vqip (get concentration for non-nutrients) 

1223 reply = self.v_change_vqip(self.storage, reply) 

1224 

1225 if "nitrate" in constants.POLLUTANTS: 

1226 # Update nutrient pool and get concentration for nutrients 

1227 prop = reply["volume"] / self.storage["volume"] 

1228 nutrients = self.nutrient_pool.extract_dissolved(prop) 

1229 reply["nitrate"] = ( 

1230 nutrients["inorganic"]["N"] 

1231 * self.storage["nitrate"] 

1232 / ( 

1233 self.storage["nitrate"] 

1234 + self.storage["ammonia"] 

1235 + self.storage["nitrite"] 

1236 ) 

1237 ) 

1238 reply["ammonia"] = ( 

1239 nutrients["inorganic"]["N"] 

1240 * self.storage["ammonia"] 

1241 / ( 

1242 self.storage["nitrate"] 

1243 + self.storage["ammonia"] 

1244 + self.storage["nitrite"] 

1245 ) 

1246 ) 

1247 reply["nitrite"] = ( 

1248 nutrients["inorganic"]["N"] 

1249 * self.storage["nitrite"] 

1250 / ( 

1251 self.storage["nitrate"] 

1252 + self.storage["ammonia"] 

1253 + self.storage["nitrite"] 

1254 ) 

1255 ) 

1256 reply["phosphate"] = nutrients["inorganic"]["P"] 

1257 reply["org-phosphorus"] = nutrients["organic"]["P"] 

1258 reply["org-nitrogen"] = nutrients["organic"]["N"] 

1259 

1260 # Extract from storage 

1261 self.storage = self.extract_vqip(self.storage, reply) 

1262 

1263 return reply 

1264 

1265 def quick_interp(self, x, xp, yp): 

1266 """A simple version of np.interp to intepolate crop information on the fly. 

1267 

1268 Args: 

1269 x (int): Current time (i.e., day of year) xp (list): Predefined times (i.e., 

1270 list of days of year) yp (list): Predefined values associated with xp 

1271 

1272 Returns: 

1273 y (float): Interpolated value for current time 

1274 """ 

1275 x_ind = bisect_left(xp, x) 

1276 x_left = xp[x_ind - 1] 

1277 x_right = xp[x_ind] 

1278 dif = x - x_left 

1279 y_left = yp[x_ind - 1] 

1280 y_right = yp[x_ind] 

1281 y = y_left + (y_right - y_left) * dif / (x_right - x_left) 

1282 return y 

1283 

1284 def calc_crop_cover(self): 

1285 """Process function that calculates how much crop cover there is, assigns 

1286 whether crops are sown/harvested, and calculates et0_coefficient based on growth 

1287 stage of crops. 

1288 

1289 Returns: 

1290 (tuple): A tuple containing a VQIP amount for model inputs and outputs 

1291 for mass balance checking. 

1292 """ 

1293 # Get current day of year 

1294 doy = self.parent.t.dayofyear 

1295 

1296 if self.parent.t.is_leap_year: 

1297 # Hacky way to handle leap years 

1298 if doy > 59: 

1299 doy -= 1 

1300 

1301 if self.days_after_sow is None: 

1302 if self.parent.t.dayofyear == self.sowing_day: 

1303 # sow 

1304 self.days_after_sow = 0 

1305 else: 

1306 if self.parent.t.dayofyear == self.harvest_day: 

1307 # harvest 

1308 self.days_after_sow = None 

1309 self.crop_factor = self.crop_factor_stages[0] 

1310 self.crop_cover = 0 

1311 self.ground_cover = 0 

1312 else: 

1313 # increment days since sow 

1314 self.days_after_sow += 1 

1315 

1316 # Calculate relevant parameters 

1317 self.crop_factor = self.quick_interp( 

1318 doy, self.crop_factor_stage_dates, self.crop_factor_stages 

1319 ) 

1320 if self.days_after_sow: 

1321 # Move outside of this if, if you want nonzero crop/ground cover outside of 

1322 # season 

1323 self.crop_cover = self.quick_interp( 

1324 doy, self.harvest_sow_calendar, self.crop_cover_stages 

1325 ) 

1326 self.ground_cover = self.quick_interp( 

1327 doy, self.harvest_sow_calendar, self.ground_cover_stages 

1328 ) 

1329 

1330 root_zone_depletion = max(self.field_capacity_m - self.get_smc(), 0) 

1331 if root_zone_depletion < self.readily_available_water: 

1332 crop_water_stress_coefficient = 1 

1333 else: 

1334 crop_water_stress_coefficient = max( 

1335 0, 

1336 (self.total_available_water - root_zone_depletion) 

1337 / ((1 - self.ET_depletion_factor) * self.total_available_water), 

1338 ) 

1339 

1340 self.et0_coefficient = crop_water_stress_coefficient * self.crop_factor 

1341 

1342 return (self.empty_vqip(), self.empty_vqip()) 

1343 

1344 def adjust_vqip_to_liquid(self, vqip, deposition, in_): 

1345 """Function to interoperate between surface tank and nutrient pool. Most 

1346 depositions are given in terms of ammonia/nitrate/phosphate - they are then 

1347 aggregated to total N or P to enter the nutrient pools. Depending on the source 

1348 of deposition these may transform (e.g., some go to dissolved and some to 

1349 solids) upon entering the nutrient pool. To reflect these transformations in the 

1350 soil tank, the amounts entering the soil tank are adjusted proportionately. 

1351 

1352 Args: 

1353 vqip (dict): A VQIP amount of pollutants originally intended to enter the 

1354 soil tank 

1355 deposition (dict): A dict with nutrients (N and P) as keys, showing the 

1356 total amount of nutrients entering the nutrient pool 

1357 in_ (dict): A dict with nutrients as keys, showing the updated amount of 

1358 nutrients that entered the nutrient pool as dissolved pollutants 

1359 

1360 Returns: 

1361 vqip (dict): A VQIP amount of pollutants that have been scaled to account 

1362 for nutrient pool transformations 

1363 """ 

1364 if "nitrate" in constants.POLLUTANTS: 

1365 if deposition["N"] > 0: 

1366 vqip["nitrate"] *= in_["N"] / deposition["N"] 

1367 vqip["ammonia"] *= in_["N"] / deposition["N"] 

1368 vqip["org-nitrogen"] *= in_["N"] / deposition["N"] 

1369 if deposition["P"] > 0: 

1370 vqip["phosphate"] *= in_["P"] / deposition["P"] 

1371 vqip["org-phosphorus"] *= in_["P"] / deposition["P"] 

1372 

1373 return vqip 

1374 

1375 def effective_precipitation_flushing(self): 

1376 """Remove the nutrients brought out by effective precipitation, which is surface 

1377 runoff, subsurface runoff, and percolation, from the nutrients pool. 

1378 

1379 Returns: 

1380 (tuple): A tuple containing a VQIP amount for model inputs and outputs 

1381 for mass balance checking. 

1382 """ 

1383 # inorganic 

1384 out = self.nutrient_pool.get_empty_nutrient() 

1385 out["N"] = ( 

1386 self.subsurface_flow["ammonia"] 

1387 + self.subsurface_flow["nitrite"] 

1388 + self.subsurface_flow["nitrate"] 

1389 + self.percolation["ammonia"] 

1390 + self.percolation["nitrite"] 

1391 + self.percolation["nitrate"] 

1392 + self.infiltration_excess["ammonia"] 

1393 + self.infiltration_excess["nitrite"] 

1394 + self.infiltration_excess["nitrate"] 

1395 ) # TODO what happens if infiltration excess (the real part) has pollutants? 

1396 out["P"] = ( 

1397 self.subsurface_flow["phosphate"] 

1398 + self.percolation["phosphate"] 

1399 + self.infiltration_excess["phosphate"] 

1400 ) 

1401 self.nutrient_pool.dissolved_inorganic_pool.extract(out) 

1402 

1403 # organic 

1404 out = self.nutrient_pool.get_empty_nutrient() 

1405 out["N"] = ( 

1406 self.subsurface_flow["org-nitrogen"] 

1407 + self.percolation["org-nitrogen"] 

1408 + self.infiltration_excess["org-nitrogen"] 

1409 ) 

1410 out["P"] = ( 

1411 self.subsurface_flow["org-phosphorus"] 

1412 + self.percolation["org-phosphorus"] 

1413 + self.infiltration_excess["org-phosphorus"] 

1414 ) 

1415 self.nutrient_pool.dissolved_organic_pool.extract(out) 

1416 

1417 return (self.empty_vqip(), self.empty_vqip()) 

1418 

1419 def fertiliser(self): 

1420 """Read, scale and allocate fertiliser, updating the tank. 

1421 

1422 Returns: 

1423 (tuple): A tuple containing a VQIP amount for model inputs and outputs 

1424 for mass balance checking. 

1425 """ 

1426 # TODO tidy up fertiliser/manure/residue/deposition once preprocessing is sorted 

1427 

1428 # Scale for surface 

1429 nhx = self.get_data_input_surface("nhx-fertiliser") * self.area 

1430 noy = self.get_data_input_surface("noy-fertiliser") * self.area 

1431 srp = self.get_data_input_surface("srp-fertiliser") * self.area 

1432 

1433 # Update as VQIP 

1434 vqip = self.empty_vqip() 

1435 vqip["ammonia"] = nhx 

1436 vqip["nitrate"] = noy 

1437 vqip["phosphate"] = srp 

1438 

1439 # Enter nutrient pool 

1440 deposition = self.nutrient_pool.get_empty_nutrient() 

1441 deposition["N"] = vqip["nitrate"] + vqip["ammonia"] 

1442 deposition["P"] = vqip["phosphate"] 

1443 in_ = self.nutrient_pool.allocate_fertiliser(deposition) 

1444 

1445 # Update tank 

1446 vqip = self.adjust_vqip_to_liquid(vqip, deposition, in_) 

1447 self.push_storage(vqip, force=True) 

1448 

1449 return (vqip, self.empty_vqip()) 

1450 

1451 def manure(self): 

1452 """Read, scale and allocate manure, updating the tank. 

1453 

1454 Returns: 

1455 (tuple): A tuple containing a VQIP amount for model inputs and outputs 

1456 for mass balance checking. 

1457 """ 

1458 # Scale for surface 

1459 nhx = self.get_data_input_surface("nhx-manure") * self.area 

1460 noy = self.get_data_input_surface("noy-manure") * self.area 

1461 srp = self.get_data_input_surface("srp-manure") * self.area 

1462 

1463 # Formulate as VQIP 

1464 vqip = self.empty_vqip() 

1465 vqip["ammonia"] = nhx 

1466 vqip["nitrate"] = noy 

1467 vqip["phosphate"] = srp 

1468 

1469 # Enter nutrient pool 

1470 deposition = self.nutrient_pool.get_empty_nutrient() 

1471 deposition["N"] = vqip["nitrate"] + vqip["ammonia"] 

1472 deposition["P"] = vqip["phosphate"] 

1473 in_ = self.nutrient_pool.allocate_manure(deposition) 

1474 

1475 # Update tank 

1476 vqip = self.adjust_vqip_to_liquid(vqip, deposition, in_) 

1477 

1478 self.push_storage(vqip, force=True) 

1479 

1480 return (vqip, self.empty_vqip()) 

1481 

1482 def residue(self): 

1483 """Read, scale and allocate residue, updating the tank (NOT CURRENTLY USED 

1484 BECAUSE NO DATA SOURCES FOR RESIDUE CAN BE IDENTIFIED). 

1485 

1486 Returns: 

1487 (tuple): A tuple containing a VQIP amount for model inputs and outputs 

1488 for mass balance checking. 

1489 """ 

1490 nhx = self.get_data_input_surface("nhx-residue") * self.area 

1491 noy = self.get_data_input_surface("noy-residue") * self.area 

1492 srp = self.get_data_input_surface("srp-residue") * self.area 

1493 

1494 vqip = self.empty_vqip() 

1495 vqip["ammonia"] = nhx * self.nutrient_pool.fraction_residue_to_fast["N"] 

1496 vqip["nitrate"] = noy * self.nutrient_pool.fraction_residue_to_fast["N"] 

1497 vqip["org-nitrogen"] = ( 

1498 nhx + noy 

1499 ) * self.nutrient_pool.fraction_residue_to_humus["N"] 

1500 vqip["phosphate"] = srp * self.nutrient_pool.fraction_residue_to_fast["P"] 

1501 vqip["org-phosphorus"] = srp * self.nutrient_pool.fraction_residue_to_humus["P"] 

1502 

1503 deposition = self.nutrient_pool.get_empty_nutrient() 

1504 deposition["N"] = vqip["nitrate"] + vqip["ammonia"] + vqip["org-nitrogen"] 

1505 deposition["P"] = vqip["phosphate"] + vqip["org-phosphorus"] 

1506 

1507 in_ = self.nutrient_pool.allocate_residue(deposition) 

1508 vqip = self.adjust_vqip_to_liquid(vqip, deposition, in_) 

1509 

1510 self.push_storage(vqip, force=True) 

1511 

1512 return (vqip, self.empty_vqip()) 

1513 

1514 def soil_pool_transformation(self): 

1515 """A process function that run transformation functions in the nutrient pool and 

1516 updates the pollutant concentrations in the surface tank. 

1517 

1518 Returns: 

1519 (tuple): A tuple containing a VQIP amount for model inputs and outputs 

1520 for mass balance checking. 

1521 """ 

1522 # Initialise mass balance tracking variables 

1523 in_ = self.empty_vqip() 

1524 out_ = self.empty_vqip() 

1525 

1526 # Get proportion of nitrogen that is nitrate in the soil tank NOTE ignores 

1527 # nitrite - couldn't find enough information on it 

1528 nitrate_proportion = self.storage["nitrate"] / ( 

1529 self.storage["nitrate"] + self.storage["ammonia"] 

1530 ) 

1531 

1532 # Run soil pool functions 

1533 ( 

1534 increase_in_dissolved_inorganic, 

1535 increase_in_dissolved_organic, 

1536 ) = self.nutrient_pool.soil_pool_transformation() 

1537 

1538 # Update tank and mass balance TODO .. there is definitely a neater way to write 

1539 # this 

1540 if increase_in_dissolved_inorganic["N"] > 0: 

1541 # Increase in inorganic nitrogen, rescale back to nitrate and ammonia 

1542 in_["nitrate"] = increase_in_dissolved_inorganic["N"] * nitrate_proportion 

1543 in_["ammonia"] = increase_in_dissolved_inorganic["N"] * ( 

1544 1 - nitrate_proportion 

1545 ) 

1546 else: 

1547 # Decrease in inorganic nitrogen, rescale back to nitrate and ammonia 

1548 out_["nitrate"] = -increase_in_dissolved_inorganic["N"] * nitrate_proportion 

1549 out_["ammonia"] = -increase_in_dissolved_inorganic["N"] * ( 

1550 1 - nitrate_proportion 

1551 ) 

1552 

1553 if increase_in_dissolved_organic["N"] > 0: 

1554 # Increase in organic nitrogen 

1555 in_["org-nitrogen"] = increase_in_dissolved_organic["N"] 

1556 else: 

1557 # Decrease in organic nitrogen 

1558 out_["org-nitrogen"] = -increase_in_dissolved_organic["N"] 

1559 

1560 if increase_in_dissolved_inorganic["P"] > 0: 

1561 # Increase in inorganic phosphate 

1562 in_["phosphate"] = increase_in_dissolved_inorganic["P"] 

1563 else: 

1564 # Decrease in inorganic phosphate 

1565 out_["phosphate"] = -increase_in_dissolved_inorganic["P"] 

1566 

1567 if increase_in_dissolved_organic["P"] > 0: 

1568 # Increase in organic phosphorus 

1569 in_["org-phosphorus"] = increase_in_dissolved_organic["P"] 

1570 else: 

1571 # Decrease in organic phosphorus 

1572 out_["org-phosphorus"] = -increase_in_dissolved_organic["P"] 

1573 

1574 # Update tank with inputs/outputs of pollutants 

1575 _ = self.push_storage(in_, force=True) 

1576 out2_ = self.pull_pollutants(out_) 

1577 

1578 if not self.compare_vqip(out_, out2_): 

1579 print("nutrient pool not tracking soil tank") 

1580 

1581 return (in_, out_) 

1582 

1583 def calc_temperature_dependence_factor(self): 

1584 """Process function that calculates the temperature dependence factor for the 

1585 nutrient pool (which impacts soil pool transformations). 

1586 

1587 Returns: 

1588 (tuple): A tuple containing a VQIP amount for model inputs and outputs 

1589 for mass balance checking. 

1590 """ 

1591 # Parameters/equations from HYPE documentation 

1592 if self.storage["temperature"] > 5: 

1593 temperature_dependence_factor = 2 ** ( 

1594 (self.storage["temperature"] - 20) / 10 

1595 ) 

1596 elif self.storage["temperature"] > 0: 

1597 temperature_dependence_factor = self.storage["temperature"] / 5 

1598 else: 

1599 temperature_dependence_factor = 0 

1600 self.nutrient_pool.temperature_dependence_factor = temperature_dependence_factor 

1601 return (self.empty_vqip(), self.empty_vqip()) 

1602 

1603 def calc_soil_moisture_dependence_factor(self): 

1604 """Process function that calculates the soil moisture dependence factor for the 

1605 nutrient pool (which impacts soil pool transformations). 

1606 

1607 Returns: 

1608 (tuple): A tuple containing a VQIP amount for model inputs and outputs 

1609 for mass balance checking. 

1610 """ 

1611 # Parameters/equations from HYPE documentation 

1612 current_soil_moisture = self.get_smc() 

1613 if current_soil_moisture >= self.field_capacity_m: 

1614 self.nutrient_pool.soil_moisture_dependence_factor = self.satact 

1615 elif current_soil_moisture <= self.wilting_point_m: 

1616 self.nutrient_pool.soil_moisture_dependence_factor = 0 

1617 else: 

1618 fc_diff = self.field_capacity_m - current_soil_moisture 

1619 fc_comp = (fc_diff / (self.thetaupp * self.rooting_depth)) ** self.thetapow 

1620 fc_comp = (1 - self.satact) * fc_comp + self.satact 

1621 wp_diff = current_soil_moisture - self.wilting_point_m 

1622 wp_comp = (wp_diff / (self.thetalow * self.rooting_depth)) ** self.thetapow 

1623 self.nutrient_pool.soil_moisture_dependence_factor = min( 

1624 1, wp_comp, fc_comp 

1625 ) 

1626 return (self.empty_vqip(), self.empty_vqip()) 

1627 

1628 def calc_crop_uptake(self): 

1629 """Process function that calculates how much nutrient crops uptake and updates 

1630 nutrient pool and surface tank. 

1631 

1632 Returns: 

1633 (tuple): A tuple containing a VQIP amount for model inputs and outputs 

1634 for mass balance checking. 

1635 """ 

1636 # Parameters/equations from HYPE documentation 

1637 

1638 # Initialise 

1639 N_common_uptake = 0 

1640 P_common_uptake = 0 

1641 

1642 if self.days_after_sow: 

1643 # If there are crops 

1644 

1645 days_after_sow = self.days_after_sow 

1646 

1647 if self.autumn_sow: 

1648 temp_func = max(0, min(1, (self.storage["temperature"] - 5) / 20)) 

1649 days_after_sow -= 25 # Not sure why this is (but it's in HYPE) 

1650 else: 

1651 temp_func = 1 

1652 

1653 # Calculate uptake 

1654 uptake_par = (self.uptake1 - self.uptake2) * exp( 

1655 -self.uptake3 * days_after_sow 

1656 ) 

1657 if (uptake_par + self.uptake2) > 0: 

1658 N_common_uptake = ( 

1659 self.uptake1 

1660 * self.uptake2 

1661 * self.uptake3 

1662 * uptake_par 

1663 / ((self.uptake2 + uptake_par) ** 2) 

1664 ) 

1665 N_common_uptake *= temp_func * constants.G_M2_TO_KG_M2 * self.area # [kg] 

1666 P_common_uptake = N_common_uptake * self.uptake_PNratio 

1667 # calculate maximum available uptake 

1668 N_maximum_available_uptake = ( 

1669 max(0, self.storage["volume"] - self.wilting_point_m * self.area) 

1670 / self.storage["volume"] 

1671 * self.nutrient_pool.dissolved_inorganic_pool.storage["N"] 

1672 ) 

1673 P_maximum_available_uptake = ( 

1674 max(0, self.storage["volume"] - self.wilting_point_m * self.area) 

1675 / self.storage["volume"] 

1676 * self.nutrient_pool.dissolved_inorganic_pool.storage["P"] 

1677 ) 

1678 

1679 uptake = { 

1680 "P": min(P_common_uptake, P_maximum_available_uptake), 

1681 "N": min(N_common_uptake, N_maximum_available_uptake), 

1682 } 

1683 crop_uptake = self.nutrient_pool.dissolved_inorganic_pool.extract(uptake) 

1684 out_ = self.empty_vqip() 

1685 

1686 # Assuming plants eat N and P as nitrate and phosphate 

1687 out_["nitrate"] = crop_uptake["N"] 

1688 out_["phosphate"] = crop_uptake["P"] 

1689 

1690 out2_ = self.pull_pollutants(out_) 

1691 if not self.compare_vqip(out_, out2_): 

1692 print("nutrient pool not tracking soil tank") 

1693 

1694 return (self.empty_vqip(), out_) 

1695 else: 

1696 return (self.empty_vqip(), self.empty_vqip()) 

1697 

1698 def erosion(self): 

1699 """Outflow function that erodes adsorbed/humus phosphorus and sediment and sends 

1700 onwards to percolation/surface runoff/subsurface runoff. 

1701 

1702 Returns: 

1703 (tuple): A tuple containing a VQIP amount for model inputs and outputs 

1704 for mass balance checking. 

1705 """ 

1706 # Parameters/equations from HYPE documentation (which explains why my 

1707 # documentation is a bit ambiguous - because theirs is too) 

1708 

1709 # Convert precipitation to MM since all the equations assume that 

1710 precipitation_depth = self.get_data_input("precipitation") * constants.M_TO_MM 

1711 

1712 # Calculate how much rain is mobilising erosion 

1713 if precipitation_depth > 5: 

1714 rainfall_energy = 8.95 + 8.44 * log10( 

1715 precipitation_depth 

1716 * ( 

1717 0.257 

1718 + sin(2 * constants.PI * ((self.parent.t.dayofyear - 70) / 365)) 

1719 * 0.09 

1720 ) 

1721 * 2 

1722 ) 

1723 rainfall_energy *= precipitation_depth 

1724 mobilised_rain = rainfall_energy * (1 - self.crop_cover) * self.erodibility 

1725 else: 

1726 mobilised_rain = 0 

1727 

1728 # Calculate if any infiltration is mobilising erosion 

1729 if self.infiltration_excess["volume"] > 0: 

1730 mobilised_flow = ( 

1731 self.infiltration_excess["volume"] / self.area * constants.M_TO_MM * 365 

1732 ) ** self.sreroexp 

1733 mobilised_flow *= ( 

1734 (1 - self.ground_cover) 

1735 * (1 / (0.5 * self.cohesion)) 

1736 * sin(self.slope / 100) 

1737 / 365 

1738 ) 

1739 else: 

1740 mobilised_flow = 0 

1741 

1742 # Sum flows (not sure why surface runoff isn't included) TODO I'm pretty sure it 

1743 # should be included here 

1744 total_flows = ( 

1745 self.infiltration_excess["volume"] 

1746 + self.subsurface_flow["volume"] 

1747 + self.percolation["volume"] 

1748 ) # m3/dt + self.tank_recharge['volume'] (guess not needed) 

1749 

1750 # Convert to MM/M2 

1751 erodingflow = total_flows / self.area * constants.M_TO_MM 

1752 

1753 # Calculate eroded sediment 

1754 transportfactor = min(1, (erodingflow / 4) ** 1.3) 

1755 erodedsed = ( 

1756 1000 * (mobilised_flow + mobilised_rain) * transportfactor 

1757 ) # [kg/km2] 

1758 # TODO not sure what conversion this HYPE 1000 is referring to 

1759 

1760 # soil erosion with adsorbed inorganic phosphorus and humus phosphorus (erodedP 

1761 # as P in eroded sediments and effect of enrichment) 

1762 if erodingflow > 4: 

1763 enrichment = 1.5 

1764 elif erodingflow > 0: 

1765 enrichment = 4 - (4 - 1.5) * erodingflow / 4 

1766 else: 

1767 return (self.empty_vqip(), self.empty_vqip()) 

1768 

1769 # Get erodable phosphorus 

1770 erodableP = ( 

1771 self.nutrient_pool.get_erodable_P() / self.area * constants.KG_M2_TO_KG_KM2 

1772 ) 

1773 erodedP = ( 

1774 erodedsed 

1775 * ( 

1776 erodableP 

1777 / ( 

1778 self.rooting_depth 

1779 * constants.M_TO_KM 

1780 * self.bulk_density 

1781 * constants.KG_M3_TO_KG_KM3 

1782 ) 

1783 ) 

1784 * enrichment 

1785 ) # [kg/km2] 

1786 

1787 # Convert to kg 

1788 erodedP *= self.area * constants.M2_TO_KM2 # [kg] 

1789 erodedsed *= self.area * constants.M2_TO_KM2 # [kg] 

1790 

1791 # Allocate to different flows 

1792 surface_erodedP = ( 

1793 self.srfilt * self.infiltration_excess["volume"] / total_flows * erodedP 

1794 ) # [kg] 

1795 surface_erodedsed = ( 

1796 self.srfilt * self.infiltration_excess["volume"] / total_flows * erodedsed 

1797 ) # [kg] 

1798 

1799 subsurface_erodedP = ( 

1800 self.macrofilt * self.subsurface_flow["volume"] / total_flows * erodedP 

1801 ) # [kg] 

1802 subsurface_erodedsed = ( 

1803 self.macrofilt * self.subsurface_flow["volume"] / total_flows * erodedsed 

1804 ) # [kg] 

1805 

1806 percolation_erodedP = ( 

1807 self.macrofilt * self.percolation["volume"] / total_flows * erodedP 

1808 ) # [kg] 

1809 percolation_erodedsed = ( 

1810 self.macrofilt * self.percolation["volume"] / total_flows * erodedsed 

1811 ) # [kg] 

1812 

1813 # Track mass balance 

1814 in_ = self.empty_vqip() 

1815 

1816 # Total eroded phosphorus 

1817 eff_erodedP = percolation_erodedP + surface_erodedP + subsurface_erodedP # [kg] 

1818 if eff_erodedP > 0: 

1819 # Update nutrient pool 

1820 org_removed, inorg_removed = self.nutrient_pool.erode_P(eff_erodedP) 

1821 total_removed = inorg_removed + org_removed 

1822 

1823 if abs(total_removed - eff_erodedP) > constants.FLOAT_ACCURACY: 

1824 print("weird nutrients") 

1825 

1826 # scale flows to split between inorganic and organic eroded P 

1827 self.infiltration_excess["org-phosphorus"] += ( 

1828 surface_erodedP * org_removed / eff_erodedP 

1829 ) 

1830 self.subsurface_flow["org-phosphorus"] += ( 

1831 subsurface_erodedP * org_removed / eff_erodedP 

1832 ) 

1833 self.percolation["org-phosphorus"] += ( 

1834 percolation_erodedP * org_removed / eff_erodedP 

1835 ) 

1836 

1837 # TODO Leon reckons this is conceptually dodgy.. but i'm not sure where else 

1838 # adsorbed inorganic phosphorus should go 

1839 self.infiltration_excess["phosphate"] += ( 

1840 surface_erodedP * inorg_removed / eff_erodedP 

1841 ) 

1842 self.subsurface_flow["phosphate"] += ( 

1843 subsurface_erodedP * inorg_removed / eff_erodedP 

1844 ) 

1845 self.percolation["phosphate"] += ( 

1846 percolation_erodedP * inorg_removed / eff_erodedP 

1847 ) 

1848 

1849 # Entering the model (no need to uptake surface tank because both adsorbed 

1850 # inorganic pool and humus pool are solids and so no tracked in the soil 

1851 # water tank) 

1852 in_["phosphate"] = inorg_removed 

1853 in_["org-phosphorus"] = org_removed 

1854 else: 

1855 pass 

1856 

1857 # Track sediment as solids 

1858 self.infiltration_excess["solids"] += surface_erodedsed 

1859 self.subsurface_flow["solids"] += subsurface_erodedsed 

1860 self.percolation["solids"] += percolation_erodedsed 

1861 

1862 in_["solids"] = surface_erodedsed + subsurface_erodedsed + percolation_erodedsed 

1863 

1864 return (in_, self.empty_vqip()) 

1865 

1866 def denitrification(self): 

1867 """Outflow function that performs denitirication processes, updating nutrient 

1868 pool and soil tank. 

1869 

1870 Returns: 

1871 (tuple): A tuple containing a VQIP amount for model inputs and outputs 

1872 for mass balance checking. 

1873 """ 

1874 # Parameters/equations from HYPE documentation TODO could more of this be moved 

1875 # to NutrientPool Calculate soil moisture dependence of denitrification 

1876 soil_moisture_content = self.get_smc() 

1877 if soil_moisture_content > self.field_capacity_m: 

1878 denitrifying_soil_moisture_dependence = 1 

1879 elif soil_moisture_content / self.field_capacity_m > self.limpar: 

1880 denitrifying_soil_moisture_dependence = ( 

1881 ((soil_moisture_content / self.field_capacity_m) - self.limpar) 

1882 / (1 - self.limpar) 

1883 ) ** self.exppar 

1884 else: 

1885 denitrifying_soil_moisture_dependence = 0 

1886 return (self.empty_vqip(), self.empty_vqip()) 

1887 

1888 # Get dissolved inorg nitrogen as a concentration and calculate factor 

1889 din_conc = ( 

1890 self.nutrient_pool.dissolved_inorganic_pool.storage["N"] 

1891 / self.storage["volume"] 

1892 ) # [kg/m3] 

1893 din_conc *= constants.KG_M3_TO_MG_L 

1894 half_saturation_concentration_dependence_factor = din_conc / ( 

1895 din_conc + self.hsatINs 

1896 ) 

1897 

1898 # Calculate and extract dentrified nitrogen 

1899 denitrified_N = ( 

1900 self.nutrient_pool.dissolved_inorganic_pool.storage["N"] 

1901 * half_saturation_concentration_dependence_factor 

1902 * denitrifying_soil_moisture_dependence 

1903 * self.nutrient_pool.temperature_dependence_factor 

1904 * self.denpar 

1905 ) 

1906 denitrified_request = self.nutrient_pool.get_empty_nutrient() 

1907 denitrified_request["N"] = denitrified_N 

1908 denitrified_N = self.nutrient_pool.dissolved_inorganic_pool.extract( 

1909 denitrified_request 

1910 ) 

1911 

1912 # Leon reckons this should leave the model (though I think technically some 

1913 # small amount goes to nitrite) 

1914 out_ = self.empty_vqip() 

1915 out_["nitrate"] = denitrified_N["N"] 

1916 

1917 # Update tank 

1918 out2_ = self.pull_pollutants(out_) 

1919 if not self.compare_vqip(out_, out2_): 

1920 print("nutrient pool not tracking soil tank") 

1921 

1922 return (self.empty_vqip(), out_) 

1923 

1924 def adsorption(self): 

1925 """Outflow function that calculates phosphorus adsorption/desorptions and 

1926 updates soil tank and nutrient pools. 

1927 

1928 Returns: 

1929 (tuple): A tuple containing a VQIP amount for model inputs and outputs 

1930 for mass balance checking. 

1931 """ 

1932 # Parameters/equations from HYPE documentation TODO could this be moved to the 

1933 # nutrient pool? 

1934 

1935 # Initialise mass balance checking 

1936 in_ = self.empty_vqip() 

1937 out_ = self.empty_vqip() 

1938 

1939 # Get total phosphorus in pool available for adsorption/desorption 

1940 limit = self.adosorption_nr_limit 

1941 ad_de_P_pool = ( 

1942 self.nutrient_pool.adsorbed_inorganic_pool.storage["P"] 

1943 + self.nutrient_pool.dissolved_inorganic_pool.storage["P"] 

1944 ) # [kg] 

1945 ad_de_P_pool /= self.area * constants.M2_TO_KM2 # [kg/km2] 

1946 if ad_de_P_pool == 0: 

1947 return (self.empty_vqip(), self.empty_vqip()) 

1948 

1949 # Calculate coefficient and concentration of adsorbed phosphorus 

1950 soil_moisture_content = ( 

1951 self.get_smc() * constants.M_TO_MM 

1952 ) # [mm] (not sure why HYPE has this in mm but whatever) 

1953 conc_sol = ( 

1954 self.nutrient_pool.adsorbed_inorganic_pool.storage["P"] 

1955 * constants.KG_TO_MG 

1956 / (self.bulk_density * self.rooting_depth * self.area) 

1957 ) # [mg P/kg soil] 

1958 coeff = self.kfr * self.bulk_density * self.rooting_depth # [mm] 

1959 

1960 # calculate equilibrium concentration 

1961 if conc_sol <= 0: 

1962 # Not sure how this would happen 

1963 print("Warning: soil partP <=0. Freundlich will give error, take shortcut.") 

1964 xn_1 = ad_de_P_pool / (soil_moisture_content + coeff) # [mg/l] 

1965 ad_P_equi_conc = self.kfr * xn_1 # [mg/ kg] 

1966 else: 

1967 # Newton-Raphson method 

1968 x0 = exp( 

1969 (log(conc_sol) - log(self.kfr)) / self.nfr 

1970 ) # initial guess of equilibrium liquid concentration 

1971 fxn = x0 * soil_moisture_content + coeff * (x0**self.nfr) - ad_de_P_pool 

1972 xn = x0 

1973 xn_1 = xn 

1974 j = 0 

1975 while ( 

1976 abs(fxn) > limit and j < self.adsorption_nr_maxiter 

1977 ): # iteration to calculate equilibrium concentations 

1978 fxn = ( 

1979 xn * soil_moisture_content + coeff * (xn**self.nfr) - ad_de_P_pool 

1980 ) 

1981 fprimxn = soil_moisture_content + self.nfr * coeff * ( 

1982 xn ** (self.nfr - 1) 

1983 ) 

1984 dx = fxn / fprimxn 

1985 if abs(dx) < (0.000001 * xn): 

1986 # From HYPE... not sure what it means 

1987 break 

1988 xn_1 = xn - dx 

1989 if xn_1 <= 0: 

1990 xn_1 = 1e-10 

1991 xn = xn_1 

1992 j += 1 

1993 ad_P_equi_conc = self.kfr * (xn_1**self.nfr) 

1994 # print(ad_P_equi_conc, conc_sol) 

1995 

1996 # Calculate new pool and concentration, depends on the equilibrium concentration 

1997 if abs(ad_P_equi_conc - conc_sol) > 1e-6: 

1998 request = self.nutrient_pool.get_empty_nutrient() 

1999 

2000 # TODO not sure about this if statement, surely it would be triggered every 

2001 # time 

2002 adsdes = (ad_P_equi_conc - conc_sol) * ( 

2003 1 - exp(-self.kadsdes) 

2004 ) # kinetic adsorption/desorption 

2005 request["P"] = ( 

2006 adsdes 

2007 * self.bulk_density 

2008 * self.rooting_depth 

2009 * (self.area * constants.M2_TO_KM2) 

2010 ) # [kg] 

2011 if request["P"] > 0: 

2012 # Adsorption 

2013 adsorbed = self.nutrient_pool.dissolved_inorganic_pool.extract(request) 

2014 if (adsorbed["P"] - request["P"]) > constants.FLOAT_ACCURACY: 

2015 print("Warning: freundlich flow adjusted, was larger than pool") 

2016 self.nutrient_pool.adsorbed_inorganic_pool.receive(adsorbed) 

2017 

2018 # Dissolved leaving the soil water tank and becoming solid 

2019 out_["phosphate"] = adsorbed["P"] 

2020 

2021 # Update tank 

2022 out2_ = self.pull_pollutants(out_) 

2023 if not self.compare_vqip(out_, out2_): 

2024 print("nutrient pool not tracking soil tank") 

2025 else: 

2026 # Desorption 

2027 request["P"] = -request["P"] 

2028 desorbed = self.nutrient_pool.adsorbed_inorganic_pool.extract(request) 

2029 if (desorbed["P"] - request["P"]) > constants.FLOAT_ACCURACY: 

2030 print("Warning: freundlich flow adjusted, was larger than pool") 

2031 self.nutrient_pool.dissolved_inorganic_pool.receive(desorbed) 

2032 

2033 # Solid phosphorus becoming inorganic P in the soil water tank 

2034 in_["phosphate"] = desorbed["P"] 

2035 _ = self.push_storage(in_, force=True) 

2036 

2037 return (in_, out_) 

2038 

2039 def dry_deposition_to_tank(self, vqip): 

2040 """Allocate dry deposition to surface tank, updating nutrient pool accordingly. 

2041 

2042 Args: 

2043 vqip (dict): A VQIP amount of dry deposition to send to tank 

2044 

2045 Returns: 

2046 vqip (dict): A VQIP amount of dry deposition that entered the tank (used 

2047 for mass balance checking) 

2048 """ 

2049 # Convert to nutrients 

2050 deposition = self.nutrient_pool.get_empty_nutrient() 

2051 deposition["N"] = vqip["nitrate"] + vqip["ammonia"] 

2052 deposition["P"] = vqip["phosphate"] 

2053 

2054 # Update nutrient pool 

2055 in_ = self.nutrient_pool.allocate_dry_deposition(deposition) 

2056 vqip = self.adjust_vqip_to_liquid(vqip, deposition, in_) 

2057 

2058 # Update tank 

2059 self.push_storage(vqip, force=True) 

2060 return vqip 

2061 

2062 def wet_deposition_to_tank(self, vqip): 

2063 """Allocate wet deposition to surface tank, updating nutrient pool accordingly. 

2064 

2065 Args: 

2066 vqip (dict): A VQIP amount of dry deposition to send to tank 

2067 

2068 Returns: 

2069 vqip (dict): A VQIP amount of dry deposition that entered the tank (used 

2070 for mass balance checking) 

2071 """ 

2072 # Convert to nutrients 

2073 deposition = self.nutrient_pool.get_empty_nutrient() 

2074 deposition["N"] = vqip["nitrate"] + vqip["ammonia"] 

2075 deposition["P"] = vqip["phosphate"] 

2076 

2077 # Update nutrient pool 

2078 in_ = self.nutrient_pool.allocate_wet_deposition(deposition) 

2079 vqip = self.adjust_vqip_to_liquid(vqip, deposition, in_) 

2080 

2081 # Update tank 

2082 self.push_storage(vqip, force=True) 

2083 return vqip 

2084 

2085 

2086class IrrigationSurface(GrowingSurface): 

2087 """""" 

2088 

2089 def __init__(self, irrigation_coefficient=0.1, **kwargs): 

2090 """A subclass of GrowingSurface that can calculate water demand for the crops 

2091 that is not met by precipitation and use the parent node to acquire water. When 

2092 the surface is created by the parent node, the irrigate function below is 

2093 assigned. 

2094 

2095 Args: 

2096 irrigation_coefficient (float, optional): proportion area irrigated * 

2097 proportion of demand met. Defaults to 0.1. 

2098 """ 

2099 # Assign param 

2100 self.irrigation_coefficient = irrigation_coefficient # proportion area 

2101 # irrigated * proportion of demand met 

2102 

2103 super().__init__(**kwargs) 

2104 

2105 def irrigate(self): 

2106 """Calculate water demand for crops and call parent node to acquire water, 

2107 updating surface tank and nutrient pools.""" 

2108 if self.days_after_sow: 

2109 # Irrigation is just difference between evaporation and precipitation amount 

2110 irrigation_demand = ( 

2111 max(self.evaporation["volume"] - self.precipitation["volume"], 0) 

2112 * self.irrigation_coefficient 

2113 ) 

2114 if irrigation_demand > constants.FLOAT_ACCURACY: 

2115 root_zone_depletion = self.get_cmd() 

2116 if root_zone_depletion <= constants.FLOAT_ACCURACY: 

2117 # TODO this isn't in FAO... but seems sensible 

2118 irrigation_demand = 0 

2119 

2120 # Pull water using parent node 

2121 supplied = self.parent.pull_distributed( 

2122 {"volume": irrigation_demand}, 

2123 of_type=["River", "Node", "Groundwater", "Reservoir"], 

2124 ) 

2125 

2126 # update tank 

2127 _ = self.push_storage(supplied, force=True) 

2128 

2129 # update nutrient pools 

2130 organic = { 

2131 "N": supplied["org-nitrogen"], 

2132 "P": supplied["org-phosphorus"], 

2133 } 

2134 inorganic = { 

2135 "N": supplied["ammonia"] + supplied["nitrate"], 

2136 "P": supplied["phosphate"], 

2137 } 

2138 self.nutrient_pool.allocate_organic_irrigation(organic) 

2139 self.nutrient_pool.allocate_inorganic_irrigation(inorganic) 

2140 

2141 

2142class GardenSurface(GrowingSurface): 

2143 """""" 

2144 

2145 # TODO - probably a simplier version of this is useful, building just on 

2146 # pervioussurface 

2147 def __init__(self, **kwargs): 

2148 """A specific surface for gardens that treats the garden as a grass crop, but 

2149 that can calculate/receive irrigation through functions that are assigned by the 

2150 parent land node's handlers, which in turn are expected to be triggered by a 

2151 query from an attached Demand node.""" 

2152 super().__init__(**kwargs) 

2153 

2154 def calculate_irrigation_demand(self, ignore_vqip=None): 

2155 """A check function (assigned by parent to push check from demand nodes) that 

2156 calculations irrigation demand (i.e., difference between evaporation and 

2157 preciptiation). 

2158 

2159 Args: 

2160 ignore_vqip (any, optional): Conventional push checks send an optional 

2161 VQIP amount, however the intention with this check is to get the 

2162 irrigation demand 

2163 

2164 Returns: 

2165 reply (dict): A VQIP amount of irrigation demand (note only 'volume' key 

2166 is used) 

2167 """ 

2168 # Calculate irrigation demand 

2169 irrigation_demand = max( 

2170 self.evaporation["volume"] - self.precipitation["volume"], 0 

2171 ) 

2172 

2173 root_zone_depletion = self.get_cmd() 

2174 if root_zone_depletion <= constants.FLOAT_ACCURACY: 

2175 # TODO this isn't in FAO... but seems sensible 

2176 irrigation_demand = 0 

2177 

2178 # Reply as VQIP 

2179 reply = self.empty_vqip() 

2180 reply["volume"] = irrigation_demand 

2181 return reply 

2182 

2183 def receive_irrigation_demand(self, vqip): 

2184 """A set function (assigned by parent to push set from demand nodes) that 

2185 assigns irrigation water supply to the surface tank. 

2186 

2187 Args: 

2188 vqip (dict): A VQIP amount of irrigation to receive 

2189 

2190 Returns: 

2191 (dict): A VQIP amount of irrigation that was not received (should always 

2192 be empty) 

2193 """ 

2194 # update tank 

2195 return self.push_storage(vqip, force=True) 

2196 

2197 

2198class VariableAreaSurface(GrowingSurface): 

2199 """""" 

2200 

2201 def __init__(self, current_surface_area=0, **kwargs): 

2202 """""" 

2203 super().__init__(**kwargs) 

2204 self.get_climate = self.get_climate_ 

2205 self.current_surface_area = current_surface_area 

2206 

2207 def get_climate_(self): 

2208 """ 

2209 

2210 Returns: 

2211 

2212 """ 

2213 precipitation_depth = self.get_data_input("precipitation") 

2214 evaporation_depth = self.get_data_input("et0") * self.et0_coefficient 

2215 

2216 precipitation_depth *= self.current_surface_area / self.area 

2217 evaporation_depth *= self.current_surface_area / self.area 

2218 

2219 return precipitation_depth, evaporation_depth