Cycles-utils 4.1.0__tar.gz → 4.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/Cycles_utils.egg-info/PKG-INFO +4 -2
  2. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/Cycles_utils.egg-info/SOURCES.txt +3 -2
  3. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/Cycles_utils.egg-info/requires.txt +2 -1
  4. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/PKG-INFO +4 -2
  5. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/README.md +1 -0
  6. {cycles_utils-4.1.0/cycles/cycles_tools → cycles_utils-4.2.0/cycles}/_base_file.py +22 -0
  7. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/cycles.py +2 -43
  8. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/cycles_runner.py +130 -55
  9. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/cycles_tools/control_file.py +4 -2
  10. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/cycles_tools/converter.py +1 -1
  11. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/cycles_tools/crop_file.py +2 -2
  12. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/cycles_tools/nudge_file.py +3 -2
  13. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/cycles_tools/operation_file.py +1 -1
  14. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/gadm/gadm.py +71 -5
  15. cycles_utils-4.2.0/cycles/rotation_builder.py +679 -0
  16. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/weather/weather.py +7 -5
  17. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/setup.py +4 -5
  18. cycles_utils-4.2.0/test/test_runner.py +45 -0
  19. cycles_utils-4.1.0/cycles/rotation_builder.py +0 -495
  20. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/Cycles_utils.egg-info/dependency_links.txt +0 -0
  21. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/Cycles_utils.egg-info/top_level.txt +0 -0
  22. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/LICENSE +0 -0
  23. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/MANIFEST.in +0 -0
  24. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/__init__.py +0 -0
  25. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/cycles_tools/__init__.py +0 -0
  26. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/cycles_tools/_geo_file.py +0 -0
  27. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/cycles_tools/output_file.py +0 -0
  28. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/cycles_tools/plot_tools.py +0 -0
  29. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/cycles_tools/reinit_file.py +0 -0
  30. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/cycles_tools/soil_file.py +0 -0
  31. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/cycles_tools/weather_file.py +0 -0
  32. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/data/GLDASp5_elevation_025d.nc4 +0 -0
  33. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/data/GLDASp5_landmask_025d.nc4 +0 -0
  34. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/data/NLDAS_elevation.nc4 +0 -0
  35. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/data/NLDAS_masks-veg-soil.nc4 +0 -0
  36. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/data/cb_2018_us_state_20m.cpg +0 -0
  37. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/data/cb_2018_us_state_20m.dbf +0 -0
  38. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/data/cb_2018_us_state_20m.prj +0 -0
  39. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/data/cb_2018_us_state_20m.shp +0 -0
  40. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/data/cb_2018_us_state_20m.shx +0 -0
  41. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/data/fips_gid_conversion.csv +0 -0
  42. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/data/gridMET_elevation_mask.nc +0 -0
  43. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/data/metdata_elevationdata.nc +0 -0
  44. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/data/us_states.csv +0 -0
  45. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/gadm/__init__.py +0 -0
  46. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/soilgrids/__init__.py +0 -0
  47. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/soilgrids/soilgrids.py +0 -0
  48. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/ssurgo/__init__.py +0 -0
  49. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/ssurgo/ssurgo.py +0 -0
  50. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/cycles/weather/__init__.py +0 -0
  51. {cycles_utils-4.1.0 → cycles_utils-4.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Cycles-utils
3
- Version: 4.1.0
3
+ Version: 4.2.0
4
4
  Summary: Python scripts to build Cycles input files and post-process Cycles output files
5
5
  Home-page: https://github.com/PSUmodeling/Cycles-utils
6
6
  Author: Yuning Shi
@@ -12,6 +12,8 @@ License-File: LICENSE
12
12
  Requires-Dist: pandas>=1.2.4
13
13
  Requires-Dist: numpy>=1.19.5
14
14
  Requires-Dist: matplotlib>=3.4.2
15
+ Requires-Dist: tqdm>=4.60.0
16
+ Requires-Dist: IPython>=7.0.0
15
17
  Provides-Extra: plot
16
18
  Requires-Dist: cartopy>=0.18.0; extra == "plot"
17
19
  Requires-Dist: geopandas>=0.9.0; extra == "plot"
@@ -19,7 +21,6 @@ Requires-Dist: fiona>=1.8.20; extra == "plot"
19
21
  Requires-Dist: shapely>=1.7.1; extra == "plot"
20
22
  Provides-Extra: weather
21
23
  Requires-Dist: netCDF4>=1.5.7; extra == "weather"
22
- Requires-Dist: tqdm>=4.60.0; extra == "weather"
23
24
  Requires-Dist: scipy>=1.6.3; extra == "weather"
24
25
  Provides-Extra: soilgrids
25
26
  Requires-Dist: geopandas>=0.9.0; extra == "soilgrids"
@@ -82,6 +83,7 @@ pip install Cycles-utils
82
83
 
83
84
  | Cycles-utils version | Cycles version |
84
85
  | -------------------- | -------------- |
86
+ | 4.2.0 | 1.5.20+ |
85
87
  | 4.1.0 | 1.5.20+ |
86
88
  | 4.0.6 | 1.5.20 |
87
89
  | 4.0.5 | 1.5.20 |
@@ -8,11 +8,11 @@ Cycles_utils.egg-info/dependency_links.txt
8
8
  Cycles_utils.egg-info/requires.txt
9
9
  Cycles_utils.egg-info/top_level.txt
10
10
  cycles/__init__.py
11
+ cycles/_base_file.py
11
12
  cycles/cycles.py
12
13
  cycles/cycles_runner.py
13
14
  cycles/rotation_builder.py
14
15
  cycles/cycles_tools/__init__.py
15
- cycles/cycles_tools/_base_file.py
16
16
  cycles/cycles_tools/_geo_file.py
17
17
  cycles/cycles_tools/control_file.py
18
18
  cycles/cycles_tools/converter.py
@@ -44,4 +44,5 @@ cycles/soilgrids/soilgrids.py
44
44
  cycles/ssurgo/__init__.py
45
45
  cycles/ssurgo/ssurgo.py
46
46
  cycles/weather/__init__.py
47
- cycles/weather/weather.py
47
+ cycles/weather/weather.py
48
+ test/test_runner.py
@@ -1,6 +1,8 @@
1
1
  pandas>=1.2.4
2
2
  numpy>=1.19.5
3
3
  matplotlib>=3.4.2
4
+ tqdm>=4.60.0
5
+ IPython>=7.0.0
4
6
 
5
7
  [all]
6
8
  cartopy>=0.18.0
@@ -39,5 +41,4 @@ fiona>=1.8.20
39
41
 
40
42
  [weather]
41
43
  netCDF4>=1.5.7
42
- tqdm>=4.60.0
43
44
  scipy>=1.6.3
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Cycles-utils
3
- Version: 4.1.0
3
+ Version: 4.2.0
4
4
  Summary: Python scripts to build Cycles input files and post-process Cycles output files
5
5
  Home-page: https://github.com/PSUmodeling/Cycles-utils
6
6
  Author: Yuning Shi
@@ -12,6 +12,8 @@ License-File: LICENSE
12
12
  Requires-Dist: pandas>=1.2.4
13
13
  Requires-Dist: numpy>=1.19.5
14
14
  Requires-Dist: matplotlib>=3.4.2
15
+ Requires-Dist: tqdm>=4.60.0
16
+ Requires-Dist: IPython>=7.0.0
15
17
  Provides-Extra: plot
16
18
  Requires-Dist: cartopy>=0.18.0; extra == "plot"
17
19
  Requires-Dist: geopandas>=0.9.0; extra == "plot"
@@ -19,7 +21,6 @@ Requires-Dist: fiona>=1.8.20; extra == "plot"
19
21
  Requires-Dist: shapely>=1.7.1; extra == "plot"
20
22
  Provides-Extra: weather
21
23
  Requires-Dist: netCDF4>=1.5.7; extra == "weather"
22
- Requires-Dist: tqdm>=4.60.0; extra == "weather"
23
24
  Requires-Dist: scipy>=1.6.3; extra == "weather"
24
25
  Provides-Extra: soilgrids
25
26
  Requires-Dist: geopandas>=0.9.0; extra == "soilgrids"
@@ -82,6 +83,7 @@ pip install Cycles-utils
82
83
 
83
84
  | Cycles-utils version | Cycles version |
84
85
  | -------------------- | -------------- |
86
+ | 4.2.0 | 1.5.20+ |
85
87
  | 4.1.0 | 1.5.20+ |
86
88
  | 4.0.6 | 1.5.20 |
87
89
  | 4.0.5 | 1.5.20 |
@@ -22,6 +22,7 @@ pip install Cycles-utils
22
22
 
23
23
  | Cycles-utils version | Cycles version |
24
24
  | -------------------- | -------------- |
25
+ | 4.2.0 | 1.5.20+ |
25
26
  | 4.1.0 | 1.5.20+ |
26
27
  | 4.0.6 | 1.5.20 |
27
28
  | 4.0.5 | 1.5.20 |
@@ -1,4 +1,6 @@
1
1
  from __future__ import annotations
2
+ import os
3
+ import sys
2
4
  import types
3
5
  from dataclasses import fields
4
6
  from pathlib import Path
@@ -74,3 +76,23 @@ def _unwrap_optional(t) -> type:
74
76
  if origin is Union or origin is types.UnionType or isinstance(t, types.UnionType):
75
77
  return next(arg for arg in t.__args__ if arg is not type(None))
76
78
  return t
79
+
80
+
81
+ def _if_ipython() -> bool:
82
+ try:
83
+ from IPython import get_ipython # type: ignore
84
+ shell = get_ipython()
85
+ return shell is not None and shell.__class__.__name__ == 'ZMQInteractiveShell'
86
+ except ImportError:
87
+ return False
88
+
89
+
90
+ def _disable_progress_bar() -> bool:
91
+ if _if_ipython():
92
+ return False
93
+ if os.environ.get('SLURM_JOB_ID') is not None:
94
+ return True
95
+ try:
96
+ return not sys.stderr.isatty()
97
+ except AttributeError:
98
+ return True
@@ -1,8 +1,6 @@
1
1
  from __future__ import annotations
2
2
  import numpy as np
3
- import os
4
3
  import pandas as pd
5
- import subprocess
6
4
  from collections.abc import Collection
7
5
  from dataclasses import dataclass, field
8
6
  from pathlib import Path
@@ -24,13 +22,12 @@ class Output:
24
22
  class Cycles:
25
23
  """Interface for executing one Cycles simulation and reading its files.
26
24
 
27
- Provides methods to run simulations, read outputs, and inspect soil/weather/operation configurations. Automatically
28
- loads the control file upon initialization.
25
+ Provides methods to read outputs, and inspect soil/weather/operation configurations. Automatically loads the control
26
+ file upon initialization.
29
27
 
30
28
  Args:
31
29
  path: Path to the simulation directory (containing input/ and output/ subdirs).
32
30
  simulation: Name of the simulation (base name of control file without extension).
33
- executable: Optional absolute path to the Cycles executable binary. This is required to run simulations using the `run` method.
34
31
 
35
32
  Attributes:
36
33
  path: Path to the simulation directory (containing input/ and output/ subdirs).
@@ -42,12 +39,10 @@ class Cycles:
42
39
  curve_number: Runoff curve number for hydrologic calculations.
43
40
  slope: Land slope used in erosion and runoff models.
44
41
  weather: DataFrame of weather forcing data (temperature, precipitation, etc.).
45
- executable: Absolute path to the Cycles executable binary.
46
42
  """
47
43
 
48
44
  simulation: str
49
45
  path: Path | str = '.'
50
- executable: Path | str | None = None
51
46
  output: dict[str, Output] = field(init=False, default_factory=dict[str, Output])
52
47
  control: ControlConfig | None = field(init=False, default=None)
53
48
  operations: list | None = field(init=False, default=None)
@@ -61,42 +56,6 @@ class Cycles:
61
56
  self.path = Path(self.path)
62
57
  assert isinstance(self.path, Path)
63
58
  self.control = _read_control_file(self.path / 'input' / f'{self.simulation}.ctrl')
64
- if self.executable is not None:
65
- self.executable = str(Path(self.executable).resolve())
66
-
67
-
68
- def run(self, options: str, silence: bool=False) -> tuple[int, str]:
69
- """Run the Cycles executable for this simulation.
70
-
71
- To use the `run` method, the `executable` attribute must be set to the path of the Cycles executable. The
72
- `options` string is passed directly to the command line when invoking Cycles, allowing you to specify any
73
- command-line options supported by Cycles (e.g., `-s` for spin-up, etc.).
74
-
75
- Args:
76
- options: Command-line options passed to Cycles.
77
- silence: If True, suppress stdout and stderr printing.
78
-
79
- Returns:
80
- A tuple with process return code and stdout text.
81
- """
82
- cwd = os.getcwd()
83
- cmd = [self.executable, *(options.split() if options else []), self.simulation]
84
-
85
- os.chdir(self.path)
86
- result = subprocess.run(
87
- cmd,
88
- shell=os.name == 'nt',
89
- capture_output=True,
90
- text=True,
91
- )
92
- if not silence:
93
- print(result.stdout)
94
- if result.stderr:
95
- print(result.stderr)
96
-
97
- os.chdir(cwd)
98
-
99
- return result.returncode, result.stdout
100
59
 
101
60
 
102
61
  def read_output(self, output_types: Collection) -> None:
@@ -3,15 +3,20 @@ import os
3
3
  import pandas as pd
4
4
  import shutil
5
5
  import subprocess
6
+ import warnings
7
+ from collections.abc import Sequence
6
8
  from dataclasses import dataclass
7
9
  from pathlib import Path
8
10
  from string import Template
9
11
  from typing import Any
10
12
  from .cycles import Cycles
11
13
  from .cycles_tools import generate_control_file, generate_nudge_file
12
- from .cycles_tools._base_file import _resolve_dict_values
14
+ from .cycles_tools.control_file import DEFAULT_CROP_FILE
15
+ from ._base_file import _resolve_dict_values, _if_ipython, _disable_progress_bar
16
+ if _if_ipython(): from tqdm.notebook import tqdm
17
+ else: from tqdm import tqdm
13
18
 
14
- SimulationConfig = list[dict] | pd.DataFrame
19
+ SimulationConfig = list[dict] | pd.DataFrame | list[None] | None
15
20
 
16
21
  INPUT_DIR: str = 'input'
17
22
  OUTPUT_DIR: str = 'output'
@@ -33,8 +38,10 @@ OUTPUT_CONTROL_FLAGS: dict = {
33
38
  class SimulationContext:
34
39
  name: str
35
40
  control_dict: dict
41
+ crop_dict: dict | None
36
42
  operation_dict: dict | None
37
43
  calibration_dict: dict | None
44
+ crop_fn: Path
38
45
  operation_fn: Path
39
46
 
40
47
 
@@ -57,20 +64,28 @@ class CyclesRunner:
57
64
  self.path = Path(self.path)
58
65
 
59
66
 
60
- def run(self, simulations: SimulationConfig, control_dict: dict[str, Any], *,
61
- summary: str | dict[str, str] | None=None,
62
- operation_template: Path | str | None=None, operation_dict: dict[str, Any] | None=None,
63
- calibration_dict: dict[str, Any] | None=None,
64
- options: str='', rm_input: bool=False, rm_output: bool=False, rm_steady_state_soil: bool=True, silence: bool=True, user_comment: str='') -> None:
67
+ def run(self, *, control_dict: dict[str, Any], simulations: SimulationConfig=None,
68
+ summary: Sequence[str] | None=None,
69
+ summary_prefix: str | None=None,
70
+ operation_template: Path | str | None=None, operation_dict: dict[str, Any] | None=None,
71
+ crop_template: Path | str | None=None, crop_dict: dict[str, Any] | None=None,
72
+ calibration_dict: dict[str, Any] | None=None,
73
+ options: str='', rm_input: bool=False, rm_output: bool=False, rm_steady_state_soil: bool=True, silence: bool=True, user_comment: str='',
74
+ _progress_bar: tqdm | None=None) -> None: # type: ignore
65
75
  """Execute a batch of simulations and write a consolidated summary.
66
76
 
67
77
  Args:
68
78
  simulations: Simulation configurations as list of dicts or a DataFrame. Each dict or DataFrame row should be
69
79
  corresponding to a single simulation and contain values to support the control, operation, and
70
- calibration dictionaries.
80
+ calibration dictionaries. If None, a single simulation is run directly from `control_dict`/
81
+ `operation_dict` (no per-row substitution). In this single-run mode, the summary CSV is **not**
82
+ written and `rm_input`/`rm_output`/`rm_steady_state_soil` are **not** applied, regardless of how
83
+ those flags are set -- the full input/output is always left in place for inspection.
71
84
  control_dict: Control-file values or callables evaluated per simulation.
72
- summary: Summary CSV name for the summary harvest file written under summary directory. If a dictionary is provided, the keys are output file types and the values are
73
- summary CSV names. If None, only the harvest summary is written into `summary/summary.csv`.
85
+ summary: List or tuple of output file types to be summarized/aggregated. If None, only the harvest summary is written into `summary/harvest.csv`.
86
+ summary_prefix: Prefix for the summary CSV files. If None, no prefix is added.
87
+ crop_template: Template file for generated crop files.
88
+ crop_dict: Substitutions used with crop template.
74
89
  operation_template: Template file for generated operation files.
75
90
  operation_dict: Substitutions used with operation template.
76
91
  calibration_dict: Nudge-file values or callables per simulation.
@@ -81,6 +96,10 @@ class CyclesRunner:
81
96
  silence: If True, suppress simulation screen output.
82
97
  user_comment: Optional text prefixed to summary header comments.
83
98
 
99
+ In batch mode (`simulations` is a list/DataFrame), a simulation that fails (non-zero exit code) is skipped --
100
+ its summary row is not written -- but does not abort the remaining batch; cleanup (`rm_input`/`rm_output`)
101
+ still runs for the failed row if requested.
102
+
84
103
  The following fields are required in `control_dict`:
85
104
 
86
105
  - `simulation_name`
@@ -139,8 +158,9 @@ class CyclesRunner:
139
158
  }
140
159
  ```
141
160
 
142
- The operation dictionary should work with a template operation file to generate the appropriate operation files for each simulation. In the template operation file, use
143
- placeholders for planting `DOY`, `END_DOY`, and `CROP` like below:
161
+ The operation dictionary should work with a template operation file to generate the appropriate operation files
162
+ for each simulation. In the template operation file, use placeholders for planting `DOY`, `END_DOY`, and `CROP`
163
+ like below:
144
164
 
145
165
  ```
146
166
  DOY $PD1
@@ -148,7 +168,8 @@ class CyclesRunner:
148
168
  CROP $CROP
149
169
  ```
150
170
 
151
- Then define the operation dictionary to substitute the placeholders with values from the simulation configurations:
171
+ Then define the operation dictionary to substitute the placeholders with values from the simulation
172
+ configurations:
152
173
 
153
174
  ```python
154
175
  operation_dict: dict = {
@@ -166,60 +187,65 @@ class CyclesRunner:
166
187
  control_dict=control_dict,
167
188
  operation_template='path/to/template.operation',
168
189
  operation_dict=operation_dict,
169
- summary='summary.csv',
170
190
  options='-s',
171
191
  )
172
192
  ```
173
193
 
174
- The `-s` option enables spin-up for the simulations. The results will be consolidated into `summary/summary.csv`.
194
+ The `-s` option enables spin-up for the simulations. The results will be consolidated into `summary/harvest.csv`.
175
195
  """
176
- if isinstance(simulations, pd.DataFrame):
177
- simulations = simulations.to_dict(orient='records')
178
- assert isinstance(simulations, list)
196
+ if calibration_dict is not None and 'n' not in options:
197
+ warnings.warn('Nudge parameters are provided but Cycles is not running in nudge mode.', UserWarning)
179
198
 
180
- if (operation_template is None) != (operation_dict is None):
181
- raise ValueError(
182
- "operation_template and operation_dict must be provided together or not at all. "
183
- f"Got operation_template={'None' if operation_template is None else repr(operation_template)}, "
184
- f"operation_dict={'None' if operation_dict is None else '...'}"
185
- )
199
+ _check_template_and_dict('crop', crop_template, crop_dict)
200
+ _check_template_and_dict('operation', operation_template, operation_dict)
186
201
 
202
+ crop_template = Path(crop_template) if crop_template is not None else None
187
203
  operation_template = Path(operation_template) if operation_template is not None else None
188
- if user_comment:
189
- user_comment = f'# {user_comment.lstrip("# ").rstrip()}\n'
204
+ user_comment = f'# {user_comment.lstrip("# ").rstrip()}\n' if user_comment else ''
190
205
  comment = user_comment + _generate_comment(self.executable, options)
191
206
  first_run = True
192
207
 
193
208
  if summary is None:
194
- summary = {'harvest': 'summary.csv'}
195
- elif isinstance(summary, str):
196
- summary = {'harvest': summary}
197
- assert isinstance(summary, dict)
209
+ summary = ['harvest']
210
+ else:
211
+ summary = list(set(summary) | {'harvest'})
198
212
 
199
- for key in summary.keys():
200
- if key == 'harvest': continue
201
- control_dict[OUTPUT_CONTROL_FLAGS[key]] = 1
213
+ for s in summary:
214
+ if s == 'harvest': continue
215
+ control_dict[OUTPUT_CONTROL_FLAGS[s]] = 1
202
216
 
203
217
  assert isinstance(self.path, Path)
204
218
  (self.path / SUMMARY_DIR).mkdir(exist_ok=True)
205
219
 
220
+ simulations = _prepare_simulations(simulations)
221
+
222
+ owns_progress = _progress_bar is None and silence and len(simulations) > 1
223
+ progress = tqdm(simulations, unit='simulation', disable=_disable_progress_bar()) if owns_progress else _progress_bar
224
+
225
+ def _report(message: str) -> None:
226
+ tqdm.write(message) if progress is not None else print(message)
227
+
228
+ assert simulations is not None
206
229
  for s in simulations:
207
- cxt: SimulationContext = self._resolve(s, control_dict, operation_dict, calibration_dict)
208
- print(f'{cxt.name} - ', end='')
230
+ cxt: SimulationContext = self._resolve(s, control_dict, crop_dict, operation_dict, calibration_dict)
231
+ if owns_progress and progress is not None:
232
+ progress.set_description(f'Running {cxt.name}')
209
233
 
210
- self._write_inputs(cxt, operation_template)
234
+ self._write_inputs(cxt, crop_template, operation_template)
211
235
 
212
- cycles = Cycles(simulation=cxt.name, path=self.path, executable=self.executable)
236
+ code, _ = _run_cycles_simulation(self.path, self.executable, cxt.name, options, silence)
237
+ if code != 0:
238
+ _report(f'{cxt.name} - Fail (exit code {code})')
239
+ if progress is not None:
240
+ progress.update()
213
241
 
214
- code, _ = cycles.run(options=options, silence=silence)
242
+ if s is None:
243
+ return
215
244
 
216
245
  if code == 0:
217
- self._write_summary(cycles, summary, header=first_run, comment=comment)
246
+ cycles = Cycles(simulation=cxt.name, path=self.path)
247
+ _write_summary(self.path, cycles, summary, summary_prefix, header=first_run, comment=comment)
218
248
  first_run = False
219
- print('Success')
220
- elif code == 1:
221
- print('Fail')
222
-
223
249
  if rm_input:
224
250
  self._remove_inputs(cxt)
225
251
  if rm_output:
@@ -229,21 +255,30 @@ class CyclesRunner:
229
255
  # If using an existing steady-state soil file, it should not be removed.
230
256
  (self.path / INPUT_DIR / f'{cxt.name}_ss.soil').unlink(missing_ok=True)
231
257
 
258
+ if owns_progress and progress is not None:
259
+ progress.set_description('Done')
260
+
232
261
 
233
- def _resolve(self, simulation: dict[str, Any], control_dict: dict[str, Any], operation_dict: dict[str, Any] | None, calibration_dict: dict[str, Any] | None) -> SimulationContext:
262
+ def _resolve(self, simulation: dict[str, Any] | None, control_dict: dict[str, Any], crop_dict: dict[str, Any] | None, operation_dict: dict[str, Any] | None,
263
+ calibration_dict: dict[str, Any] | None) -> SimulationContext:
234
264
  control = _resolve_dict_values(control_dict, simulation)
235
265
  assert isinstance(self.path, Path)
236
266
  return SimulationContext(
237
267
  name=control['simulation_name'],
238
268
  control_dict=control,
269
+ crop_dict=_resolve_dict_values(crop_dict, simulation) if crop_dict is not None else None,
239
270
  operation_dict=_resolve_dict_values(operation_dict, simulation) if operation_dict is not None else None,
240
271
  calibration_dict=_resolve_dict_values(calibration_dict, simulation) if calibration_dict is not None else None,
241
272
  operation_fn=self.path / INPUT_DIR / control['operation_file'],
273
+ crop_fn=self.path / INPUT_DIR / control.get('crop_file', DEFAULT_CROP_FILE),
242
274
  )
243
275
 
244
276
 
245
- def _write_inputs(self, cxt: SimulationContext, operation_template: Path | None) -> None:
277
+ def _write_inputs(self, cxt: SimulationContext, crop_template: Path | None, operation_template: Path | None) -> None:
246
278
  assert isinstance(self.path, Path)
279
+ if crop_template is not None:
280
+ assert cxt.crop_dict is not None
281
+ _render_template(crop_template, cxt.crop_fn, cxt.crop_dict)
247
282
  if operation_template is not None:
248
283
  assert cxt.operation_dict is not None
249
284
  _render_template(operation_template, cxt.operation_fn, cxt.operation_dict)
@@ -256,21 +291,31 @@ class CyclesRunner:
256
291
  assert isinstance(self.path, Path)
257
292
  (self.path / INPUT_DIR / f'{cxt.name}.ctrl').unlink(missing_ok=True)
258
293
  (self.path / INPUT_DIR / f'{cxt.name}.nudge').unlink(missing_ok=True)
294
+ if cxt.crop_dict is not None:
295
+ cxt.crop_fn.unlink(missing_ok=True)
259
296
  if cxt.operation_dict is not None:
260
297
  cxt.operation_fn.unlink(missing_ok=True)
261
298
 
262
299
 
263
- def _write_summary(self, cycles: Cycles, summary: dict, *, header: bool, comment: str) -> None:
264
- assert isinstance(self.path, Path)
265
- cycles.read_output(summary.keys())
266
- for key, fn in summary.items():
267
- cycles.output[key].data.insert(0, 'simulation', cycles.simulation)
300
+ def _write_summary(path: Path, cycles: Cycles, summary: list[str], summary_prefix: str | None, *, header: bool, comment: str) -> None:
301
+ cycles.read_output(summary)
302
+ for s in summary:
303
+ cycles.output[s].data.insert(0, 'simulation', cycles.simulation)
304
+
305
+ mode = 'w' if header else 'a'
306
+ with open(path / SUMMARY_DIR / (f'{summary_prefix}_{s}.csv' if summary_prefix else f'{s}.csv'), mode) as f:
307
+ if header:
308
+ f.write(comment)
309
+ cycles.output[s].data.to_csv(f, header=header, index=False)
310
+
268
311
 
269
- mode = 'w' if header else 'a'
270
- with open(self.path / SUMMARY_DIR / fn, mode) as f:
271
- if header:
272
- f.write(comment)
273
- cycles.output[key].data.to_csv(f, header=header, index=False)
312
+ def _check_template_and_dict(input_type: str, template_name: Path | str | None, dict_name: dict | None):
313
+ if (template_name is None) != (dict_name is None):
314
+ raise ValueError(
315
+ f"{input_type}_template and {input_type}_dict must be provided together or not at all. "
316
+ f"Got {input_type}_template={'None' if template_name is None else repr(template_name)}, "
317
+ f"{input_type}_dict={'None' if dict_name is None else '...'}"
318
+ )
274
319
 
275
320
 
276
321
  def _render_template(template_fn: Path, dest_fn: Path, substitutions: dict) -> None:
@@ -293,3 +338,33 @@ def _generate_comment(executable: str, options: str) -> str:
293
338
  'dynamically reduced fertilization rates' if 'x' in options else None,
294
339
  ]
295
340
  return ', '.join(p for p in parts if p) + '\n'
341
+
342
+
343
+ def _run_cycles_simulation(path: Path, executable: str, simulation: str, options: str, silence: bool) -> tuple[int, str]:
344
+ cwd = os.getcwd()
345
+ cmd = [executable, *(options.split() if options else []), simulation]
346
+
347
+ os.chdir(path)
348
+ result = subprocess.run(
349
+ cmd,
350
+ shell=os.name == 'nt',
351
+ capture_output=True,
352
+ text=True,
353
+ )
354
+ if not silence:
355
+ print(result.stdout)
356
+ if result.stderr:
357
+ print(result.stderr)
358
+
359
+ os.chdir(cwd)
360
+
361
+ return result.returncode, result.stdout
362
+
363
+
364
+ def _prepare_simulations(simulations: SimulationConfig) -> list:
365
+ if simulations is None:
366
+ simulations = [None]
367
+ if isinstance(simulations, pd.DataFrame):
368
+ simulations = simulations.to_dict(orient='records')
369
+
370
+ return simulations
@@ -3,7 +3,9 @@ import warnings
3
3
  from dataclasses import dataclass, field, fields
4
4
  from pathlib import Path
5
5
  from typing import Any, get_type_hints
6
- from ._base_file import _write_file, _resolve_dict_values, _extract, _parse_value, _unwrap_optional, FMT_1F
6
+ from .._base_file import _write_file, _resolve_dict_values, _extract, _parse_value, _unwrap_optional, FMT_1F
7
+
8
+ DEFAULT_CROP_FILE = 'GenericCrops.crop'
7
9
 
8
10
  @dataclass(kw_only=True)
9
11
  class SimulationYears:
@@ -13,7 +15,7 @@ class SimulationYears:
13
15
 
14
16
  @dataclass(kw_only=True)
15
17
  class InputFiles:
16
- crop_file: str = 'GenericCrops.crop'
18
+ crop_file: str = DEFAULT_CROP_FILE
17
19
  operation_file: str
18
20
  soil_file: str
19
21
  weather_file: str
@@ -1,7 +1,7 @@
1
1
  from dataclasses import dataclass, field, fields
2
2
  from pathlib import Path
3
3
  from typing import get_type_hints, Any
4
- from ._base_file import _unwrap_optional, _format_field, FMT_1F, FMT_2F, FMT_3F
4
+ from .._base_file import _unwrap_optional, _format_field, FMT_1F, FMT_2F, FMT_3F
5
5
  from .operation_file import Planting, Tillage, FixedFertilization
6
6
  from .operation_file import generate_operation_file
7
7
  from .crop_file import Crop, generate_crop_file
@@ -2,8 +2,8 @@ from __future__ import annotations
2
2
  from dataclasses import dataclass, field, fields
3
3
  from pathlib import Path
4
4
  from typing import Any, get_type_hints, Protocol
5
- from ._base_file import _write_file, _resolve_dict_values, _extract, _parse_value, _unwrap_optional, _format_block
6
- from ._base_file import FMT_1F, FMT_2F, FMT_3F, FMT_4F
5
+ from .._base_file import _write_file, _resolve_dict_values, _extract, _parse_value, _unwrap_optional, _format_block
6
+ from .._base_file import FMT_1F, FMT_2F, FMT_3F, FMT_4F
7
7
 
8
8
  @dataclass(kw_only=True)
9
9
  class Phenology:
@@ -2,8 +2,8 @@ from __future__ import annotations
2
2
  from dataclasses import dataclass, field
3
3
  from pathlib import Path
4
4
  from typing import Any
5
- from ._base_file import _write_file, _resolve_dict_values, _extract
6
- from ._base_file import FMT_2F
5
+ from .._base_file import _write_file, _resolve_dict_values, _extract
6
+ from .._base_file import FMT_2F
7
7
 
8
8
  @dataclass(kw_only=True)
9
9
  class CalibrationMultipliers:
@@ -18,6 +18,7 @@ class CalibrationMultipliers:
18
18
  nitrif_rate: float = field(default=1.0, metadata={'description': 'nitrification rate', 'fmt': FMT_2F})
19
19
  pot_denitrif_rate: float = field(default=1.0, metadata={'description': 'potential denitrification rate', 'fmt': FMT_2F})
20
20
  denitrif_half_rate: float = field(default=1.0, metadata={'description': 'half saturation constant for denitrification', 'fmt': FMT_2F})
21
+ n2o_respiration_factor: float = field(default=1.0, metadata={'description': 'respiration factor that modulates proportion of N2O from denitrification', 'fmt': FMT_2F})
21
22
  decomp_half_resp: float = field(default=1.0, metadata={'description': 'decomposition half response to saturation (default 0.22)', 'fmt': FMT_2F})
22
23
  decomp_resp_power: float = field(default=1.0, metadata={'description': 'decomposition exponential response to saturation (default 3.0)', 'fmt': FMT_2F})
23
24
  root_progression: float = field(default=1.0, metadata={'description': 'rooting depth progression rate', 'fmt': FMT_2F})
@@ -3,7 +3,7 @@ import re
3
3
  from pathlib import Path
4
4
  from dataclasses import dataclass, field, fields
5
5
  from typing import get_type_hints, Protocol, Any
6
- from ._base_file import _parse_value, FMT_1F, FMT_2F, _format_field
6
+ from .._base_file import _parse_value, FMT_1F, FMT_2F, _format_field
7
7
 
8
8
  class Operation(Protocol):
9
9
  year: int | None