Coverage for wsimod\nodes\land.py: 10%

780 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-10-24 11:16 +0100

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 

9from typing import Any, Dict 

10 

11from wsimod.core import constants 

12from wsimod.nodes.nodes import Node 

13from wsimod.nodes.nutrient_pool import NutrientPool 

14from wsimod.nodes.tanks import DecayTank, ResidenceTank 

15 

16 

17class Land(Node): 

18 """""" 

19 

20 def __init__( 

21 self, 

22 name, 

23 subsurface_residence_time=5, 

24 percolation_residence_time=50, 

25 surface_residence_time=1, 

26 surfaces=[], 

27 data_input_dict={}, 

28 ): 

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

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

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

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

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

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

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

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

37 

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

39 surfaces) 

40 

41 Args: 

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

43 Residence time for 

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

45 percolation_residence_time (int, optional): Residence time for 

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

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

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

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

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

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

52 Defaults to []. 

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

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

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

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

57 

58 Functions intended to call in orchestration: 

59 run apply_irrigation (if used) 

60 

61 Key assumptions: 

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

63 a residence-time method. 

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

65 generated by different hydrological response units (subclasses of 

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

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

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

69 `storage.py/River` nodes. 

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

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

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

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

74 

75 Input data and parameter requirements: 

76 - Precipitation and evapotranspiration are in the `data_input_dict` 

77 at the model timestep. _Units_: metres/timestep 

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

79 _Units_: C 

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

81 _Units_: number of timesteps 

82 """ 

83 # Assign parameters 

84 self.subsurface_residence_time = subsurface_residence_time 

85 self.percolation_residence_time = percolation_residence_time 

86 self.surface_residence_time = surface_residence_time 

87 self.data_input_dict = data_input_dict 

88 

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

90 

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

92 # demand node has any gardening or not 

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

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

95 

96 # Create surfaces 

97 self.irrigation_functions = [lambda: None] 

98 

99 surfaces_ = surfaces.copy() 

100 surfaces = [] 

101 for surface in surfaces_: 

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

103 surface["parent"] = self 

104 

105 # Get surface type 

106 type_ = surface["type_"] 

107 del surface["type_"] 

108 

109 # Instantiate surface and store in list of surfaces 

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

111 

112 # Assign ds (mass balance checking) 

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

114 

115 # Assign any irrigation functions 

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

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

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

119 

120 # Assign garden surface functions 

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

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

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

124 -1 

125 ].calculate_irrigation_demand 

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

127 -1 

128 ].receive_irrigation_demand 

129 

130 # Update handlers 

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

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

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

134 

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

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

137 # these be decayresidencetanks? 

138 self.subsurface_runoff = ResidenceTank( 

139 residence_time=self.subsurface_residence_time, 

140 capacity=constants.UNBOUNDED_CAPACITY, 

141 ) 

142 self.percolation = ResidenceTank( 

143 residence_time=self.percolation_residence_time, 

144 capacity=constants.UNBOUNDED_CAPACITY, 

145 ) 

146 self.surface_runoff = ResidenceTank( 

147 residence_time=self.surface_residence_time, 

148 capacity=constants.UNBOUNDED_CAPACITY, 

149 ) 

150 

151 # Store surfaces 

152 self.surfaces = surfaces 

153 

154 # Mass balance checkign vqips and functions 

155 self.running_inflow_mb = self.empty_vqip() 

156 self.running_outflow_mb = self.empty_vqip() 

157 

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

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

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

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

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

163 

164 def apply_overrides(self, overrides=Dict[str, Any]): 

165 """Apply overrides to the Land. 

166 

167 Enables a user to override any parameter of the residence_time and update 

168 the residence_tank accordingly. 

169 

170 Args: 

171 overrides (Dict[str, Any]): Dict describing which parameters should 

172 be overridden (keys) and new values (values). Defaults to {}. 

173 """ 

174 self.surface_residence_time = overrides.pop( 

175 "surface_residence_time", self.surface_residence_time 

176 ) 

177 self.subsurface_residence_time = overrides.pop( 

178 "subsurface_residence_time", self.subsurface_residence_time 

179 ) 

180 self.percolation_residence_time = overrides.pop( 

181 "percolation_residence_time", self.percolation_residence_time 

182 ) 

183 self.surface_runoff.residence_time = self.surface_residence_time 

184 self.subsurface_runoff.residence_time = self.subsurface_residence_time 

185 self.percolation.residence_time = self.percolation_residence_time 

186 super().apply_overrides(overrides) 

187 

188 def apply_irrigation(self): 

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

190 

191 maybe). 

192 """ 

193 for f in self.irrigation_functions: 

194 f() 

195 

196 def run(self): 

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

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

199 # Run all surfaces 

200 for surface in self.surfaces: 

201 surface.run() 

202 

203 # Apply residence time to percolation 

204 percolation = self.percolation.pull_outflow() 

205 

206 # Distribute percolation 

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

208 

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

210 # Update percolation 'tank' 

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

212 

213 # Apply residence time to subsurface/surface runoff 

214 surface_runoff = self.surface_runoff.pull_outflow() 

215 subsurface_runoff = self.subsurface_runoff.pull_outflow() 

216 

217 # Total runoff 

218 total_runoff = self.sum_vqip(surface_runoff, subsurface_runoff) 

219 if total_runoff["volume"] > 0: 

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

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

222 

223 # Redistribute total_runoff not sent 

224 if reply["volume"] > 0: 

225 reply_surface = self.v_change_vqip( 

226 reply, 

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

228 ) 

229 reply_subsurface = self.v_change_vqip( 

230 reply, 

231 reply["volume"] 

232 * subsurface_runoff["volume"] 

233 / total_runoff["volume"], 

234 ) 

235 

236 # Update surface/subsurface runoff 'tanks' 

237 if reply_surface["volume"] > 0: 

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

239 if reply_subsurface["volume"] > 0: 

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

241 

242 def push_set_sewer(self, vqip): 

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

244 surfaces. 

245 

246 Args: 

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

248 

249 Returns: 

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

251 """ 

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

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

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

255 # for now 

256 for surface in self.surfaces: 

257 if isinstance(surface, ImperviousSurface): 

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

259 break 

260 return vqip 

261 

262 def end_timestep(self): 

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

264 self.running_inflow_mb = self.empty_vqip() 

265 self.running_outflow_mb = self.empty_vqip() 

266 for tanks in self.surfaces + [ 

267 self.surface_runoff, 

268 self.subsurface_runoff, 

269 self.percolation, 

270 ]: 

271 tanks.end_timestep() 

272 

273 def get_surface(self, surface_): 

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

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

276 

277 Args: 

278 surface_ (str): Name of the surface 

279 

280 Returns: 

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

282 """ 

283 for surface in self.surfaces: 

284 if surface.surface == surface_: 

285 return surface 

286 return None 

287 

288 def reinit(self): 

289 """""" 

290 self.end_timestep() 

291 for surface in self.surfaces + [ 

292 self.surface_runoff, 

293 self.subsurface_runoff, 

294 self.percolation, 

295 ]: 

296 surface.reinit() 

297 

298 

299class Surface(DecayTank): 

300 """""" 

301 

302 def __init__( 

303 self, 

304 surface="", 

305 area=1, 

306 depth=1, 

307 data_input_dict={}, 

308 pollutant_load={}, 

309 **kwargs, 

310 ): 

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

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

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

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

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

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

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

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

319 

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

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

322 

323 Args: 

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

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

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

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

328 implications for different subclasses). Defaults to 1. 

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

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

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

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

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

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

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

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

337 {}. 

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

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

340 Defaults to {}. 

341 

342 Key assumptions: 

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

344 deposition. 

345 - Formulated as a `Tank` object. 

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

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

348 transformation details). 

349 

350 Input data and parameter requirements: 

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

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

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

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

355 a monthly timestep) 

