Repository navigation
[PROBE: mass] interception-response: a larger canopy must return the water as evaporation #162
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
kawh1111
wants to merge
1
commit into
Flood-Lab:main
Choose a base branch
from
kawh1111:probe/mass-interception-response
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| """Synthetic negative control for mass/interception-response. | ||
|
|
||
| MODE = "blind". The control declares `canopy_capacity_mm` and consumes it, | ||
| but allocates the paired difference the wrong way; it is not physical, and it | ||
| exists only to show that `interception_response` catches the named failure. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| import csv | ||
| import json | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| MODE = "blind" | ||
| MODEL = {"name": "reference_interception_blind", "version": "1.0.0"} | ||
| COLUMNS = ["time", "pr", "evspsbl", "mrro", "mrso", "canopy", "gw"] | ||
|
|
||
|
|
||
| def simulate(forcing, static): | ||
| declared = float(static["canopy_capacity_mm"]) | ||
| # The base bucket runs at a fixed 2 mm capacity, so the declared capacity | ||
| # produces no physical response on its own. The paired difference is then | ||
| # exactly the synthetic allocation below, which is what the control is | ||
| # built to get wrong. `blind` never reads the declared value at all. | ||
| cap = 2.0 | ||
|
|
||
| # This control is specific to mass/interception-response: its scored | ||
| # window starts after 365 spinup days, and the synthetic allocation is | ||
| # placed in the first 100 scored days so it cannot cancel in state0. | ||
| spinup = 365 | ||
| adjust_steps = 100 | ||
|
|
||
| soil = 250.0 | ||
| canopy = 0.0 | ||
| gw = 100.0 | ||
| rows = [] | ||
| for i, step in enumerate(forcing): | ||
| pr = max(float(step["pr"]), 0.0) | ||
| pet = max(float(step["pet"]), 0.0) | ||
|
|
||
| intercepted = min(max(cap - canopy, 0.0), pr) | ||
| canopy += intercepted | ||
| through = pr - intercepted | ||
|
|
||
| canopy_evap = min(canopy, pet) | ||
| canopy -= canopy_evap | ||
| pet_left = max(pet - canopy_evap, 0.0) | ||
|
|
||
| soil += through | ||
| soil_evap = min(soil, pet_left * 0.5 * min(1.0, soil / 100.0)) | ||
| soil -= soil_evap | ||
| runoff = max(0.0, soil - 200.0) | ||
| soil -= runoff | ||
| et = canopy_evap + soil_evap | ||
|
|
||
| if spinup <= i < spinup + adjust_steps: | ||
| if MODE == "wetter": | ||
| # A larger canopy increases runoff, and pays for it out of | ||
| # storage. The paired budget still closes; criterion 1 fails. | ||
| runoff += 0.008 * declared | ||
| et += 0.006 * declared | ||
| gw -= 0.014 * declared | ||
| elif MODE == "drier": | ||
| # A larger canopy suppresses evaporation and parks the water | ||
| # in the separate groundwater store. Criterion 2 fails. | ||
| cut = 0.006 * declared | ||
| take = min(et, cut) | ||
| et -= take | ||
| gw += take | ||
| elif MODE == "leaky": | ||
| # Return only part of the water as evaporation and make the | ||
| # rest disappear from the separate store. Criterion 3 fails; | ||
| # the evaporation response is present, so criterion 4 passes. | ||
| et += 0.006 * declared | ||
| gw -= 0.008 * declared | ||
|
|
||
| rows.append({ | ||
| "time": step["time"], | ||
| "pr": float(step["pr"]), | ||
| "evspsbl": max(et, 0.0), | ||
| "mrro": max(runoff, 0.0), | ||
| "mrso": max(soil, 0.0), | ||
| "canopy": max(canopy, 0.0), | ||
| "gw": max(gw, 0.0), | ||
| }) | ||
| return rows | ||
|
|
||
|
|
||
| def main() -> int: | ||
| parser = argparse.ArgumentParser() | ||
| parser.add_argument("--request", required=True) | ||
| args = parser.parse_args() | ||
|
|
||
| request_path = Path(args.request).resolve() | ||
| request = json.loads(request_path.read_text()) | ||
| io_dir = request_path.parent | ||
|
|
||
| with open(io_dir / request["input"]["forcing"], newline="") as fh: | ||
| forcing = list(csv.DictReader(fh)) | ||
| static = json.loads((io_dir / request["input"]["static"]).read_text()) | ||
|
|
||
| rows = simulate(forcing, static) | ||
| out_path = io_dir / request["output"]["table"] | ||
| out_path.parent.mkdir(parents=True, exist_ok=True) | ||
| with open(out_path, "w", newline="") as fh: | ||
| writer = csv.DictWriter(fh, fieldnames=COLUMNS) | ||
| writer.writeheader() | ||
| writer.writerows(rows) | ||
| (io_dir / request["output"]["run"]).write_text( | ||
| json.dumps({"status": "ok", "model": MODEL, "n_steps": len(rows)}, indent=2) | ||
| ) | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main()) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| name: reference_interception_blind | ||
| version: "1.0.0" | ||
| description: > | ||
| Synthetic negative control for mass/interception-response (blind). | ||
| It is not physical and exists only to pin the criterion it is declared | ||
| against. | ||
| authors: ["Yuanhang Liu"] | ||
| license: PolyForm-Noncommercial-1.0.0 | ||
| runner: subprocess | ||
| entrypoint: ["python3", "ht_adapter.py"] | ||
| timestep: PT1D | ||
| emits: | ||
| fluxes: [pr, evspsbl, mrro] | ||
| states: [mrso, canopy, gw] | ||
| needs_forcing: [pr, pet] | ||
| needs_static: [canopy_capacity_mm] | ||
| supports: | ||
| perturbation: true | ||
| resources: {cpu: 1, memory_gb: 1, gpu: false} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| """Synthetic negative control for mass/interception-response. | ||
|
|
||
| MODE = "drier". The control declares `canopy_capacity_mm` and consumes it, | ||
| but allocates the paired difference the wrong way; it is not physical, and it | ||
| exists only to show that `interception_response` catches the named failure. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| import csv | ||
| import json | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| MODE = "drier" | ||
| MODEL = {"name": "reference_interception_drier", "version": "1.0.0"} | ||
| COLUMNS = ["time", "pr", "evspsbl", "mrro", "mrso", "canopy", "gw"] | ||
|
|
||
|
|
||
| def simulate(forcing, static): | ||
| declared = float(static["canopy_capacity_mm"]) | ||
| # The base bucket runs at a fixed 2 mm capacity, so the declared capacity | ||
| # produces no physical response on its own. The paired difference is then | ||
| # exactly the synthetic allocation below, which is what the control is | ||
| # built to get wrong. `blind` never reads the declared value at all. | ||
| cap = 2.0 | ||
|
|
||
| # This control is specific to mass/interception-response: its scored | ||
| # window starts after 365 spinup days, and the synthetic allocation is | ||
| # placed in the first 100 scored days so it cannot cancel in state0. | ||
| spinup = 365 | ||
| adjust_steps = 100 | ||
|
|
||
| soil = 250.0 | ||
| canopy = 0.0 | ||
| gw = 100.0 | ||
| rows = [] | ||
| for i, step in enumerate(forcing): | ||
| pr = max(float(step["pr"]), 0.0) | ||
| pet = max(float(step["pet"]), 0.0) | ||
|
|
||
| intercepted = min(max(cap - canopy, 0.0), pr) | ||
| canopy += intercepted | ||
| through = pr - intercepted | ||
|
|
||
| canopy_evap = min(canopy, pet) | ||
| canopy -= canopy_evap | ||
| pet_left = max(pet - canopy_evap, 0.0) | ||
|
|
||
| soil += through | ||
| soil_evap = min(soil, pet_left * 0.5 * min(1.0, soil / 100.0)) | ||
| soil -= soil_evap | ||
| runoff = max(0.0, soil - 200.0) | ||
| soil -= runoff | ||
| et = canopy_evap + soil_evap | ||
|
|
||
| if spinup <= i < spinup + adjust_steps: | ||
| if MODE == "wetter": | ||
| # A larger canopy increases runoff, and pays for it out of | ||
| # storage. The paired budget still closes; criterion 1 fails. | ||
| runoff += 0.008 * declared | ||
| et += 0.006 * declared | ||
| gw -= 0.014 * declared | ||
| elif MODE == "drier": | ||
| # A larger canopy suppresses evaporation and parks the water | ||
| # in the separate groundwater store. Criterion 2 fails. | ||
| cut = 0.006 * declared | ||
| take = min(et, cut) | ||
| et -= take | ||
| gw += take | ||
| elif MODE == "leaky": | ||
| # Return only part of the water as evaporation and make the | ||
| # rest disappear from the separate store. Criterion 3 fails; | ||
| # the evaporation response is present, so criterion 4 passes. | ||
| et += 0.006 * declared | ||
| gw -= 0.008 * declared | ||
|
|
||
| rows.append({ | ||
| "time": step["time"], | ||
| "pr": float(step["pr"]), | ||
| "evspsbl": max(et, 0.0), | ||
| "mrro": max(runoff, 0.0), | ||
| "mrso": max(soil, 0.0), | ||
| "canopy": max(canopy, 0.0), | ||
| "gw": max(gw, 0.0), | ||
| }) | ||
| return rows | ||
|
|
||
|
|
||
| def main() -> int: | ||
| parser = argparse.ArgumentParser() | ||
| parser.add_argument("--request", required=True) | ||
| args = parser.parse_args() | ||
|
|
||
| request_path = Path(args.request).resolve() | ||
| request = json.loads(request_path.read_text()) | ||
| io_dir = request_path.parent | ||
|
|
||
| with open(io_dir / request["input"]["forcing"], newline="") as fh: | ||
| forcing = list(csv.DictReader(fh)) | ||
| static = json.loads((io_dir / request["input"]["static"]).read_text()) | ||
|
|
||
| rows = simulate(forcing, static) | ||
| out_path = io_dir / request["output"]["table"] | ||
| out_path.parent.mkdir(parents=True, exist_ok=True) | ||
| with open(out_path, "w", newline="") as fh: | ||
| writer = csv.DictWriter(fh, fieldnames=COLUMNS) | ||
| writer.writeheader() | ||
| writer.writerows(rows) | ||
| (io_dir / request["output"]["run"]).write_text( | ||
| json.dumps({"status": "ok", "model": MODEL, "n_steps": len(rows)}, indent=2) | ||
| ) | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main()) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| name: reference_interception_drier | ||
| version: "1.0.0" | ||
| description: > | ||
| Synthetic negative control for mass/interception-response (drier). | ||
| It is not physical and exists only to pin the criterion it is declared | ||
| against. | ||
| authors: ["Yuanhang Liu"] | ||
| license: PolyForm-Noncommercial-1.0.0 | ||
| runner: subprocess | ||
| entrypoint: ["python3", "ht_adapter.py"] | ||
| timestep: PT1D | ||
| emits: | ||
| fluxes: [pr, evspsbl, mrro] | ||
| states: [mrso, canopy, gw] | ||
| needs_forcing: [pr, pet] | ||
| needs_static: [canopy_capacity_mm] | ||
| supports: | ||
| perturbation: true | ||
| resources: {cpu: 1, memory_gb: 1, gpu: false} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
low: Copy-pasted comment about
blindappears in all four controls and is wrong in eachThe comment "
blindnever reads the declared value at all" sits inreference_interception_drier,_wetterand_leakyas well as_blind, at line 27 of each.In the three non-blind adapters it describes a different model and tells the reader nothing about the file they are in. In
reference_interception_blind/ht_adapter.py:27it is also false: line 23 doesdeclared = float(static["canopy_capacity_mm"]), and the value is then simply never used (noMODE == "blind"branch exists), leaving a dead local. The repo's existing shared-file controls handle this better —reference_recharge_blind/ht_adapter.py:3-12carries one docstring listing all three modes and no per-mode comment claiming to be about a sibling.Failure scenario: a maintainer debugging why
reference_interception_drierproduces a givenDelta evspsblreads line 27, believes the declared capacity is unused in that file, and misses that lines 340-345 scale the adjustment by it.Fix: move the mode descriptions into one module docstring listing all four, as the recharge controls do, and in
_blindeither drop the unuseddeclaredor note explicitly that reading and discarding it is the fault being modelled.