Cycles-utils 4.0.4__tar.gz → 4.0.6__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 (47) hide show
  1. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/Cycles_utils.egg-info/PKG-INFO +4 -2
  2. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/Cycles_utils.egg-info/SOURCES.txt +5 -0
  3. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/Cycles_utils.egg-info/requires.txt +1 -0
  4. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/PKG-INFO +4 -2
  5. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/README.md +2 -1
  6. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/__init__.py +1 -0
  7. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/cycles.py +35 -15
  8. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/cycles_runner.py +139 -20
  9. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/cycles_tools/__init__.py +4 -0
  10. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/cycles_tools/_base_file.py +29 -1
  11. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/cycles_tools/control_file.py +19 -19
  12. cycles_utils-4.0.6/cycles/cycles_tools/nudge_file.py +63 -0
  13. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/cycles_tools/operation_file.py +61 -16
  14. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/cycles_tools/output_file.py +5 -5
  15. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/cycles_tools/plot_tools.py +53 -14
  16. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/cycles_tools/soil_file.py +13 -13
  17. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/cycles_tools/weather_file.py +3 -3
  18. cycles_utils-4.0.6/cycles/data/cb_2018_us_state_20m.cpg +1 -0
  19. cycles_utils-4.0.6/cycles/data/cb_2018_us_state_20m.dbf +0 -0
  20. cycles_utils-4.0.6/cycles/data/cb_2018_us_state_20m.prj +1 -0
  21. cycles_utils-4.0.6/cycles/data/cb_2018_us_state_20m.shp +0 -0
  22. cycles_utils-4.0.6/cycles/data/cb_2018_us_state_20m.shx +0 -0
  23. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/rotation_builder.py +13 -33
  24. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/soilgrids/soilgrids.py +14 -9
  25. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/ssurgo/ssurgo.py +103 -57
  26. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/setup.py +2 -2
  27. cycles_utils-4.0.4/cycles/cycles_tools/nudge_file.py +0 -63
  28. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/Cycles_utils.egg-info/dependency_links.txt +0 -0
  29. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/Cycles_utils.egg-info/top_level.txt +0 -0
  30. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/LICENSE +0 -0
  31. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/MANIFEST.in +0 -0
  32. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/cycles_tools/reinit_file.py +0 -0
  33. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/data/GLDASp5_elevation_025d.nc4 +0 -0
  34. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/data/GLDASp5_landmask_025d.nc4 +0 -0
  35. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/data/NLDAS_elevation.nc4 +0 -0
  36. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/data/NLDAS_masks-veg-soil.nc4 +0 -0
  37. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/data/fips_gid_conversion.csv +0 -0
  38. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/data/gridMET_elevation_mask.nc +0 -0
  39. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/data/metdata_elevationdata.nc +0 -0
  40. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/data/us_states.csv +0 -0
  41. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/gadm/__init__.py +0 -0
  42. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/gadm/gadm.py +0 -0
  43. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/soilgrids/__init__.py +0 -0
  44. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/ssurgo/__init__.py +0 -0
  45. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/weather/__init__.py +0 -0
  46. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/cycles/weather/weather.py +0 -0
  47. {cycles_utils-4.0.4 → cycles_utils-4.0.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Cycles-utils
3
- Version: 4.0.4
3
+ Version: 4.0.6
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
@@ -21,6 +21,7 @@ Requires-Dist: rasterio>=1.2.3; extra == "soilgrids"
21
21
  Requires-Dist: shapely>=1.7.1; extra == "soilgrids"
22
22
  Provides-Extra: gssurgo
23
23
  Requires-Dist: shapely>=1.7.1; extra == "gssurgo"
24
+ Requires-Dist: fiona>=1.8.20; extra == "gssurgo"
24
25
  Provides-Extra: weather
25
26
  Requires-Dist: netCDF4>=1.5.7; extra == "weather"
26
27
  Requires-Dist: tqdm>=4.60.0; extra == "weather"
@@ -60,5 +61,6 @@ pip install Cycles-utils
60
61
 
61
62
  | Cycles-utils version | Cycles version |
62
63
  | -------------------- | -------------- |
64
+ | 4.0.6 | 1.5.20 |
65
+ | 4.0.5 | 1.5.20 |
63
66
  | 4.0.4 | 1.5.20 |
64
-
@@ -25,6 +25,11 @@ cycles/data/GLDASp5_elevation_025d.nc4
25
25
  cycles/data/GLDASp5_landmask_025d.nc4
26
26
  cycles/data/NLDAS_elevation.nc4
27
27
  cycles/data/NLDAS_masks-veg-soil.nc4
28
+ cycles/data/cb_2018_us_state_20m.cpg
29
+ cycles/data/cb_2018_us_state_20m.dbf
30
+ cycles/data/cb_2018_us_state_20m.prj
31
+ cycles/data/cb_2018_us_state_20m.shp
32
+ cycles/data/cb_2018_us_state_20m.shx
28
33
  cycles/data/fips_gid_conversion.csv
29
34
  cycles/data/gridMET_elevation_mask.nc
30
35
  cycles/data/metdata_elevationdata.nc
@@ -6,6 +6,7 @@ matplotlib>=3.4.2
6
6
 
7
7
  [gssurgo]
8
8
  shapely>=1.7.1
9
+ fiona>=1.8.20
9
10
 
10
11
  [soilgrids]
11
12
  rioxarray>=0.5.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Cycles-utils
3
- Version: 4.0.4
3
+ Version: 4.0.6
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
@@ -21,6 +21,7 @@ Requires-Dist: rasterio>=1.2.3; extra == "soilgrids"
21
21
  Requires-Dist: shapely>=1.7.1; extra == "soilgrids"
22
22
  Provides-Extra: gssurgo
23
23
  Requires-Dist: shapely>=1.7.1; extra == "gssurgo"
24
+ Requires-Dist: fiona>=1.8.20; extra == "gssurgo"
24
25
  Provides-Extra: weather
25
26
  Requires-Dist: netCDF4>=1.5.7; extra == "weather"
26
27
  Requires-Dist: tqdm>=4.60.0; extra == "weather"
@@ -60,5 +61,6 @@ pip install Cycles-utils
60
61
 
61
62
  | Cycles-utils version | Cycles version |
62
63
  | -------------------- | -------------- |
64
+ | 4.0.6 | 1.5.20 |
65
+ | 4.0.5 | 1.5.20 |
63
66
  | 4.0.4 | 1.5.20 |
64
-
@@ -22,5 +22,6 @@ pip install Cycles-utils
22
22
 
23
23
  | Cycles-utils version | Cycles version |
24
24
  | -------------------- | -------------- |
25
+ | 4.0.6 | 1.5.20 |
26
+ | 4.0.5 | 1.5.20 |
25
27
  | 4.0.4 | 1.5.20 |
26
-
@@ -8,6 +8,7 @@ from .cycles_tools import read_soil_file
8
8
  from .cycles_tools import read_weather_file
9
9
  from .cycles_tools import read_output
10
10
  from .cycles_tools import read_operation_file
11
+ from .cycles_tools import generate_operation_file
11
12
  from .cycles_tools import SoilLayer
12
13
  from .cycles_tools import plot_yield
13
14
  from .cycles_tools import plot_operations
@@ -29,6 +29,11 @@ class Cycles:
29
29
  Provides methods to run simulations, read outputs, and inspect soil/weather/operation configurations. Automatically
30
30
  loads the control file upon initialization.
31
31
 
32
+ Args:
33
+ path: Path to the simulation directory (containing input/ and output/ subdirs).
34
+ simulation: Name of the simulation (base name of control file without extension).
35
+ executable: Optional absolute path to the Cycles executable binary. This is required to run simulations using the `run` method.
36
+
32
37
  Attributes:
33
38
  path: Path to the simulation directory (containing input/ and output/ subdirs).
34
39
  simulation: Name of the simulation (base name of control file without extension).
@@ -44,14 +49,14 @@ class Cycles:
44
49
 
45
50
  path: Path | str
46
51
  simulation: str
47
- output: dict[str, Output] = field(default_factory=dict[str, Output])
48
- control: ControlConfig | None = None
49
- operations: list | None = None
50
- soil_profile: list[SoilLayer] | None = None
51
- curve_number: int | None = None
52
- slope: float | None = None
53
- weather: pd.DataFrame | None = None
54
52
  executable: Path | str | None = None
53
+ output: dict[str, Output] = field(init=False, default_factory=dict[str, Output])
54
+ control: ControlConfig | None = field(init=False, default=None)
55
+ operations: list | None = field(init=False, default=None)
56
+ soil_profile: list[SoilLayer] | None = field(init=False, default=None)
57
+ curve_number: int | None = field(init=False, default=None)
58
+ slope: float | None = field(init=False, default=None)
59
+ weather: pd.DataFrame | None = field(init=False, default=None)
55
60
 
56
61
 
57
62
  def __post_init__(self):
@@ -65,6 +70,10 @@ class Cycles:
65
70
  def run(self, options: str, silence: bool=False) -> tuple[int, str]:
66
71
  """Run the Cycles executable for this simulation.
67
72
 
73
+ To use the `run` method, the `executable` attribute must be set to the path of the Cycles executable. The
74
+ `options` string is passed directly to the command line when invoking Cycles, allowing you to specify any
75
+ command-line options supported by Cycles (e.g., `-s` for spin-up, etc.).
76
+
68
77
  Args:
69
78
  options: Command-line options passed to Cycles.
70
79
  silence: If True, suppress stdout and stderr printing.
@@ -89,6 +98,9 @@ class Cycles:
89
98
  def read_output(self, output_types: Collection) -> None:
90
99
  """Read one or more output tables into memory.
91
100
 
101
+ When reading multiple output tables, the `output_types` argument can be a list or tuple of table names. Each
102
+ table is read into a pandas DataFrame and stored in the `output` dictionary with its corresponding units.
103
+
92
104
  Args:
93
105
  output_types: Output table name or collection of names.
94
106
  """
@@ -130,32 +142,40 @@ class Cycles:
130
142
  self.weather = _read_weather_file(self.path / 'input' / self.control.input_files.weather_file, start_year=start_year, end_year=end_year, subdaily=subdaily)
131
143
 
132
144
 
133
- def generate_reinit_file(self, doy: int, *, reinit: str | None=None) -> None:
145
+ def generate_reinit_file(self, doy: int, *, reinit_name: str | None=None) -> None:
134
146
  """Generate a reinitialization file from model output.
135
147
 
136
148
  Args:
137
149
  doy: Day-of-year to extract from reinit output.
138
- reinit: Optional output stem for the reinit file.
150
+ reinit_name: Optional output stem for the reinit file.
139
151
  """
140
152
  assert isinstance(self.path, Path)
141
- _generate_reinit_file(self.path / 'input' / f'{self.simulation if reinit is None else reinit}.reinit', self.path / 'output' / self.simulation, doy)
153
+ _generate_reinit_file(self.path / 'input' / f'{self.simulation if reinit_name is None else reinit_name}.reinit', self.path / 'output' / self.simulation, doy)
142
154
 
143
155
 
144
- def plot_yield(self, *, ax: Axes | None=None, fontsize: int | None=None) -> Axes:
145
- """Plot grain and forage yields from harvest output."""
156
+ def plot_yield(self, *, ax: Axes | None=None, crop_colors: dict | None=None, fontsize: int | None=None) -> Axes:
157
+ """Plot grain and forage yields from harvest output.
158
+
159
+ Args:
160
+ ax: Optional axes to draw on.
161
+ crop_colors: Optional mapping from crop names to plot colors.
162
+ fontsize: Optional global font size override.
163
+
164
+ Returns:
165
+ Axes containing the yield plot.
166
+ """
146
167
  if 'harvest' not in self.output:
147
168
  self.read_output('harvest')
148
169
 
149
- return _plot_yield(self.output['harvest'].data, ax=ax, fontsize=fontsize)
170
+ return _plot_yield(self.output['harvest'].data, ax=ax, crop_colors=crop_colors, fontsize=fontsize)
150
171
 
151
172
 
152
173
  def plot_operations(self, *, axs: Axes | np.ndarray | None=None, fontsize: int | None=None):
153
174
  """Plot operation timelines grouped by rotation year.
154
175
 
155
176
  Args:
156
- rotation_size: Number of years in the plotted rotation.
157
177
  axs: Optional axes object(s) to draw on.
158
- fontsize: Global matplotlib font size override.
178
+ fontsize: Optional global matplotlib font size override.
159
179
 
160
180
  Returns:
161
181
  The axes used for plotting.
@@ -16,6 +16,18 @@ INPUT_DIR: Path = Path('input')
16
16
  OUTPUT_DIR: Path = Path('output')
17
17
  SUMMARY_DIR: Path = Path('summary')
18
18
 
19
+ OUTPUT_CONTROL_FLAGS: dict = {
20
+ 'dailyEnviron': 'daily_weather_out',
21
+ 'dailyResidue': 'daily_residue_out',
22
+ 'dailyWater': 'daily_water_out',
23
+ 'dailyN': 'daily_nitrogen_out',
24
+ 'dailySoilC': 'daily_soil_carbon_out',
25
+ 'dailySoilLayersCN': 'daily_soil_lyr_cn_out',
26
+ 'annualSOM': 'annual_soil_out',
27
+ 'annualSoilProfileC': 'annual_profile_out',
28
+ 'annualN': 'annual_nflux_out',
29
+ }
30
+
19
31
  @dataclass
20
32
  class SimulationContext:
21
33
  name: str
@@ -32,36 +44,131 @@ class CyclesRunner:
32
44
  Manages batch execution of Cycles simulations by generating control files, operation files, and nudge files from
33
45
  templates and parameter dictionaries. Consolidates results into a summary CSV file.
34
46
 
35
- Attributes:
47
+ Args:
36
48
  executable: Absolute path to the Cycles executable binary.
37
- rotation_builder: If True, disables spin-up option and enables rotation features.
38
49
  """
39
50
 
40
51
  executable: str
41
- rotation_builder: bool = False
42
52
 
43
53
  def __post_init__(self):
44
54
  self.executable = str(Path(self.executable).resolve())
45
55
 
46
56
 
47
57
  def run(self, simulations: SimulationConfig, control_dict: dict[str, Any], *,
48
- summary: str='summary.csv', operation_template: Path | str | None=None, operation_dict: dict[str, Any] | None=None, calibration_dict: dict[str, Any] | None=None,
58
+ summary: str | dict[str, str] | None=None,
59
+ operation_template: Path | str | None=None, operation_dict: dict[str, Any] | None=None,
60
+ calibration_dict: dict[str, Any] | None=None,
49
61
  options: str='', rm_input: bool=False, rm_output: bool=False, rm_steady_state_soil: bool=True, silence: bool=True, user_comment: str='') -> None:
50
62
  """Execute a batch of simulations and write a consolidated summary.
51
63
 
52
64
  Args:
53
- simulations: Simulation rows as list of dicts or a DataFrame.
54
- control_dict: Control-file values or callables evaluated per row.
55
- summary: Summary CSV name written under summary directory.
65
+ simulations: Simulation configurations as list of dicts or a DataFrame. Each dict or DataFrame row should be
66
+ corresponding to a single simulation and contain values to support the control, operation, and
67
+ calibration dictionaries.
68
+ control_dict: Control-file values or callables evaluated per simulation.
69
+ 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
70
+ summary CSV names. If None, only the harvest summary is written into `summary/summary.csv`.
56
71
  operation_template: Template file for generated operation files.
57
72
  operation_dict: Substitutions used with operation template.
58
- calibration_dict: Nudge-file values or callables per row.
73
+ calibration_dict: Nudge-file values or callables per simulation.
59
74
  options: Cycles command options.
60
75
  rm_input: Remove generated input files after each run.
61
76
  rm_output: Remove run output directory after each run.
62
77
  rm_steady_state_soil: Remove generated steady-state soil file.
63
78
  silence: If True, suppress simulation screen output.
64
79
  user_comment: Optional text prefixed to summary header comments.
80
+
81
+ The following fields are required in `control_dict`:
82
+
83
+ - `simulation_name`
84
+ - `simulation_start_year`
85
+ - `simulation_end_year`
86
+ - `rotation_size`
87
+ - `operation_file`
88
+ - `soil_file`
89
+ - `weather_file`
90
+
91
+ The default values for other fields are:
92
+
93
+ - `crop_file`: `GenericCrops.crop`
94
+ - `reinit_file`: `N/A`
95
+ - `soil_layers`: inferred from the soil file (if not provided)
96
+ - `co2_level`: `-999`
97
+ - `use_reinitialization`: `0`
98
+ - `adjusted_yields`: `0`
99
+ - `hydrology_option`: `1`
100
+ - `automatic_nitrogen`: `0`
101
+ - `automatic_phosphorus`: `0`
102
+ - `automatic_sulfur`: `0`
103
+
104
+ All output control fields default to `0`.
105
+
106
+ Note that `simulation_name` is used to generate the control file name for each simulation. The `simulation_name`
107
+ should be unique for each simulation in the batch.
108
+
109
+ #### Example:
110
+
111
+ To run a batch simulation of continuous corn in different counties of Iowa, you can use the following code snippet:
112
+ ```python
113
+ from cycles import CyclesRunner
114
+
115
+ runner = CyclesRunner(executable='/path/to/Cycles')
116
+
117
+ simulations: list[dict] = [
118
+ 'GID': 'USA.16.1_1', 'weather': 'NLDAS_41.438Nx94.562W', 'soil': 'maize_rainfed_SoilGrids_USA.16.1_1.soil', 'plant_start': 112, 'plant_end': 154, 'maturity_group': 100,
119
+ 'GID': 'USA.16.2_1', 'weather': 'NLDAS_40.938Nx94.688W', 'soil': 'maize_rainfed_SoilGrids_USA.16.2_1.soil', 'plant_start': 112, 'plant_end': 154, 'maturity_group': 100,
120
+ 'GID': 'USA.16.3_1', 'weather': 'NLDAS_43.188Nx91.562W', 'soil': 'maize_rainfed_SoilGrids_USA.16.3_1.soil', 'plant_start': 112, 'plant_end': 154, 'maturity_group': 90,
121
+ ]
122
+ ```
123
+
124
+ The control dictionary should work with the simulation configurations to generate the appropriate control files for each simulation:
125
+
126
+ ```python
127
+ control_dict: dict = {
128
+ 'simulation_name': lambda x: x['GID'],
129
+ 'simulation_start_year': 1981,
130
+ 'simulation_end_year': 2016,
131
+ 'rotation_size': 1,
132
+ 'crop_file': 'GenericCrops.crop',
133
+ 'operation_file': lambda x: f'{x["GID"]}.operation',
134
+ 'soil_file': lambda x: f'path/to/{x["soil"]}',
135
+ 'weather_file': lambda x: f'path/to/{x["gridMET_weather"]}.weather',
136
+ }
137
+ ```
138
+
139
+ 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
140
+ placeholders for planting `DOY`, `END_DOY`, and `CROP` like below:
141
+
142
+ ```
143
+ DOY $PD1
144
+ END_DOY $PD2
145
+ CROP $CROP
146
+ ```
147
+
148
+ Then define the operation dictionary to substitute the placeholders with values from the simulation configurations:
149
+
150
+ ```python
151
+ operation_dict: dict = {
152
+ 'PD1': lambda x: x['plant_start'],
153
+ 'PD2': lambda x: x['plant_end'],
154
+ 'CROP': lambda x: f'CornRM.{x["relative_maturity_group"]}',
155
+ }
156
+ ```
157
+
158
+ Finally, run the simulations with the following code snippet:
159
+
160
+ ```python
161
+ cycles_runner.run(
162
+ simulations=simulations,
163
+ control_dict=control_dict,
164
+ operation_template='path/to/template.operation',
165
+ operation_dict=operation_dict,
166
+ summary='summary.csv',
167
+ options='-s',
168
+ )
169
+ ```
170
+
171
+ The `-s` option enables spin-up for the simulations. The results will be consolidated into `summary/summary.csv`.
65
172
  """
66
173
  if isinstance(simulations, pd.DataFrame):
67
174
  simulations = simulations.to_dict(orient='records')
@@ -74,13 +181,22 @@ class CyclesRunner:
74
181
  f"operation_dict={'None' if operation_dict is None else '...'}"
75
182
  )
76
183
 
77
- if 's' in options and self.rotation_builder:
78
- raise ValueError('Spin-up cannot be used with rotation builder.')
79
-
80
184
  operation_template = Path(operation_template) if operation_template is not None else None
185
+ if user_comment:
186
+ user_comment = f'# {user_comment.lstrip("# ").rstrip()}\n'
81
187
  comment = user_comment + _generate_comment(self.executable, options)
82
188
  first_run = True
83
189
 
190
+ if summary is None:
191
+ summary = {'harvest': 'summary.csv'}
192
+ elif isinstance(summary, str):
193
+ summary = {'harvest': summary}
194
+ assert isinstance(summary, dict)
195
+
196
+ for key in summary.keys():
197
+ if key == 'harvest': continue
198
+ control_dict[OUTPUT_CONTROL_FLAGS[key]] = 1
199
+
84
200
  SUMMARY_DIR.mkdir(exist_ok=True)
85
201
 
86
202
  for s in simulations:
@@ -104,7 +220,9 @@ class CyclesRunner:
104
220
  self._remove_inputs(cxt)
105
221
  if rm_output:
106
222
  shutil.rmtree(OUTPUT_DIR / cxt.name, ignore_errors=True)
107
- if rm_steady_state_soil:
223
+ if rm_steady_state_soil and 's' in options:
224
+ # Steady-state soil should only be removed if generated during this run (i.e., spin-up was requested).
225
+ # If using an existing steady-state soil file, it should not be removed.
108
226
  (INPUT_DIR / f'{cxt.name}_ss.soil').unlink(missing_ok=True)
109
227
 
110
228
 
@@ -135,15 +253,16 @@ class CyclesRunner:
135
253
  cxt.operation_fn.unlink(missing_ok=True)
136
254
 
137
255
 
138
- def _write_summary(self, cycles: Cycles, summary: str, *, header: bool, comment: str) -> None:
139
- cycles.read_output('harvest')
140
- cycles.output['harvest'].data.insert(0, 'simulation', cycles.simulation)
256
+ def _write_summary(self, cycles: Cycles, summary: dict, *, header: bool, comment: str) -> None:
257
+ cycles.read_output(summary.keys())
258
+ for key, fn in summary.items():
259
+ cycles.output[key].data.insert(0, 'simulation', cycles.simulation)
141
260
 
142
- mode = 'w' if header else 'a'
143
- with open(SUMMARY_DIR / summary, mode) as f:
144
- if header:
145
- f.write(comment)
146
- cycles.output['harvest'].data.to_csv(f, header=header, index=False)
261
+ mode = 'w' if header else 'a'
262
+ with open(SUMMARY_DIR / fn, mode) as f:
263
+ if header:
264
+ f.write(comment)
265
+ cycles.output[key].data.to_csv(f, header=header, index=False)
147
266
 
148
267
 
149
268
  def _render_template(template_fn: Path, dest_fn: Path, substitutions: dict) -> None:
@@ -3,6 +3,8 @@ from .control_file import read_control_file
3
3
  from .control_file import ControlConfig
4
4
  from .nudge_file import generate_nudge_file
5
5
  from .operation_file import read_operation_file
6
+ from .operation_file import format_operation
7
+ from .operation_file import generate_operation_file
6
8
  from .operation_file import Operation, Planting, Tillage, Harvest, Kill, FixedFertilization, FixedIrrigation, AutoIrrigation
7
9
  from .output_file import read_output
8
10
  from .soil_file import MAPPABLE_PARAMETERS
@@ -16,12 +18,14 @@ from .plot_tools import plot_operations
16
18
  from .plot_tools import plot_map
17
19
  from .plot_tools import plot_satellite_map
18
20
  from ._base_file import resolve_dict_values
21
+ from ._base_file import read_geospatial_file
19
22
 
20
23
  __all__ = [
21
24
  "generate_control_file",
22
25
  "read_control_file",
23
26
  "generate_nudge_file",
24
27
  "read_operation_file",
28
+ "generate_operation_file",
25
29
  "read_output",
26
30
  "generate_soil_file",
27
31
  "read_soil_file",
@@ -1,4 +1,7 @@
1
1
  from __future__ import annotations
2
+ import fiona
3
+ import geopandas as gpd
4
+ import pandas as pd
2
5
  import types
3
6
  from dataclasses import fields
4
7
  from pathlib import Path
@@ -7,7 +10,11 @@ from typing import Union, Any
7
10
  def _format_block(label: str, block) -> str:
8
11
  lines = [f'## {label.replace("_", " ").upper()} ##']
9
12
  for f in fields(block):
10
- lines.append('%-27s\t%s' % (f.name.upper(), getattr(block, f.name)))
13
+ val = getattr(block, f.name)
14
+ if isinstance(val, float) and val == -999.0:
15
+ val = '-999'
16
+ description = f.metadata.get('description', '')
17
+ lines.append(f'{f.name.upper():<28}{val:<8}# {description}' if description else f'{f.name.upper():<28}{val}')
11
18
  lines.append('')
12
19
 
13
20
  return '\n'.join(lines)
@@ -45,3 +52,24 @@ def unwrap_optional(t) -> type:
45
52
  if origin is Union or origin is types.UnionType or isinstance(t, types.UnionType):
46
53
  return next(arg for arg in t.__args__ if arg is not type(None))
47
54
  return t
55
+
56
+
57
+ def read_geospatial_file(file_path: str | Path) -> gpd.GeoDataFrame:
58
+ file_path = Path(file_path)
59
+ ext = file_path.suffix.lstrip('.').lower()
60
+ match ext:
61
+ case 'shp':
62
+ return gpd.read_file(file_path)
63
+ case 'kml':
64
+ return _read_kml(file_path)
65
+ case _:
66
+ raise ValueError(f"Unsupported boundary format: '.{ext}'")
67
+
68
+
69
+ def _read_kml(file_path: Path) -> gpd.GeoDataFrame:
70
+ return gpd.GeoDataFrame(
71
+ pd.concat(
72
+ [gpd.read_file(file_path, driver='KML', layer=layer) for layer in fiona.listlayers(file_path)],
73
+ ignore_index=True,
74
+ )
75
+ )
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
  import warnings
3
- from dataclasses import dataclass, fields
3
+ from dataclasses import dataclass, field, fields
4
4
  from pathlib import Path
5
5
  from typing import Any, get_type_hints
6
6
  from ._base_file import write_file, resolve_dict_values, extract, parse_value, unwrap_optional
@@ -22,10 +22,10 @@ class InputFiles:
22
22
  @dataclass(kw_only=True)
23
23
  class SimulationOptions:
24
24
  soil_layers: int
25
- co2_level: float = -999
25
+ co2_level: float = field(default=-999, metadata={'description': 'atmospheric CO2 concentration (ppm). Use co2.txt file if set to -999'})
26
26
  use_reinitialization: int = 0
27
27
  adjusted_yields: int = 0
28
- hydrology_option: int = 1
28
+ hydrology_option: int = field(default=1, metadata={'description': "1: gravity driven, 2: Richards' equation with Crank-Nicholson, 3: Richards' equation with CVode"})
29
29
  automatic_nitrogen: int = 0
30
30
  automatic_phosphorus: int = 0
31
31
  automatic_sulfur: int = 0
@@ -65,23 +65,23 @@ def _build_control_config(control_dict: dict, simulation_dict: dict[str, Any] |
65
65
  )
66
66
 
67
67
 
68
- def _get_soil_layers(fn: Path) -> int:
68
+ def _get_soil_layers(file_path: Path) -> int:
69
69
  NUM_HEADER_LINES = 2
70
70
  try:
71
- lines = [line for line in fn.read_text().splitlines() if line.strip() and not line.strip().startswith('#')]
71
+ lines = [line for line in file_path.read_text().splitlines() if line.strip() and not line.strip().startswith('#')]
72
72
  return len(lines) - NUM_HEADER_LINES - 1
73
73
  except FileNotFoundError:
74
- warnings.warn(f"Soil file not found: {fn}")
74
+ warnings.warn(f"Soil file not found: {file_path}")
75
75
  return -999
76
76
 
77
77
 
78
- def generate_control_file(fn: str | Path, user_dict: dict, *, simulation_dict: dict[str, Any] | None=None) -> ControlConfig:
78
+ def generate_control_file(file_path: str | Path, user_dict: dict[str, Any], *, simulation_dict: dict[str, Any] | None=None) -> ControlConfig:
79
79
  """Generate and write a Cycles control file.
80
80
 
81
- Provide either direct values or callables that accept a simulation row and return a value in `user_dict`. The
82
- parameter names should be in lowercase and correspond to the fields in Cycles simulation control files. If a field
83
- is not provided, it will be filled with a default value. If a field's value is a callable, it will be called with
84
- the `simulation_dict` to resolve its value.
81
+ Provide either direct values or callables that accept a simulation configuration and return a value in `user_dict`.
82
+ The parameter names should be in lowercase and correspond to the fields in Cycles simulation control files. If a
83
+ field is not provided, it will be filled with a default value. If a field's value is a callable, it will be called
84
+ with the `simulation_dict` to resolve its value.
85
85
 
86
86
  The following fields are required in `user_dict`:
87
87
 
@@ -108,30 +108,30 @@ def generate_control_file(fn: str | Path, user_dict: dict, *, simulation_dict: d
108
108
  All output control fields default to `0`.
109
109
 
110
110
  Args:
111
- fn: Destination control file path.
111
+ file_path: Destination control file path.
112
112
  user_dict: Values or callables for control fields.
113
113
  simulation_dict: Optional simulation row for callable resolution.
114
114
 
115
115
  Returns:
116
116
  The generated control configuration.
117
117
  """
118
- fn = Path(fn)
119
- config = _build_control_config(user_dict, simulation_dict, fn.parent)
120
- write_file(fn, config)
118
+ file_path = Path(file_path)
119
+ config = _build_control_config(user_dict, simulation_dict, file_path.parent)
120
+ write_file(file_path, config)
121
121
 
122
122
  return config
123
123
 
124
124
 
125
- def read_control_file(control: str | Path) -> ControlConfig:
126
- """Parse a Cycles control file into a ControlConfig instance.
125
+ def read_control_file(file_path: str | Path) -> ControlConfig:
126
+ """Parse a Cycles control file into a `ControlConfig` dataclass instance.
127
127
 
128
128
  Args:
129
- control: Path to a Cycles control file path.
129
+ file_path: Path to a Cycles control file path.
130
130
 
131
131
  Returns:
132
132
  Control configuration.
133
133
  """
134
- with open(Path(control)) as f:
134
+ with open(Path(file_path)) as f:
135
135
  lines = f.read().splitlines()
136
136
  lines = iter([line for line in lines if (not line.strip().startswith('#')) and line.strip()])
137
137
 
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from pathlib import Path
4
+ from typing import Any
5
+ from ._base_file import write_file, resolve_dict_values, extract
6
+
7
+ @dataclass(kw_only=True)
8
+ class CalibrationMultipliers:
9
+ soc_decomp_rate: float = field(default=1.0, metadata={'description': 'soil organic carbon decomposition rate'})
10
+ residue_decomp_rate: float = field(default=1.0, metadata={'description': 'residue decomposition rate'})
11
+ root_decomp_rate: float = field(default=1.0, metadata={'description': 'root decomposition rate'})
12
+ rhizo_decomp_rate: float = field(default=1.0, metadata={'description': 'rhizodeposit decomposition rate'})
13
+ manure_decomp_rate: float = field(default=1.0, metadata={'description': 'manure decomposition rate'})
14
+ ferment_decomp_rate: float = field(default=1.0, metadata={'description': 'ferment decomposition rate'})
15
+ microb_decomp_rate: float = field(default=1.0, metadata={'description': 'microbe decomposition rate'})
16
+ soc_humif_power: float = field(default=1.0, metadata={'description': 'soil organic carbon humification exponent'})
17
+ nitrif_rate: float = field(default=1.0, metadata={'description': 'nitrification rate'})
18
+ pot_denitrif_rate: float = field(default=1.0, metadata={'description': 'potential denitrification rate'})
19
+ denitrif_half_rate: float = field(default=1.0, metadata={'description': 'half saturation constant for denitrification'})
20
+ decomp_half_resp: float = field(default=1.0, metadata={'description': 'decomposition half response to saturation (default 0.22)'})
21
+ decomp_resp_power: float = field(default=1.0, metadata={'description': 'decomposition exponential response to saturation (default 3.0)'})
22
+ root_progression: float = field(default=1.0, metadata={'description': 'rooting depth progression rate'})
23
+ radiation_use_efficiency: float = field(default=1.0, metadata={'description': 'crop radiation use efficiency'})
24
+
25
+ @dataclass(kw_only=True)
26
+ class ParameterValues:
27
+ kd_no3: float = field(default=0.0, metadata={'description': 'adsorption coefficient for NO3 (default 0.0 cm3/g)'})
28
+ kd_nh4: float = field(default=5.6, metadata={'description': 'adsorption coefficient for NH4 (default 5.6 cm3/g)'})
29
+
30
+ @dataclass
31
+ class NudgeConfig:
32
+ calibration_multipliers: CalibrationMultipliers
33
+ parameter_values: ParameterValues
34
+
35
+
36
+ def _build_nudge_config(user_dict: dict[str, Any], calibration_dict: dict[str, Any] | None) -> NudgeConfig:
37
+ resolved = resolve_dict_values(user_dict, calibration_dict)
38
+
39
+ return NudgeConfig(
40
+ calibration_multipliers=CalibrationMultipliers(**extract(CalibrationMultipliers, resolved)),
41
+ parameter_values=ParameterValues(**extract(ParameterValues, resolved)),
42
+ )
43
+
44
+
45
+ def generate_nudge_file(file_path: str | Path, user_dict: dict[str, Any], *, calibration_dict: dict[str, Any] | None=None) -> None:
46
+ """Write a Cycles nudge file from user-provided values.
47
+
48
+ Provide either direct values or callables that accept a calibration configuration and return a value in `user_dict`.
49
+ The parameter names should be in lowercase and correspond to the fields in Cycles nudge (calibration) files. If a
50
+ field is not provided, it will be filled with a default value. If a field's value is a callable, it will be called
51
+ with the `calibration_dict` to resolve its value.
52
+
53
+ The default values for all calibration multipliers are `1.0`, and the default values for `kd_no3` and `kd_nh4` are
54
+ `0.0` and `5.6`, respectively.
55
+
56
+ Args:
57
+ file_path: Destination nudge file path.
58
+ user_dict: Values or callables for nudge parameters.
59
+ calibration_dict: Optional simulation row for callable resolution.
60
+ """
61
+ file_path = Path(file_path)
62
+ config = _build_nudge_config(user_dict, calibration_dict)
63
+ write_file(file_path, config)