356 """ 

357 # Assign parameters 

358 self.depth = depth 

359 self.data_input_dict = data_input_dict 

360 self.surface = surface 

361 self.pollutant_load = pollutant_load 

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

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

364 # them in kwargs.. 

365 capacity = area * depth 

366 # Parameters 

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

368 

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

370 # precipitation here 

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

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

373 else: 

374 self.inflows = [] 

375 if len(self.pollutant_load) > 0: 

376 self.inflows.append(self.simple_deposition) 

377 self.processes = [] 

378 self.outflows = [] 

379 

380 def apply_overrides(self, overrides=Dict[str, Any]): 

381 """Override parameters. 

382 

383 Enables a user to override any of the following parameters: 

384 area and depth (both will update the capacity), pollutant_load (the 

385 entire dict does not need to be redefined, only changed values need to 

386 be included). 

387 

388 Args: 

389 overrides (Dict[str, Any]): Dict describing which parameters should 

390 be overridden (keys) and new values (values). Defaults to {}. 

391 """ 

392 self.surface = overrides.pop("surface", self.surface) 

393 self.pollutant_load.update(overrides.pop("pollutant_load", {})) 

394 

395 self.area = overrides.pop("area", self.area) 

396 self.depth = overrides.pop("depth", self.depth) 

397 self.capacity = self.area * self.depth 

398 

399 if "capacity" in overrides.keys(): 

400 overrides.pop("capacity") 

401 print( 

402 "Warning: specifying capacity is depreciated in overrides for surface, \ 

403 please specify depth and area instead. capacity override value has been ignored" 

404 ) 

405 

406 # overrides data_input_dict 

407 from wsimod.orchestration.model import read_csv 

408 

409 content = overrides.pop("data_input_dict", self.data_input_dict) 

410 if isinstance(content, str): 

411 self.data_input_dict = read_csv(content) 

412 elif isinstance(content, dict): 

413 self.data_input_dict = content 

414 else: 

415 raise ValueError( 

416 f"{content.__class__} is not a recognised format for data_input_dict" 

417 ) 

418 

419 super().apply_overrides(overrides) 

420 

421 def run(self): 

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

423 if "nitrite" in constants.POLLUTANTS: 

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

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

426 # Thus, these decays are simulated here 

427 

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

429 # the end_timestep function 

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

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

432 

433 # Decayed ammonia becomes nitrite 

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

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

436 

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

438 # Iterate over function lists, updating mass balance 

439 in_, out_ = f() 

440 self.parent.running_inflow_mb = self.sum_vqip( 

441 self.parent.running_inflow_mb, in_ 

442 ) 

443 self.parent.running_outflow_mb = self.sum_vqip( 

444 self.parent.running_outflow_mb, out_ 

445 ) 

446 

447 def get_data_input(self, var): 

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

449 

450 Args: 

451 var (str): Name of variable 

452 

453 Returns: 

454 Data read 

455 """ 

456 return self.parent.get_data_input(var) 

457 

458 def get_data_input_surface(self, var): 

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

460 

461 Args: 

462 var (str): Name of variable 

463 

464 Returns: 

465 Data read 

466 """ 

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

468 

469 def dry_deposition_to_tank(self, vqip): 

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

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

472 behaviour). 

473 

474 Args: 

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

476 

477 Returns: 

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

479 for mass balance checking) 

480 """ 

481 # Default behaviour is to just enter the tank 

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

483 return vqip 

484 

485 def wet_deposition_to_tank(self, vqip): 

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

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

488 behaviour). 

489 

490 Args: 

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

492 

493 Returns: 

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

495 for mass balance checking) 

496 """ 

497 # Default behaviour is to just enter the tank 

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

499 return vqip 

500 

501 def simple_deposition(self): 

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

503 surface tank. 

504 

505 Returns: 

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

507 for mass balance checking. 

508 """ 

509 pollution = self.empty_vqip() 

510 

511 # Scale by area 

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

513 if pol in constants.ADDITIVE_POLLUTANTS: 

514 pollution[pol] = item * self.area 

515 else: 

516 pollution[pol] = item 

517 pollution["volume"] = 0 

518 

519 # Update tank 

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

521 

522 return (pollution, self.empty_vqip()) 

523 

524 def atmospheric_deposition(self): 

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

526 surface tank. 

527 

528 Returns: 

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

530 for mass balance checking. 

531 """ 

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

533 # NHX/noy? 

534 

535 # Read data and scale 

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

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

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

539 

540 # Assign pollutants 

541 vqip = self.empty_vqip() 

542 vqip["ammonia"] = nhx 

543 vqip["nitrate"] = noy 

544 vqip["phosphate"] = srp 

545 

546 # Update tank 

547 in_ = self.dry_deposition_to_tank(vqip) 

548 

549 # Return mass balance 

550 return (in_, self.empty_vqip()) 

551 

552 def precipitation_deposition(self): 

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

554 surface tank. 

555 

556 Returns: 

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

558 for mass balance checking. 

559 """ 

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

561 

562 # Read data and scale 

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

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

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

566 

567 # Assign pollutants 

568 vqip = self.empty_vqip() 

569 vqip["ammonia"] = nhx 

570 vqip["nitrate"] = noy 

571 vqip["phosphate"] = srp 

572 

573 # Update tank 

574 in_ = self.wet_deposition_to_tank(vqip) 

575 

576 # Return mass balance 

577 return (in_, self.empty_vqip()) 

578 

579 

580class ImperviousSurface(Surface): 

581 """""" 

582 

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

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

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

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

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

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

589 only be mobilised if runoff occurs. 

590 

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

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

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

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

595 

596 Args: 

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

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

599 water accumulates in before flowing. Defaults to 0. 

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

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

602 ImperviousSurface. Defaults to 1. 

603 """ 

604 # Assign parameters 

605 self.et0_to_e = et0_to_e # Total evaporation 

606 self.pore_depth = pore_depth 

607 

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

609 

610 # Initialise state variables 

611 self.evaporation = self.empty_vqip() 

612 self.precipitation = self.empty_vqip() 

613 

614 # Populate function lists 

615 self.inflows.append(self.precipitation_evaporation) 

616 

617 self.outflows.append(self.push_to_sewers) 

618 

619 def apply_overrides(self, overrides=Dict[str, Any]): 

620 """Override parameters. 

621 

622 Enables a user to override any of the following parameters: 

623 eto_to_e, pore_depth. 

624 

625 Args: 

626 overrides (Dict[str, Any]): Dict describing which parameters should 

627 be overridden (keys) and new values (values). Defaults to {}. 

628 """ 

629 self.et0_to_e = overrides.pop("et0_to_e", self.et0_to_e) 

630 if "depth" in overrides.keys(): 

631 overrides.pop("depth") 

632 print( 

633 "ERROR: specifying depth is depreciated in overrides for \ 

634 impervious surface, please specify pore_depth instead" 

635 ) 

636 self.pore_depth = overrides.pop("pore_depth", self.pore_depth) 

637 self.depth = self.pore_depth 

638 self.capacity = self.area * self.depth 

639 super().apply_overrides(overrides) 

640 

641 def precipitation_evaporation(self): 

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

643 

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

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

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

647 

648 Returns: 

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

650 for mass balance checking. 

651 """ 

652 # Read data in length units 

653 precipitation_depth = self.get_data_input("precipitation") 

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

655 

656 if precipitation_depth < evaporation_depth: 

657 # No effective precipitation 

658 net_precipitation = 0 

659 

660 # Calculate how much should be evaporated from pores 

661 evaporation_from_pores = evaporation_depth - precipitation_depth 

662 

663 # Scale 

664 evaporation_from_pores *= self.area 

665 

666 # Pull from tank 

667 evaporation_from_pores = self.evaporate(evaporation_from_pores) 

668 

669 # Scale to get actual evaporation 

670 total_evaporation = evaporation_from_pores + precipitation_depth * self.area 

671 else: 

672 # Effective precipitation 

673 net_precipitation = precipitation_depth - evaporation_depth 

674 

675 # Scale 

676 net_precipitation *= self.area 

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

678 

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

680 # use surface air temperature 

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

682 

683 # Update tank 

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

685 total_evaporation = evaporation_depth * self.area 

686 

687 # Converrt to VQIP 

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

689 self.precipitation = self.v_change_vqip( 

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

691 ) 

692 

693 return (self.precipitation, self.evaporation) 

694 

695 def push_to_sewers(self): 

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

697 parent node's attached sewers. 

698 

699 Returns: 

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

701 for mass balance checking. 

702 """ 

703 # Get runoff 

704 surface_runoff = self.pull_ponded() 

705 

706 # Distribute TODO in cwsd_partition this is done with timearea 

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

708 

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

710 # can it go) 

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

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

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

714 # to be revisited for detailed sewer models 

715 

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

717 return (self.empty_vqip(), self.empty_vqip()) 

718 

719 

720class PerviousSurface(Surface): 

721 """""" 

722 

723 def __init__( 

724 self, 

725 depth=0.75, 

726 total_porosity=0.4, 

727 field_capacity=0.3, 

728 wilting_point=0.12, 

729 infiltration_capacity=0.5, 

730 surface_coefficient=0.05, 

731 percolation_coefficient=0.75, 

732 et0_coefficient=0.5, 

733 ihacres_p=10, 

734 **kwargs, 

735 ): 

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

737 

738 Args: 

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

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

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

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

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

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

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

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

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

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

749 to 0.12. 

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

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

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

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

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

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

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

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

758 runoff. Defaults to 0.75. 

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

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

761 surface. Defaults to=0.5, 

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

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

764 

765 Key assumptions: 

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

767 infiltration capacity, beyond which the precipitation will flow directly 

768 as surface runoff. 

769 - Evapotranspiration and effective precipitation are calculated based on 

770 soil moisture content. 

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

772 and subsurface runoff by multiplying the corresponding coefficient. 

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

774 corresponding residence tanks for rounting to downstream. 

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

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

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

778 

779 Input data and parameter requirements: 

780 - Field capacity and wilting point. 

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

782 - Infiltration capacity. 

783 _Units_: m/day 

784 - Surface, percolation coefficient. 

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

786 - et0 coefficient. 

787 _Units_: - 

788 - ihacres_p. 

789 _Units_: - 

790 """ 

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

792 self.field_capacity = field_capacity 

793 self.field_capacity_m = field_capacity * depth 

794 self.wilting_point = wilting_point 

795 self.wilting_point_m = wilting_point * depth 

796 self.infiltration_capacity = infiltration_capacity 

797 self.surface_coefficient = surface_coefficient 

798 self.percolation_coefficient = percolation_coefficient 

799 self.et0_coefficient = et0_coefficient 

800 self.ihacres_p = ihacres_p 

801 self.total_porosity = total_porosity 

802 

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

804 # TODO what should these params be? 

805 self.soil_temp_w_prev = 0.1 # previous timestep weighting 

806 self.soil_temp_w_air = 0.6 # air temperature weighting 

807 self.soil_temp_w_deep = 0.1 # deep soil temperature weighting 

808 self.soil_temp_deep = 10 # deep soil temperature 

809 

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

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

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

813 

814 # Calculate subsurface coefficient 

815 self.subsurface_coefficient = 1 - self.percolation_coefficient 

816 

817 # Initiliase state variables 

818 self.infiltration_excess = self.empty_vqip() 

819 self.subsurface_flow = self.empty_vqip() 

820 self.percolation = self.empty_vqip() 

821 self.tank_recharge = 0 

822 self.evaporation = self.empty_vqip() 

823 self.precipitation = self.empty_vqip() 

824 

825 # Populate function lists 

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

827 

828 # TODO interception if I hate myself enough? 

829 self.processes.append( 

830 self.calculate_soil_temperature 

831 ) # Calculate soil temp + dependence factor 

832 

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

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

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

836 

837 self.outflows.append(self.route) 

838 

839 def apply_overrides(self, overrides=Dict[str, Any]): 

840 """Override parameters. 

841 

842 Enables a user to override any of the following parameters: 

843 field_capacity, wilting_point, total_porosity, infiltration_capacity, 

844 surface_coefficient, percolation_coefficient, et0_coefficient, ihacres_p, 

845 soil_temp_w_prev, soil_temp_w_air, soil_temp_w_deep, soil_temp_deep, 

846 and the corresponding parameter values, including field_capacity_m, 

847 wilting_point_m, depth, capacity, subsurface_coefficient. 

848 

849 Args: 

850 overrides (Dict[str, Any]): Dict describing which parameters should 

851 be overridden (keys) and new values (values). Defaults to {}. 

852 """ 

853 

854 self.depth /= self.total_porosity # restore the physical depth (root) 

855 

856 overwrite_params = [ 

857 "field_capacity", 

858 "wilting_point", 

859 "total_porosity", 

860 "infiltration_capacity", 

861 "surface_coefficient", 

862 "percolation_coefficient", 

863 "et0_coefficient", 

864 "ihacres_p", 

865 "soil_temp_w_prev", 

866 "soil_temp_w_air", 

867 "soil_temp_w_deep", 

868 "soil_temp_deep", 

869 ] 

870 for param in overwrite_params: 

871 setattr(self, param, overrides.pop(param, getattr(self, param))) 

872 

873 self.subsurface_coefficient = 1 - self.percolation_coefficient 

874 

875 super().apply_overrides(overrides) 

876 # After the depth has been changed ... 

877 self.field_capacity_m = self.field_capacity * self.depth 

878 self.wilting_point_m = self.wilting_point * self.depth 

879 self.depth *= self.total_porosity # update the simulation depth 

880 self.capacity = self.depth * self.area 

881 

882 def get_cmd(self): 

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

884 

885 Returns: 

886 (float): current moisture deficit 

887 """ 

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

889 

890 def get_smc(self): 

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

892 

893 Returns: 

894 (float): soil moisture content 

895 """ 

896 # Depth of soil moisture 

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

898 

899 def get_climate(self): 

900 """ 

901 

902 Returns: 

903 

904 """ 

905 precipitation_depth = self.get_data_input("precipitation") 

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

907 return precipitation_depth, evaporation_depth 

908 

909 def ihacres(self): 

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

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

912 the route function). 

913 

914 Returns: 

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

916 for mass balance checking. 

917 """ 

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

919 precipitation_depth, evaporation_depth = self.get_climate() 

920 temperature = self.get_data_input("temperature") 

921 

922 # Apply infiltration 

923 infiltrated_precipitation = min(precipitation_depth, self.infiltration_capacity) 

924 infiltration_excess = max(precipitation_depth - infiltrated_precipitation, 0) 

925 

926 # Get current moisture deficit 

927 current_moisture_deficit_depth = self.get_cmd() 

928 

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

930 # convert from a deficit to storage tank) 

931 evaporation = evaporation_depth * min( 

932 1, 

933 exp( 

934 2 

935 * ( 

936 1 

937 - current_moisture_deficit_depth 

938 / (self.depth - self.wilting_point_m) 

939 ) 

940 ), 

941 ) 

942 outflow = infiltrated_precipitation * ( 

943 1 

944 - min( 

945 1, 

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

947 ** self.ihacres_p, 

948 ) 

949 ) 

950 

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

952 # prevents this ever being needed) 

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

954 

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

956 # runoff/subsurface runoff 

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

958 percolation = ( 

959 outflow 

960 * (1 - self.surface_coefficient) 

961 * self.percolation_coefficient 

962 * self.area 

963 ) 

964 subsurface_flow = ( 

965 outflow 

966 * (1 - self.surface_coefficient) 

967 * self.subsurface_coefficient 

968 * self.area 

969 ) 

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

971 infiltration_excess *= self.area 

972 infiltration_excess += surface 

973 evaporation *= self.area 

974 precipitation = precipitation_depth * self.area 

975 

976 # Mix in tank to calculate pollutant concentrations 

977 total_water_passing_through_soil_tank = ( 

978 tank_recharge + subsurface_flow + percolation 

979 ) 

980 

981 if total_water_passing_through_soil_tank > 0: 

982 # Net effective preipitation 

983 total_water_passing_through_soil_tank = self.v_change_vqip( 

984 self.empty_vqip(), total_water_passing_through_soil_tank 

985 ) 

986 # Assign a temperature before sending into tank 

987 total_water_passing_through_soil_tank["temperature"] = temperature 

988 # Assign to tank 

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

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

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

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

993 else: 

994 # No net effective precipitation (instead evaporation occurs) 

995 evap = self.evaporate(-total_water_passing_through_soil_tank) 

996 subsurface_flow = self.empty_vqip() 

997 percolation = self.empty_vqip() 

998 

999 if ( 

1000 abs( 

1001 evap 

1002 + infiltrated_precipitation * self.area 

1003 - evaporation 

1004 - infiltration_excess 

1005 ) 

1006 > constants.FLOAT_ACCURACY 

1007 ): 

1008 print( 

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

1010 abs( 

1011 evap 

1012 + infiltrated_precipitation * self.area 

1013 - evaporation 

1014 - infiltration_excess 

1015 ) 

1016 ) 

1017 ) 

1018 

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

1020 # net effective precipitation? ) 

1021 

1022 # Convert to VQIPs 

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

1024 infiltration_excess["temperature"] = temperature 

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

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

1027 

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

1029 self.infiltration_excess = infiltration_excess 

1030 self.subsurface_flow = subsurface_flow 

1031 self.percolation = percolation 

1032 self.tank_recharge = tank_recharge 

1033 self.evaporation = evaporation 

1034 self.precipitation = precipitation 

1035 

1036 # Mass balance 

1037 in_ = precipitation 

1038 out_ = evaporation 

1039 

1040 return (in_, out_) 

1041 

1042 def route(self): 

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

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

1045 

1046 Returns: 

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

1048 for mass balance checking. 

1049 """ 

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

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

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

1053 

1054 return (self.empty_vqip(), self.empty_vqip()) 

1055 

1056 def calculate_soil_temperature(self): 

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

1058 

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

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

1061 

1062 Returns: 

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

1064 for mass balance checking. 

1065 """ 

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

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

1068 total_weight = ( 

1069 self.soil_temp_w_air + self.soil_temp_w_deep + self.soil_temp_w_prev 

1070 ) 

1071 self.storage["temperature"] = ( 

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

1073 ) / total_weight 

1074 

1075 return (self.empty_vqip(), self.empty_vqip()) 

1076 

1077 

1078class GrowingSurface(PerviousSurface): 

1079 """""" 

1080 

1081 def __init__( 

1082 self, 

1083 rooting_depth=1, 

1084 ET_depletion_factor=1, 

1085 crop_factor_stages=[1, 1], 

1086 crop_factor_stage_dates=[0, 365], 

1087 sowing_day=1, 

1088 harvest_day=365, 

1089 initial_soil_storage=None, 

1090 **kwargs, 

1091 ): 

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

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

1094 primarily based on FAO document: 

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

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

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

1098 erosion, crop behaviour. 

1099 

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

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

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

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

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

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

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

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

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

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

1110 balance. 

1111 

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

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

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

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

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

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

1118 

1119 Args: 

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

1121 crop roots go). Defaults to 1. 

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

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

1124 occurs. Defaults to 1. 

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

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

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

1128 relation to crop_factor_stage_dates. See wsimod/preprocessing/ 

1129 england_data_formatting.py/format_surfaces for further details on 

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

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

1132 [1,1]. 

1133 crop_factor_stage_dates (list, optional): Dates associated with 

1134 crop_factor_stages. Defaults to [0, 365]. 

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

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

1137 to 365. 

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

1139 pollutants 

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

1141 

1142 Key assumptions: 

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

1144 evapotranspiration. 

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

1146 which are added 

1147 into soil nutrient pools, including dissovled inorganic, dissolved 

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

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

1150 between the soil 

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

1152 phosphorus. These processes are affected by temperature and soil 

1153 moisture. 

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

1155 different for spring-sown 

1156 and autumn-sown crops. 

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

1158 sources of suspended solids 

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

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

1161 both adsorbed inorganic and humus form. 

1162 

1163 Input data and parameter requirements: 

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

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

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

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

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

1169 - Rooting depth. 

1170 _Units_: m 

1171 - Evapotranspiration depletion factor. 

1172 _Units_: - 

1173 - Sowing day, harvest day and crop calendars. 

1174 _Units_: day number in Julian calendar 

1175 - Crop factor. 

1176 _Units_: - 

1177 - Initial storage for solid pollutants. 

1178 _Units_: kg 

1179 

1180 """ 

1181 # Crop factors (set when creating object) 

1182 self.ET_depletion_factor = ( 

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

1184 ) 

1185 self.rooting_depth = ( 

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

1187 ) 

1188 depth = rooting_depth 

1189 

1190 # Crop parameters 

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

1192 self.ground_cover_max = 0.3 # [-] 

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

1194 # away with interpolation etc. 

1195 self.crop_factor_stages = crop_factor_stages 

1196 self.crop_factor_stage_dates = crop_factor_stage_dates 

1197 self.sowing_day = sowing_day 

1198 self.harvest_day = harvest_day 

1199 

1200 # Soil moisture dependence parameters 

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

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

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

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

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

1206 

1207 # Crop uptake parameters 

1208 self.uptake1 = ( 

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

1210 ) 

1211 self.uptake2 = ( 

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

1213 ) 

1214 self.uptake3 = ( 

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

1216 ) 

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

1218 

1219 # Erosion parameters 

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

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

1222 self.cohesion = 1 # [kPa] 

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

1224 self.srfilt = ( 

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

1226 ) 

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

1228 # after filtration 

1229 

1230 # Denitrification parameters 

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

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

1233 # soil_moisture_dependence_factor_exp calculation 

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

1235 # dependence factor 

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

1237 

1238 # Adsorption parameters 

1239 self.adosorption_nr_limit = 0.00001 

1240 self.adsorption_nr_maxiter = 20 

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

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

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

1244 

1245 # Other soil parameters 

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

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

1248 

1249 ( 

1250 self.harvest_sow_calendar, 

1251 self.ground_cover_stages, 

1252 self.crop_cover_stages, 

1253 self.autumn_sow, 

1254 ) = self.infer_sow_harvest_calendar() 

1255 

1256 # State variables 

1257 self.days_after_sow = None 

1258 self.crop_cover = 0 

1259 self.ground_cover = 0 

1260 self.crop_factor = 0 

1261 self.et0_coefficient = 1 

1262 

1263 ( 

1264 self.total_available_water, 

1265 self.readily_available_water, 

1266 ) = self.calculate_available_water() 

1267 

1268 # Initiliase nutrient pools 

1269 self.nutrient_pool = NutrientPool() 

1270 

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

1272 if "nitrate" in constants.POLLUTANTS: 

1273 # Populate function lists 

1274 self.inflows.append(self.effective_precipitation_flushing) 

1275 self.inflows.append(self.fertiliser) 

1276 self.inflows.append(self.manure) 

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

1278 

1279 self.processes.append(self.calc_temperature_dependence_factor) 

1280 self.processes.append(self.calc_soil_moisture_dependence_factor) 

1281 self.processes.append(self.soil_pool_transformation) 

1282 self.processes.append(self.calc_crop_uptake) 

1283 

1284 # TODO possibly move these into nutrient pool 

1285 self.processes.append(self.erosion) 

1286 self.processes.append(self.denitrification) 

1287 self.processes.append(self.adsorption) 

1288 

1289 # Reflect initial water concentration in dissolved nutrient pools 

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

1291 "phosphate" 

1292 ] 

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

1294 self.storage["nitrate"] 

1295 + self.storage["ammonia"] 

1296 + self.storage["nitrite"] 

1297 ) 

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

1299 "org-phosphorus" 

1300 ] 

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

1302 "org-nitrogen" 

1303 ] 

1304 if initial_soil_storage: 

1305 self.initial_soil_storage = initial_soil_storage 

1306 # Reflect initial nutrient stores in solid nutrient pools 

1307 self.nutrient_pool.adsorbed_inorganic_pool.storage["P"] = ( 

1308 initial_soil_storage["phosphate"] 

1309 ) 

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

1311 initial_soil_storage["ammonia"] 

1312 + initial_soil_storage["nitrate"] 

1313 + initial_soil_storage["nitrite"] 

1314 ) 

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

1316 "org-nitrogen" 

1317 ] 

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

1319 "org-phosphorus" 

1320 ] 

1321 

1322 def infer_sow_harvest_calendar(self): 

1323 """Infer basic sow/harvest calendar and indicate autumn-sown. 

1324 Returns: 

1325 (list): havest/sow calendar 

1326 (list): ground cover stages 

1327 (list): crop cover stages 

1328 (boolean): indication for autumn-sown crops 

1329 """ 

1330 # Infer basic sow/harvest calendar 

1331 harvest_sow_calendar = [ 

1332 0, 

1333 self.sowing_day, 

1334 self.harvest_day, 

1335 self.harvest_day + 1, 

1336 365, 

1337 ] 

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

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

1340 

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

1342 if self.sowing_day > 181: 

1343 autumn_sow = True 

1344 else: 

1345 autumn_sow = False 

1346 

1347 return harvest_sow_calendar, ground_cover_stages, crop_cover_stages, autumn_sow 

1348 

1349 def calculate_available_water(self): 

1350 """Calculate total/readily available water based on capacity/wp. 

1351 Returns: 

1352 (float): total available water 

1353 (float): readily available water 

1354 """ 

1355 # Calculate parameters based on capacity/wp 

1356 total_available_water = self.field_capacity_m - self.wilting_point_m 

1357 if total_available_water < 0: 

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

1359 readily_available_water = total_available_water * self.ET_depletion_factor 

1360 

1361 return total_available_water, readily_available_water 

1362 

1363 def apply_overrides(self, overrides=Dict[str, Any]): 

1364 """Override parameters. 

1365 

1366 Enables a user to override any of the following parameters 

1367 

1368 Args: 

1369 overrides (Dict[str, Any]): Dict describing which parameters should 

1370 be overridden (keys) and new values (values). Defaults to {}. 

1371 """ 

1372 overwrite_params = [ 

1373 "ET_depletion_factor", 

1374 "crop_cover_max", 

1375 "ground_cover_max", 

1376 "crop_factor_stages", 

1377 "crop_factor_stage_dates", 

1378 "sowing_day", 

1379 "harvest_day", 

1380 "satact", 

1381 "thetaupp", 

1382 "thetalow", 

1383 "thetapow", 

1384 "uptake1", 

1385 "uptake2", 

1386 "uptake3", 

1387 "uptake_PNratio", 

1388 "erodibility", 

1389 "sreroexp", 

1390 "cohesion", 

1391 "slope", 

1392 "srfilt", 

1393 "macrofilt", 

1394 "limpar", 

1395 "exppar", 

1396 "hsatINs", 

1397 "denpar", 

1398 "adosorption_nr_limit", 

1399 "adsorption_nr_maxiter", 

1400 "kfr", 

1401 "nfr", 

1402 "kadsdes", 

1403 "bulk_density", 

1404 ] 

1405 for param in overwrite_params: 

1406 setattr(self, param, overrides.pop(param, getattr(self, param))) 

1407 

1408 if "depth" in overrides.keys(): 

1409 overrides.pop("depth") 

1410 print( 

1411 "ERROR: specifying depth is depreciated in overrides for \ 

1412 GrowingSurface, please specify rooting_depth instead" 

1413 ) 

1414 self.rooting_depth = overrides.pop("rooting_depth", self.rooting_depth) 

1415 overrides["depth"] = self.rooting_depth 

1416 super().apply_overrides(overrides) 

1417 

1418 ( 

1419 self.harvest_sow_calendar, 

1420 self.ground_cover_stages, 

1421 self.crop_cover_stages, 

1422 self.autumn_sow, 

1423 ) = self.infer_sow_harvest_calendar() 

1424 ( 

1425 self.total_available_water, 

1426 self.readily_available_water, 

1427 ) = self.calculate_available_water() 

1428 

1429 def pull_storage(self, vqip): 

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

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

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

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

1434 surface tank. 

1435 

1436 Args: 

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

1438 

1439 Returns: 

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

1441 """ 

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

1443 return self.empty_vqip() 

1444 

1445 # Adjust based on available volume 

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

1447 

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

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

1450 

1451 if "nitrate" in constants.POLLUTANTS: 

1452 # Update nutrient pool and get concentration for nutrients 

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

1454 nutrients = self.nutrient_pool.extract_dissolved(prop) 

1455 reply["nitrate"] = ( 

1456 nutrients["inorganic"]["N"] 

1457 * self.storage["nitrate"] 

1458 / ( 

1459 self.storage["nitrate"] 

1460 + self.storage["ammonia"] 

1461 + self.storage["nitrite"] 

1462 ) 

1463 ) 

1464 reply["ammonia"] = ( 

1465 nutrients["inorganic"]["N"] 

1466 * self.storage["ammonia"] 

1467 / ( 

1468 self.storage["nitrate"] 

1469 + self.storage["ammonia"] 

1470 + self.storage["nitrite"] 

1471 ) 

1472 ) 

1473 reply["nitrite"] = ( 

1474 nutrients["inorganic"]["N"] 

1475 * self.storage["nitrite"] 

1476 / ( 

1477 self.storage["nitrate"] 

1478 + self.storage["ammonia"] 

1479 + self.storage["nitrite"] 

1480 ) 

1481 ) 

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

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

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

1485 

1486 # Extract from storage 

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

1488 

1489 return reply 

1490 

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

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

1493 

1494 Args: 

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

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

1497 

1498 Returns: 

1499 y (float): Interpolated value for current time 

1500 """ 

1501 x_ind = bisect_left(xp, x) 

1502 x_left = xp[x_ind - 1] 

1503 x_right = xp[x_ind] 

1504 dif = x - x_left 

1505 y_left = yp[x_ind - 1] 

1506 y_right = yp[x_ind] 

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

1508 return y 

1509 

1510 def calc_crop_cover(self): 

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

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

1513 stage of crops. 

1514 

1515 Returns: 

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

1517 for mass balance checking. 

1518 """ 

1519 # Get current day of year 

1520 doy = self.parent.t.dayofyear 

1521 

1522 if self.parent.t.is_leap_year: 

1523 # Hacky way to handle leap years 

1524 if doy > 59: 

1525 doy -= 1 

1526 

1527 if self.days_after_sow is None: 

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

1529 # sow 

1530 self.days_after_sow = 0 

1531 else: 

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

1533 # harvest 

1534 self.days_after_sow = None 

1535 self.crop_factor = self.crop_factor_stages[0] 

1536 self.crop_cover = 0 

1537 self.ground_cover = 0 

1538 else: 

1539 # increment days since sow 

1540 self.days_after_sow += 1 

1541 

1542 # Calculate relevant parameters 

1543 self.crop_factor = self.quick_interp( 

1544 doy, self.crop_factor_stage_dates, self.crop_factor_stages 

1545 ) 

1546 if self.days_after_sow: 

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

1548 # season 

1549 self.crop_cover = self.quick_interp( 

1550 doy, self.harvest_sow_calendar, self.crop_cover_stages 

1551 ) 

1552 self.ground_cover = self.quick_interp( 

1553 doy, self.harvest_sow_calendar, self.ground_cover_stages 

1554 ) 

1555 

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

1557 if root_zone_depletion < self.readily_available_water: 

1558 crop_water_stress_coefficient = 1 

1559 else: 

1560 crop_water_stress_coefficient = max( 

1561 0, 

1562 (self.total_available_water - root_zone_depletion) 

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

1564 ) 

1565 

1566 self.et0_coefficient = crop_water_stress_coefficient * self.crop_factor 

1567 

1568 return (self.empty_vqip(), self.empty_vqip()) 

1569 

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

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

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

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

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

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

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

1577 

1578 Args: 

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

1580 soil tank 

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

1582 total amount of nutrients entering the nutrient pool 

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

1584 nutrients that entered the nutrient pool as dissolved pollutants 

1585 

1586 Returns: 

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

1588 for nutrient pool transformations 

1589 """ 

1590 if "nitrate" in constants.POLLUTANTS: 

1591 if deposition["N"] > 0: 

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

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

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

1595 if deposition["P"] > 0: 

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

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

1598 

1599 return vqip 

1600 

1601 def effective_precipitation_flushing(self): 

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

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

1604 

1605 Returns: 

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

1607 for mass balance checking. 

1608 """ 

1609 # inorganic 

1610 out = self.nutrient_pool.get_empty_nutrient() 

1611 out["N"] = ( 

1612 self.subsurface_flow["ammonia"] 

1613 + self.subsurface_flow["nitrite"] 

1614 + self.subsurface_flow["nitrate"] 

1615 + self.percolation["ammonia"] 

1616 + self.percolation["nitrite"] 

1617 + self.percolation["nitrate"] 

1618 + self.infiltration_excess["ammonia"] 

1619 + self.infiltration_excess["nitrite"] 

1620 + self.infiltration_excess["nitrate"] 

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

1622 out["P"] = ( 

1623 self.subsurface_flow["phosphate"] 

1624 + self.percolation["phosphate"] 

1625 + self.infiltration_excess["phosphate"] 

1626 ) 

1627 self.nutrient_pool.dissolved_inorganic_pool.extract(out) 

1628 

1629 # organic 

1630 out = self.nutrient_pool.get_empty_nutrient() 

1631 out["N"] = ( 

1632 self.subsurface_flow["org-nitrogen"] 

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

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

1635 ) 

1636 out["P"] = ( 

1637 self.subsurface_flow["org-phosphorus"] 

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

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

1640 ) 

1641 self.nutrient_pool.dissolved_organic_pool.extract(out) 

1642 

1643 return (self.empty_vqip(), self.empty_vqip()) 

1644 

1645 def fertiliser(self): 

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

1647 

1648 Returns: 

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

1650 for mass balance checking. 

1651 """ 

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

1653 

1654 # Scale for surface 

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

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

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

1658 

1659 # Update as VQIP 

1660 vqip = self.empty_vqip() 

1661 vqip["ammonia"] = nhx 

1662 vqip["nitrate"] = noy 

1663 vqip["phosphate"] = srp 

1664 

1665 # Enter nutrient pool 

1666 deposition = self.nutrient_pool.get_empty_nutrient() 

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

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

1669 in_ = self.nutrient_pool.allocate_fertiliser(deposition) 

1670 

1671 # Update tank 

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

1673 self.push_storage(vqip, force=True) 

1674 

1675 return (vqip, self.empty_vqip()) 

1676 

1677 def manure(self): 

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

1679 

1680 Returns: 

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

1682 for mass balance checking. 

1683 """ 

1684 # Scale for surface 

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

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

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

1688 

1689 # Formulate as VQIP 

1690 vqip = self.empty_vqip() 

1691 vqip["ammonia"] = nhx 

1692 vqip["nitrate"] = noy 

1693 vqip["phosphate"] = srp 

1694 

1695 # Enter nutrient pool 

1696 deposition = self.nutrient_pool.get_empty_nutrient() 

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

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

1699 in_ = self.nutrient_pool.allocate_manure(deposition) 

1700 

1701 # Update tank 

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

1703 

1704 self.push_storage(vqip, force=True) 

1705 

1706 return (vqip, self.empty_vqip()) 

1707 

1708 def residue(self): 

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

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

1711 

1712 Returns: 

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

1714 for mass balance checking. 

1715 """ 

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

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

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

1719 

1720 vqip = self.empty_vqip() 

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

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

1723 vqip["org-nitrogen"] = ( 

1724 nhx + noy 

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

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

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

1728 

1729 deposition = self.nutrient_pool.get_empty_nutrient() 

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

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

1732 

1733 in_ = self.nutrient_pool.allocate_residue(deposition) 

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

1735 

1736 self.push_storage(vqip, force=True) 

1737 

1738 return (vqip, self.empty_vqip()) 

1739 

1740 def soil_pool_transformation(self): 

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

1742 updates the pollutant concentrations in the surface tank. 

1743 

1744 Returns: 

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

1746 for mass balance checking. 

1747 """ 

1748 # Initialise mass balance tracking variables 

1749 in_ = self.empty_vqip() 

1750 out_ = self.empty_vqip() 

1751 

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

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

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

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

1756 ) 

1757 

1758 # Run soil pool functions 

1759 ( 

1760 increase_in_dissolved_inorganic, 

1761 increase_in_dissolved_organic, 

1762 ) = self.nutrient_pool.soil_pool_transformation() 

1763 

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

1765 # this 

1766 if increase_in_dissolved_inorganic["N"] > 0: 

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

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

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

1770 1 - nitrate_proportion 

1771 ) 

1772 else: 

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

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

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

1776 1 - nitrate_proportion 

1777 ) 

1778 

1779 if increase_in_dissolved_organic["N"] > 0: 

1780 # Increase in organic nitrogen 

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

1782 else: 

1783 # Decrease in organic nitrogen 

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

1785 

1786 if increase_in_dissolved_inorganic["P"] > 0: 

1787 # Increase in inorganic phosphate 

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

1789 else: 

1790 # Decrease in inorganic phosphate 

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

1792 

1793 if increase_in_dissolved_organic["P"] > 0: 

1794 # Increase in organic phosphorus 

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

1796 else: 

1797 # Decrease in organic phosphorus 

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

1799 

1800 # Update tank with inputs/outputs of pollutants 

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

1802 out2_ = self.pull_pollutants(out_) 

1803 

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

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

1806 

1807 return (in_, out_) 

1808 

1809 def calc_temperature_dependence_factor(self): 

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

1811 nutrient pool (which impacts soil pool transformations). 

1812 

1813 Returns: 

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

1815 for mass balance checking. 

1816 """ 

1817 # Parameters/equations from HYPE documentation 

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

1819 temperature_dependence_factor = 2 ** ( 

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

1821 ) 

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

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

1824 else: 

1825 temperature_dependence_factor = 0 

1826 self.nutrient_pool.temperature_dependence_factor = temperature_dependence_factor 

1827 return (self.empty_vqip(), self.empty_vqip()) 

1828 

1829 def calc_soil_moisture_dependence_factor(self): 

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

1831 nutrient pool (which impacts soil pool transformations). 

1832 

1833 Returns: 

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

1835 for mass balance checking. 

1836 """ 

1837 # Parameters/equations from HYPE documentation 

1838 current_soil_moisture = self.get_smc() 

1839 if current_soil_moisture >= self.field_capacity_m: 

1840 self.nutrient_pool.soil_moisture_dependence_factor = self.satact 

1841 elif current_soil_moisture <= self.wilting_point_m: 

1842 self.nutrient_pool.soil_moisture_dependence_factor = 0 

1843 else: 

1844 fc_diff = self.field_capacity_m - current_soil_moisture 

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

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

1847 wp_diff = current_soil_moisture - self.wilting_point_m 

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

1849 self.nutrient_pool.soil_moisture_dependence_factor = min( 

1850 1, wp_comp, fc_comp 

1851 ) 

1852 return (self.empty_vqip(), self.empty_vqip()) 

1853 

1854 def calc_crop_uptake(self): 

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

1856 nutrient pool and surface tank. 

1857 

1858 Returns: 

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

1860 for mass balance checking. 

1861 """ 

1862 # Parameters/equations from HYPE documentation 

1863 

1864 # Initialise 

1865 N_common_uptake = 0 

1866 P_common_uptake = 0 

1867 

1868 if self.days_after_sow: 

1869 # If there are crops 

1870 

1871 days_after_sow = self.days_after_sow 

1872 

1873 if self.autumn_sow: 

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

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

1876 else: 

1877 temp_func = 1 

1878 

1879 # Calculate uptake 

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

1881 -self.uptake3 * days_after_sow 

1882 ) 

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

1884 N_common_uptake = ( 

1885 self.uptake1 

1886 * self.uptake2 

1887 * self.uptake3 

1888 * uptake_par 

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

1890 ) 

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

1892 P_common_uptake = N_common_uptake * self.uptake_PNratio 

1893 # calculate maximum available uptake 

1894 N_maximum_available_uptake = ( 

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

1896 / self.storage["volume"] 

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

1898 ) 

1899 P_maximum_available_uptake = ( 

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

1901 / self.storage["volume"] 

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

1903 ) 

1904 

1905 uptake = { 

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

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

1908 } 

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

1910 out_ = self.empty_vqip() 

1911 

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

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

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

1915 

1916 out2_ = self.pull_pollutants(out_) 

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

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

1919 

1920 return (self.empty_vqip(), out_) 

1921 else: 

1922 return (self.empty_vqip(), self.empty_vqip()) 

1923 

1924 def erosion(self): 

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

1926 onwards to percolation/surface runoff/subsurface runoff. 

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 (which explains why my 

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

1934 

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

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

1937 

1938 # Calculate how much rain is mobilising erosion 

1939 if precipitation_depth > 5: 

1940 rainfall_energy = 8.95 + 8.44 * log10( 

1941 precipitation_depth 

1942 * ( 

1943 0.257 

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

1945 * 0.09 

1946 ) 

1947 * 2 

1948 ) 

1949 rainfall_energy *= precipitation_depth 

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

1951 else: 

1952 mobilised_rain = 0 

1953 

1954 # Calculate if any infiltration is mobilising erosion 

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

1956 mobilised_flow = ( 

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

1958 ) ** self.sreroexp 

1959 mobilised_flow *= ( 

1960 (1 - self.ground_cover) 

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

1962 * sin(self.slope / 100) 

1963 / 365 

1964 ) 

1965 else: 

1966 mobilised_flow = 0 

1967 

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

1969 # should be included here 

1970 total_flows = ( 

1971 self.infiltration_excess["volume"] 

1972 + self.subsurface_flow["volume"] 

1973 + self.percolation["volume"] 

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

1975 

1976 # Convert to MM/M2 

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

1978 

1979 # Calculate eroded sediment 

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

1981 erodedsed = ( 

1982 1000 * (mobilised_flow + mobilised_rain) * transportfactor 

1983 ) # [kg/km2] 

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

1985 

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

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

1988 if erodingflow > 4: 

1989 enrichment = 1.5 

1990 elif erodingflow > 0: 

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

1992 else: 

1993 return (self.empty_vqip(), self.empty_vqip()) 

1994 

1995 # Get erodable phosphorus 

1996 erodableP = ( 

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

1998 ) 

1999 erodedP = ( 

2000 erodedsed 

2001 * ( 

2002 erodableP 

2003 / ( 

2004 self.rooting_depth 

2005 * constants.M_TO_KM 

2006 * self.bulk_density 

2007 * constants.KG_M3_TO_KG_KM3 

2008 ) 

2009 ) 

2010 * enrichment 

2011 ) # [kg/km2] 

2012 

2013 # Convert to kg 

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

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

2016 

2017 # Allocate to different flows 

2018 surface_erodedP = ( 

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

2020 ) # [kg] 

2021 surface_erodedsed = ( 

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

2023 ) # [kg] 

2024 

2025 subsurface_erodedP = ( 

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

2027 ) # [kg] 

2028 subsurface_erodedsed = ( 

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

2030 ) # [kg] 

2031 

2032 percolation_erodedP = ( 

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

2034 ) # [kg] 

2035 percolation_erodedsed = ( 

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

2037 ) # [kg] 

2038 

2039 # Track mass balance 

2040 in_ = self.empty_vqip() 

2041 

2042 # Total eroded phosphorus 

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

2044 if eff_erodedP > 0: 

2045 # Update nutrient pool 

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

2047 total_removed = inorg_removed + org_removed 

2048 

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

2050 print("weird nutrients") 

2051 

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

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

2054 surface_erodedP * org_removed / eff_erodedP 

2055 ) 

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

2057 subsurface_erodedP * org_removed / eff_erodedP 

2058 ) 

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

2060 percolation_erodedP * org_removed / eff_erodedP 

2061 ) 

2062 

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

2064 # adsorbed inorganic phosphorus should go 

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

2066 surface_erodedP * inorg_removed / eff_erodedP 

2067 ) 

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

2069 subsurface_erodedP * inorg_removed / eff_erodedP 

2070 ) 

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

2072 percolation_erodedP * inorg_removed / eff_erodedP 

2073 ) 

2074 

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

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

2077 # water tank) 

2078 in_["phosphate"] = inorg_removed 

2079 in_["org-phosphorus"] = org_removed 

2080 else: 

2081 pass 

2082 

2083 # Track sediment as solids 

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

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

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

2087 

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

2089 

2090 return (in_, self.empty_vqip()) 

2091 

2092 def denitrification(self): 

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

2094 pool and soil tank. 

2095 

2096 Returns: 

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

2098 for mass balance checking. 

2099 """ 

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

2101 # to NutrientPool Calculate soil moisture dependence of denitrification 

2102 soil_moisture_content = self.get_smc() 

2103 if soil_moisture_content > self.field_capacity_m: 

2104 denitrifying_soil_moisture_dependence = 1 

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

2106 denitrifying_soil_moisture_dependence = ( 

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

2108 / (1 - self.limpar) 

2109 ) ** self.exppar 

2110 else: 

2111 denitrifying_soil_moisture_dependence = 0 

2112 return (self.empty_vqip(), self.empty_vqip()) 

2113 

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

2115 din_conc = ( 

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

2117 / self.storage["volume"] 

2118 ) # [kg/m3] 

2119 din_conc *= constants.KG_M3_TO_MG_L 

2120 half_saturation_concentration_dependence_factor = din_conc / ( 

2121 din_conc + self.hsatINs 

2122 ) 

2123 

2124 # Calculate and extract dentrified nitrogen 

2125 denitrified_N = ( 

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

2127 * half_saturation_concentration_dependence_factor 

2128 * denitrifying_soil_moisture_dependence 

2129 * self.nutrient_pool.temperature_dependence_factor 

2130 * self.denpar 

2131 ) 

2132 denitrified_request = self.nutrient_pool.get_empty_nutrient() 

2133 denitrified_request["N"] = denitrified_N 

2134 denitrified_N = self.nutrient_pool.dissolved_inorganic_pool.extract( 

2135 denitrified_request 

2136 ) 

2137 

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

2139 # small amount goes to nitrite) 

2140 out_ = self.empty_vqip() 

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

2142 

2143 # Update tank 

2144 out2_ = self.pull_pollutants(out_) 

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

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

2147 

2148 return (self.empty_vqip(), out_) 

2149 

2150 def adsorption(self): 

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

2152 updates soil tank and nutrient pools. 

2153 

2154 Returns: 

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

2156 for mass balance checking. 

2157 """ 

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

2159 # nutrient pool? 

2160 

2161 # Initialise mass balance checking 

2162 in_ = self.empty_vqip() 

2163 out_ = self.empty_vqip() 

2164 

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

2166 limit = self.adosorption_nr_limit 

2167 ad_de_P_pool = ( 

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

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

2170 ) # [kg] 

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

2172 if ad_de_P_pool == 0: 

2173 return (self.empty_vqip(), self.empty_vqip()) 

2174 

2175 # Calculate coefficient and concentration of adsorbed phosphorus 

2176 soil_moisture_content = ( 

2177 self.get_smc() * constants.M_TO_MM 

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

2179 conc_sol = ( 

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

2181 * constants.KG_TO_MG 

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

2183 ) # [mg P/kg soil] 

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

2185 

2186 # calculate equilibrium concentration 

2187 if conc_sol <= 0: 

2188 # Not sure how this would happen 

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

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

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

2192 else: 

2193 # Newton-Raphson method 

2194 x0 = exp( 

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

2196 ) # initial guess of equilibrium liquid concentration 

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

2198 xn = x0 

2199 xn_1 = xn 

2200 j = 0 

2201 while ( 

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

2203 ): # iteration to calculate equilibrium concentations 

2204 fxn = xn * soil_moisture_content + coeff * (xn**self.nfr) - ad_de_P_pool 

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

2206 xn ** (self.nfr - 1) 

2207 ) 

2208 dx = fxn / fprimxn 

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

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

2211 break 

2212 xn_1 = xn - dx 

2213 if xn_1 <= 0: 

2214 xn_1 = 1e-10 

2215 xn = xn_1 

2216 j += 1 

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

2218 # print(ad_P_equi_conc, conc_sol) 

2219 

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

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

2222 request = self.nutrient_pool.get_empty_nutrient() 

2223 

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

2225 # time 

2226 adsdes = (ad_P_equi_conc - conc_sol) * ( 

2227 1 - exp(-self.kadsdes) 

2228 ) # kinetic adsorption/desorption 

2229 request["P"] = ( 

2230 adsdes 

2231 * self.bulk_density 

2232 * self.rooting_depth 

2233 * (self.area * constants.M2_TO_KM2) 

2234 ) # [kg] 

2235 if request["P"] > 0: 

2236 # Adsorption 

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

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

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

2240 self.nutrient_pool.adsorbed_inorganic_pool.receive(adsorbed) 

2241 

2242 # Dissolved leaving the soil water tank and becoming solid 

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

2244 

2245 # Update tank 

2246 out2_ = self.pull_pollutants(out_) 

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

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

2249 else: 

2250 # Desorption 

2251 request["P"] = -request["P"] 

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

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

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

2255 self.nutrient_pool.dissolved_inorganic_pool.receive(desorbed) 

2256 

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

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

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

2260 

2261 return (in_, out_) 

2262 

2263 def dry_deposition_to_tank(self, vqip): 

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

2265 

2266 Args: 

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

2268 

2269 Returns: 

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

2271 for mass balance checking) 

2272 """ 

2273 # Convert to nutrients 

2274 deposition = self.nutrient_pool.get_empty_nutrient() 

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

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

2277 

2278 # Update nutrient pool 

2279 in_ = self.nutrient_pool.allocate_dry_deposition(deposition) 

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

2281 

2282 # Update tank 

2283 self.push_storage(vqip, force=True) 

2284 return vqip 

2285 

2286 def wet_deposition_to_tank(self, vqip): 

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

2288 

2289 Args: 

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

2291 

2292 Returns: 

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

2294 for mass balance checking) 

2295 """ 

2296 # Convert to nutrients 

2297 deposition = self.nutrient_pool.get_empty_nutrient() 

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

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

2300 

2301 # Update nutrient pool 

2302 in_ = self.nutrient_pool.allocate_wet_deposition(deposition) 

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

2304 

2305 # Update tank 

2306 self.push_storage(vqip, force=True) 

2307 return vqip 

2308 

2309 

2310class IrrigationSurface(GrowingSurface): 

2311 """""" 

2312 

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

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

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

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

2317 assigned. 

2318 

2319 Args: 

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

2321 proportion of demand met. Defaults to 0.1. 

2322 """ 

2323 # Assign param 

2324 self.irrigation_coefficient = irrigation_coefficient # proportion area 

2325 # irrigated * proportion of demand met 

2326 

2327 super().__init__(**kwargs) 

2328 

2329 def apply_overrides(self, overrides=Dict[str, Any]): 

2330 """Override parameters. 

2331 

2332 Enables a user to override irrigation_coefficient 

2333 

2334 Args: 

2335 overrides (Dict[str, Any]): Dict describing which parameters should 

2336 be overridden (keys) and new values (values). Defaults to {}. 

2337 """ 

2338 self.irrigation_coefficient = overrides.pop( 

2339 "irrigation_coefficient", self.irrigation_coefficient 

2340 ) 

2341 super().apply_overrides(overrides) 

2342 

2343 def irrigate(self): 

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

2345 updating surface tank and nutrient pools.""" 

2346 if self.days_after_sow: 

2347 # Irrigation is just difference between evaporation and precipitation amount 

2348 irrigation_demand = ( 

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

2350 * self.irrigation_coefficient 

2351 ) 

2352 if irrigation_demand > constants.FLOAT_ACCURACY: 

2353 root_zone_depletion = self.get_cmd() 

2354 if root_zone_depletion <= constants.FLOAT_ACCURACY: 

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

2356 irrigation_demand = 0 

2357 

2358 # Pull water using parent node 

2359 supplied = self.parent.pull_distributed( 

2360 {"volume": irrigation_demand}, 

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

2362 ) 

2363 

2364 # update tank 

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

2366 

2367 # update nutrient pools 

2368 organic = { 

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

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

2371 } 

2372 inorganic = { 

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

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

2375 } 

2376 self.nutrient_pool.allocate_organic_irrigation(organic) 

2377 self.nutrient_pool.allocate_inorganic_irrigation(inorganic) 

2378 

2379 

2380class GardenSurface(GrowingSurface): 

2381 """""" 

2382 

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

2384 # pervioussurface 

2385 def __init__(self, **kwargs): 

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

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

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

2389 query from an attached Demand node.""" 

2390 super().__init__(**kwargs) 

2391 

2392 def calculate_irrigation_demand(self, ignore_vqip=None): 

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

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

2395 preciptiation). 

2396 

2397 Args: 

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

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

2400 irrigation demand 

2401 

2402 Returns: 

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

2404 is used) 

2405 """ 

2406 # Calculate irrigation demand 

2407 irrigation_demand = max( 

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

2409 ) 

2410 

2411 root_zone_depletion = self.get_cmd() 

2412 if root_zone_depletion <= constants.FLOAT_ACCURACY: 

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

2414 irrigation_demand = 0 

2415 

2416 # Reply as VQIP 

2417 reply = self.empty_vqip() 

2418 reply["volume"] = irrigation_demand 

2419 return reply 

2420 

2421 def receive_irrigation_demand(self, vqip): 

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

2423 assigns irrigation water supply to the surface tank. 

2424 

2425 Args: 

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

2427 

2428 Returns: 

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

2430 be empty) 

2431 """ 

2432 # update tank 

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

2434 

2435 

2436class VariableAreaSurface(GrowingSurface): 

2437 """""" 

2438 

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

2440 """""" 

2441 super().__init__(**kwargs) 

2442 self.get_climate = self.get_climate_ 

2443 self.current_surface_area = current_surface_area 

2444 

2445 def get_climate_(self): 

2446 """ 

2447 

2448 Returns: 

2449 

2450 """ 

2451 precipitation_depth = self.get_data_input("precipitation") 

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

2453 

2454 precipitation_depth *= self.current_surface_area / self.area 

2455 evaporation_depth *= self.current_surface_area / self.area 

2456 

2457 return precipitation_depth, evaporation_depth