Cycles-utils 3.1.0__tar.gz → 4.0.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.
- {cycles_utils-3.1.0 → cycles_utils-4.0.0}/Cycles_utils.egg-info/PKG-INFO +2 -2
- {cycles_utils-3.1.0 → cycles_utils-4.0.0}/Cycles_utils.egg-info/SOURCES.txt +13 -3
- {cycles_utils-3.1.0 → cycles_utils-4.0.0}/LICENSE +1 -1
- {cycles_utils-3.1.0 → cycles_utils-4.0.0}/PKG-INFO +2 -2
- {cycles_utils-3.1.0 → cycles_utils-4.0.0}/README.md +1 -1
- cycles_utils-4.0.0/cycles/__init__.py +17 -0
- cycles_utils-4.0.0/cycles/cycles.py +173 -0
- cycles_utils-4.0.0/cycles/cycles_runner.py +170 -0
- cycles_utils-4.0.0/cycles/cycles_tools/__init__.py +34 -0
- cycles_utils-4.0.0/cycles/cycles_tools/_base_file.py +47 -0
- cycles_utils-4.0.0/cycles/cycles_tools/control_file.py +117 -0
- cycles_utils-4.0.0/cycles/cycles_tools/nudge_file.py +55 -0
- cycles_utils-4.0.0/cycles/cycles_tools/operation_file.py +167 -0
- cycles_utils-4.0.0/cycles/cycles_tools/output_file.py +37 -0
- cycles_utils-4.0.0/cycles/cycles_tools/plot_tools.py +272 -0
- cycles_utils-4.0.0/cycles/cycles_tools/reinit_file.py +98 -0
- cycles_utils-4.0.0/cycles/cycles_tools/soil_file.py +315 -0
- cycles_utils-4.0.0/cycles/cycles_tools/weather_file.py +63 -0
- {cycles_utils-3.1.0 → cycles_utils-4.0.0}/cycles/gadm/__init__.py +11 -0
- cycles_utils-4.0.0/cycles/gadm/gadm.py +193 -0
- cycles_utils-4.0.0/cycles/rotation_builder.py +507 -0
- {cycles_utils-3.1.0 → cycles_utils-4.0.0}/cycles/soilgrids/__init__.py +5 -0
- cycles_utils-4.0.0/cycles/soilgrids/soilgrids.py +255 -0
- cycles_utils-4.0.0/cycles/ssurgo/__init__.py +8 -0
- cycles_utils-4.0.0/cycles/ssurgo/ssurgo.py +400 -0
- cycles_utils-4.0.0/cycles/weather/__init__.py +10 -0
- cycles_utils-4.0.0/cycles/weather/weather.py +551 -0
- {cycles_utils-3.1.0 → cycles_utils-4.0.0}/setup.py +1 -1
- cycles_utils-3.1.0/cycles/__init__.py +0 -12
- cycles_utils-3.1.0/cycles/cycles.py +0 -99
- cycles_utils-3.1.0/cycles/cycles_runner.py +0 -110
- cycles_utils-3.1.0/cycles/cycles_tools.py +0 -575
- cycles_utils-3.1.0/cycles/gadm/gadm.py +0 -100
- cycles_utils-3.1.0/cycles/gssurgo/__init__.py +0 -4
- cycles_utils-3.1.0/cycles/gssurgo/gssurgo.py +0 -283
- cycles_utils-3.1.0/cycles/soilgrids/soilgrids.py +0 -192
- cycles_utils-3.1.0/cycles/weather/__init__.py +0 -6
- cycles_utils-3.1.0/cycles/weather/weather.py +0 -571
- {cycles_utils-3.1.0 → cycles_utils-4.0.0}/Cycles_utils.egg-info/dependency_links.txt +0 -0
- {cycles_utils-3.1.0 → cycles_utils-4.0.0}/Cycles_utils.egg-info/requires.txt +0 -0
- {cycles_utils-3.1.0 → cycles_utils-4.0.0}/Cycles_utils.egg-info/top_level.txt +0 -0
- {cycles_utils-3.1.0 → cycles_utils-4.0.0}/MANIFEST.in +0 -0
- {cycles_utils-3.1.0 → cycles_utils-4.0.0}/cycles/data/GLDASp5_elevation_025d.nc4 +0 -0
- {cycles_utils-3.1.0 → cycles_utils-4.0.0}/cycles/data/GLDASp5_landmask_025d.nc4 +0 -0
- {cycles_utils-3.1.0 → cycles_utils-4.0.0}/cycles/data/NLDAS_elevation.nc4 +0 -0
- {cycles_utils-3.1.0 → cycles_utils-4.0.0}/cycles/data/NLDAS_masks-veg-soil.nc4 +0 -0
- {cycles_utils-3.1.0 → cycles_utils-4.0.0}/cycles/data/fips_gid_conversion.csv +0 -0
- {cycles_utils-3.1.0 → cycles_utils-4.0.0}/cycles/data/gridMET_elevation_mask.nc +0 -0
- {cycles_utils-3.1.0 → cycles_utils-4.0.0}/cycles/data/metdata_elevationdata.nc +0 -0
- {cycles_utils-3.1.0 → cycles_utils-4.0.0}/cycles/data/us_states.csv +0 -0
- {cycles_utils-3.1.0 → cycles_utils-4.0.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: Cycles-utils
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.0.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
|
|
@@ -53,4 +53,4 @@ pip install Cycles-utils
|
|
|
53
53
|
|
|
54
54
|
# API reference
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
[psumodeling.github.io/Cycles-utils](https://psumodeling.github.io/Cycles-utils)
|
|
@@ -10,7 +10,17 @@ Cycles_utils.egg-info/top_level.txt
|
|
|
10
10
|
cycles/__init__.py
|
|
11
11
|
cycles/cycles.py
|
|
12
12
|
cycles/cycles_runner.py
|
|
13
|
-
cycles/
|
|
13
|
+
cycles/rotation_builder.py
|
|
14
|
+
cycles/cycles_tools/__init__.py
|
|
15
|
+
cycles/cycles_tools/_base_file.py
|
|
16
|
+
cycles/cycles_tools/control_file.py
|
|
17
|
+
cycles/cycles_tools/nudge_file.py
|
|
18
|
+
cycles/cycles_tools/operation_file.py
|
|
19
|
+
cycles/cycles_tools/output_file.py
|
|
20
|
+
cycles/cycles_tools/plot_tools.py
|
|
21
|
+
cycles/cycles_tools/reinit_file.py
|
|
22
|
+
cycles/cycles_tools/soil_file.py
|
|
23
|
+
cycles/cycles_tools/weather_file.py
|
|
14
24
|
cycles/data/GLDASp5_elevation_025d.nc4
|
|
15
25
|
cycles/data/GLDASp5_landmask_025d.nc4
|
|
16
26
|
cycles/data/NLDAS_elevation.nc4
|
|
@@ -21,9 +31,9 @@ cycles/data/metdata_elevationdata.nc
|
|
|
21
31
|
cycles/data/us_states.csv
|
|
22
32
|
cycles/gadm/__init__.py
|
|
23
33
|
cycles/gadm/gadm.py
|
|
24
|
-
cycles/gssurgo/__init__.py
|
|
25
|
-
cycles/gssurgo/gssurgo.py
|
|
26
34
|
cycles/soilgrids/__init__.py
|
|
27
35
|
cycles/soilgrids/soilgrids.py
|
|
36
|
+
cycles/ssurgo/__init__.py
|
|
37
|
+
cycles/ssurgo/ssurgo.py
|
|
28
38
|
cycles/weather/__init__.py
|
|
29
39
|
cycles/weather/weather.py
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c)
|
|
3
|
+
Copyright (c) 2026 Penn State Earth System Modeling Team
|
|
4
4
|
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
6
|
of this software and associated documentation files (the "Software"), to deal
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: Cycles-utils
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.0.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
|
|
@@ -53,4 +53,4 @@ pip install Cycles-utils
|
|
|
53
53
|
|
|
54
54
|
# API reference
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
[psumodeling.github.io/Cycles-utils](https://psumodeling.github.io/Cycles-utils)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .cycles import Cycles
|
|
2
|
+
from .cycles_tools import generate_control_file
|
|
3
|
+
from .cycles_tools import generate_nudge_file
|
|
4
|
+
from .cycles_tools import generate_soil_file
|
|
5
|
+
from .cycles_tools import generate_reinit_file
|
|
6
|
+
from .cycles_tools import read_control_file
|
|
7
|
+
from .cycles_tools import read_soil_file
|
|
8
|
+
from .cycles_tools import read_weather_file
|
|
9
|
+
from .cycles_tools import read_output
|
|
10
|
+
from .cycles_tools import read_operation_file
|
|
11
|
+
from .cycles_tools import SoilLayer
|
|
12
|
+
from .cycles_tools import plot_yield
|
|
13
|
+
from .cycles_tools import plot_operations
|
|
14
|
+
from .cycles_tools import plot_map
|
|
15
|
+
from .cycles_tools import Operation, Planting, Tillage, Harvest, Kill, FixedFertilization, FixedIrrigation, AutoIrrigation
|
|
16
|
+
from .cycles_runner import CyclesRunner
|
|
17
|
+
from .rotation_builder import CyclesRotationBuilder, Crop, CropGroup
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import numpy as np
|
|
3
|
+
import os
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections.abc import Collection
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from matplotlib.axes import Axes
|
|
11
|
+
from .cycles_tools import SoilLayer, ControlConfig
|
|
12
|
+
from .cycles_tools import read_control_file as _read_control_file
|
|
13
|
+
from .cycles_tools import read_soil_file as _read_soil_file
|
|
14
|
+
from .cycles_tools import read_weather_file as _read_weather_file
|
|
15
|
+
from .cycles_tools import read_output as _read_output
|
|
16
|
+
from .cycles_tools import read_operation_file as _read_operation_file
|
|
17
|
+
from .cycles_tools import generate_reinit_file as _generate_reinit_file
|
|
18
|
+
from .cycles_tools import plot_yield as _plot_yield
|
|
19
|
+
from .cycles_tools import plot_operations as _plot_operations
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Output:
|
|
23
|
+
data: pd.DataFrame
|
|
24
|
+
units: dict[str, str]
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Cycles:
|
|
28
|
+
"""Interface for executing one Cycles simulation and reading its files.
|
|
29
|
+
|
|
30
|
+
Provides methods to run simulations, read outputs, and inspect soil/weather/operation
|
|
31
|
+
configurations. Automatically loads the control file upon initialization.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
path: Path to the simulation directory (containing input/ and output/ subdirs).
|
|
35
|
+
simulation: Name of the simulation (base name of control file without extension).
|
|
36
|
+
output: Dictionary mapping output table names to Output objects with data and units.
|
|
37
|
+
control: Parsed control file configuration from the input directory.
|
|
38
|
+
operations: List of parsed operation records from the operation file.
|
|
39
|
+
soil_profile: List of SoilLayer objects describing the soil profile.
|
|
40
|
+
curve_number: Runoff curve number for hydrologic calculations.
|
|
41
|
+
slope: Land slope used in erosion and runoff models.
|
|
42
|
+
weather: DataFrame of weather forcing data (temperature, precipitation, etc.).
|
|
43
|
+
executable: Absolute path to the Cycles executable binary.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
path: Path | str
|
|
47
|
+
simulation: str
|
|
48
|
+
output: dict[str, Output] = field(default_factory=dict[str, Output])
|
|
49
|
+
control: ControlConfig | None = None
|
|
50
|
+
operations: list | None = None
|
|
51
|
+
soil_profile: list[SoilLayer] | None = None
|
|
52
|
+
curve_number: int | None = None
|
|
53
|
+
slope: float | None = None
|
|
54
|
+
weather: pd.DataFrame | None = None
|
|
55
|
+
executable: Path | str | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def __post_init__(self):
|
|
59
|
+
self.path = Path(self.path)
|
|
60
|
+
assert isinstance(self.path, Path)
|
|
61
|
+
self.control = _read_control_file(self.path / 'input' / f'{self.simulation}.ctrl')
|
|
62
|
+
if self.executable is not None:
|
|
63
|
+
self.executable = str(Path(self.executable).resolve())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def run(self, options: str, silence: bool=False) -> tuple[int, str]:
|
|
67
|
+
"""Run the Cycles executable for this simulation.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
options: Command-line options passed to Cycles.
|
|
71
|
+
silence: If True, suppress stdout and stderr printing.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
A tuple with process return code and stdout text.
|
|
75
|
+
"""
|
|
76
|
+
cmd = [self.executable, *(options.split() if options else []), self.simulation]
|
|
77
|
+
result = subprocess.run(
|
|
78
|
+
cmd,
|
|
79
|
+
shell=os.name == 'nt',
|
|
80
|
+
capture_output=True,
|
|
81
|
+
text=True,
|
|
82
|
+
)
|
|
83
|
+
if not silence:
|
|
84
|
+
print(result.stdout)
|
|
85
|
+
if result.stderr:
|
|
86
|
+
print(result.stderr)
|
|
87
|
+
return result.returncode, result.stdout
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def read_output(self, output_types: Collection) -> None:
|
|
91
|
+
"""Read one or more output tables into memory.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
output_types: Output table name or collection of names.
|
|
95
|
+
"""
|
|
96
|
+
assert isinstance(self.path, Path)
|
|
97
|
+
if isinstance(output_types, str):
|
|
98
|
+
output_types = output_types,
|
|
99
|
+
|
|
100
|
+
for output_type in output_types:
|
|
101
|
+
df, units = _read_output(self.path / 'output' / self.simulation, output_type)
|
|
102
|
+
self.output[output_type] = Output(data=df, units=units)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def read_operation_file(self) -> None:
|
|
106
|
+
"""Load operation records defined in the control file."""
|
|
107
|
+
assert isinstance(self.path, Path)
|
|
108
|
+
assert self.control is not None
|
|
109
|
+
self.operations = _read_operation_file(self.path / 'input' / self.control.input_files.operation_file)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def read_soil_file(self) -> None:
|
|
113
|
+
"""Load soil profile layers and metadata from the configured soil file."""
|
|
114
|
+
assert isinstance(self.path, Path)
|
|
115
|
+
assert self.control is not None
|
|
116
|
+
self.soil_profile, meta = _read_soil_file(self.path / 'input' / self.control.input_files.soil_file)
|
|
117
|
+
self.curve_number = meta['curve_number']
|
|
118
|
+
self.slope = meta['slope']
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def read_weather_file(self, *, start_year: int=-9999, end_year: int=9999, subdaily: bool=False) -> None:
|
|
122
|
+
"""Read weather forcing data for the configured weather file.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
start_year: Inclusive first year to keep.
|
|
126
|
+
end_year: Inclusive last year to keep.
|
|
127
|
+
subdaily: If True, parse hourly format instead of daily.
|
|
128
|
+
"""
|
|
129
|
+
assert isinstance(self.path, Path)
|
|
130
|
+
assert self.control is not None
|
|
131
|
+
self.weather = _read_weather_file(self.path / 'input' / self.control.input_files.weather_file, start_year=start_year, end_year=end_year, subdaily=subdaily)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def generate_reinit_file(self, doy: int, *, reinit: str | None=None) -> None:
|
|
135
|
+
"""Generate a reinitialization file from model output.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
doy: Day-of-year to extract from reinit output.
|
|
139
|
+
reinit: Optional output stem for the reinit file.
|
|
140
|
+
"""
|
|
141
|
+
assert isinstance(self.path, Path)
|
|
142
|
+
_generate_reinit_file(self.path / 'input' / f'{self.simulation if reinit is None else reinit}.reinit', self.path / 'output' / self.simulation, doy)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def plot_yield(self, *, ax: Axes | None=None, fontsize: int | None=None) -> Axes:
|
|
146
|
+
"""Plot grain and forage yields from harvest output."""
|
|
147
|
+
if 'harvest' not in self.output:
|
|
148
|
+
self.read_output('harvest')
|
|
149
|
+
|
|
150
|
+
return _plot_yield(self.output['harvest'].data, ax=ax, fontsize=fontsize)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def plot_operations(self, rotation_size: int | None=None, *, axs: Axes | np.ndarray | None=None, fontsize: int | None=None):
|
|
154
|
+
"""Plot operation timelines grouped by rotation year.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
rotation_size: Number of years in the plotted rotation.
|
|
158
|
+
axs: Optional axes object(s) to draw on.
|
|
159
|
+
fontsize: Global matplotlib font size override.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
The axes used for plotting.
|
|
163
|
+
"""
|
|
164
|
+
if self.operations is None:
|
|
165
|
+
self.read_operation_file()
|
|
166
|
+
|
|
167
|
+
assert self.control is not None
|
|
168
|
+
assert self.operations is not None
|
|
169
|
+
|
|
170
|
+
if rotation_size is None:
|
|
171
|
+
rotation_size = self.control.simulation_years.rotation_size
|
|
172
|
+
|
|
173
|
+
return _plot_operations(self.operations, rotation_size, axs=axs, fontsize=fontsize)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import os
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from string import Template
|
|
9
|
+
from typing import Any
|
|
10
|
+
from .cycles import Cycles
|
|
11
|
+
from .cycles_tools import generate_control_file, generate_nudge_file, resolve_dict_values
|
|
12
|
+
|
|
13
|
+
SimulationConfig = list[dict] | pd.DataFrame
|
|
14
|
+
|
|
15
|
+
INPUT_DIR: Path = Path('input')
|
|
16
|
+
OUTPUT_DIR: Path = Path('output')
|
|
17
|
+
SUMMARY_DIR: Path = Path('summary')
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SimulationContext:
|
|
21
|
+
name: str
|
|
22
|
+
control_dict: dict
|
|
23
|
+
operation_dict: dict | None
|
|
24
|
+
calibration_dict: dict | None
|
|
25
|
+
operation_fn: Path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class CyclesRunner:
|
|
30
|
+
"""Run one or many Cycles simulations with templated inputs.
|
|
31
|
+
|
|
32
|
+
Manages batch execution of Cycles simulations by generating control files,
|
|
33
|
+
operation files, and nudge files from templates and parameter dictionaries.
|
|
34
|
+
Consolidates results into a summary CSV file.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
executable: Absolute path to the Cycles executable binary.
|
|
38
|
+
rotation_builder: If True, disables spin-up option and enables rotation features.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
executable: str
|
|
42
|
+
rotation_builder: bool = False
|
|
43
|
+
|
|
44
|
+
def __post_init__(self):
|
|
45
|
+
self.executable = str(Path(self.executable).resolve())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run(self, simulations: SimulationConfig, control_dict: dict[str, Any], *,
|
|
49
|
+
summary: str='summary.csv', operation_template: Path | str | None=None, operation_dict: dict[str, Any] | None=None, calibration_dict: dict[str, Any] | None=None,
|
|
50
|
+
options: str='', rm_input: bool=False, rm_output: bool=False, rm_steady_state_soil: bool=True, silence: bool=True, user_comment: str='') -> None:
|
|
51
|
+
"""Execute a batch of simulations and write a consolidated summary.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
simulations: Simulation rows as list of dicts or a DataFrame.
|
|
55
|
+
control_dict: Control-file values or callables evaluated per row.
|
|
56
|
+
summary: Summary CSV name written under summary directory.
|
|
57
|
+
operation_template: Template file for generated operation files.
|
|
58
|
+
operation_dict: Substitutions used with operation template.
|
|
59
|
+
calibration_dict: Nudge-file values or callables per row.
|
|
60
|
+
options: Cycles command options.
|
|
61
|
+
rm_input: Remove generated input files after each run.
|
|
62
|
+
rm_output: Remove run output directory after each run.
|
|
63
|
+
rm_steady_state_soil: Remove generated steady-state soil file.
|
|
64
|
+
silence: If True, suppress simulation screen output.
|
|
65
|
+
user_comment: Optional text prefixed to summary header comments.
|
|
66
|
+
"""
|
|
67
|
+
if isinstance(simulations, pd.DataFrame):
|
|
68
|
+
simulations = simulations.to_dict(orient='records')
|
|
69
|
+
assert isinstance(simulations, list)
|
|
70
|
+
|
|
71
|
+
if (operation_template is None) != (operation_dict is None):
|
|
72
|
+
raise ValueError(
|
|
73
|
+
"operation_template and operation_dict must be provided together or not at all. "
|
|
74
|
+
f"Got operation_template={'None' if operation_template is None else repr(operation_template)}, "
|
|
75
|
+
f"operation_dict={'None' if operation_dict is None else '...'}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if 's' in options and self.rotation_builder:
|
|
79
|
+
raise ValueError('Spin-up cannot be used with rotation builder.')
|
|
80
|
+
|
|
81
|
+
operation_template = Path(operation_template) if operation_template is not None else None
|
|
82
|
+
comment = user_comment + _generate_comment(self.executable, options)
|
|
83
|
+
first_run = True
|
|
84
|
+
|
|
85
|
+
SUMMARY_DIR.mkdir(exist_ok=True)
|
|
86
|
+
|
|
87
|
+
for s in simulations:
|
|
88
|
+
cxt: SimulationContext = self._resolve(s, control_dict, operation_dict, calibration_dict)
|
|
89
|
+
print(f'{cxt.name} - ', end='')
|
|
90
|
+
|
|
91
|
+
self._write_inputs(cxt, operation_template)
|
|
92
|
+
|
|
93
|
+
cycles = Cycles(path='.', simulation=cxt.name, executable=self.executable)
|
|
94
|
+
|
|
95
|
+
code, _ = cycles.run(options=options, silence=silence)
|
|
96
|
+
|
|
97
|
+
if code == 0:
|
|
98
|
+
self._write_summary(cycles, summary, header=first_run, comment=comment)
|
|
99
|
+
first_run = False
|
|
100
|
+
print('Success')
|
|
101
|
+
elif code == 1:
|
|
102
|
+
print('Fail')
|
|
103
|
+
|
|
104
|
+
if rm_input:
|
|
105
|
+
self._remove_inputs(cxt)
|
|
106
|
+
if rm_output:
|
|
107
|
+
shutil.rmtree(OUTPUT_DIR / cxt.name, ignore_errors=True)
|
|
108
|
+
if rm_steady_state_soil:
|
|
109
|
+
(INPUT_DIR / f'{cxt.name}_ss.soil').unlink(missing_ok=True)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
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:
|
|
113
|
+
"""Call control_dict once per row and resolve all derived values."""
|
|
114
|
+
control = resolve_dict_values(control_dict, simulation)
|
|
115
|
+
return SimulationContext(
|
|
116
|
+
name=control['simulation_name'],
|
|
117
|
+
control_dict=control,
|
|
118
|
+
operation_dict=resolve_dict_values(operation_dict, simulation) if operation_dict is not None else None,
|
|
119
|
+
calibration_dict=resolve_dict_values(calibration_dict, simulation) if calibration_dict is not None else None,
|
|
120
|
+
operation_fn=INPUT_DIR / control['operation_file'],
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _write_inputs(self, cxt: SimulationContext, operation_template: Path | None) -> None:
|
|
125
|
+
if operation_template is not None:
|
|
126
|
+
assert cxt.operation_dict is not None
|
|
127
|
+
_render_template(operation_template, cxt.operation_fn, cxt.operation_dict)
|
|
128
|
+
if cxt.calibration_dict is not None:
|
|
129
|
+
generate_nudge_file(INPUT_DIR / f'{cxt.name}.nudge', cxt.calibration_dict)
|
|
130
|
+
generate_control_file(INPUT_DIR / f'{cxt.name}.ctrl', cxt.control_dict)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _remove_inputs(self, cxt: SimulationContext) -> None:
|
|
134
|
+
(INPUT_DIR / f'{cxt.name}.ctrl').unlink(missing_ok=True)
|
|
135
|
+
(INPUT_DIR / f'{cxt.name}.nudge').unlink(missing_ok=True)
|
|
136
|
+
if cxt.operation_dict is not None:
|
|
137
|
+
cxt.operation_fn.unlink(missing_ok=True)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _write_summary(self, cycles: Cycles, summary: str, *, header: bool, comment: str) -> None:
|
|
141
|
+
cycles.read_output('harvest')
|
|
142
|
+
cycles.output['harvest'].data.insert(0, 'simulation', cycles.simulation)
|
|
143
|
+
|
|
144
|
+
mode = 'w' if header else 'a'
|
|
145
|
+
with open(SUMMARY_DIR / summary, mode) as f:
|
|
146
|
+
if header:
|
|
147
|
+
f.write(comment)
|
|
148
|
+
cycles.output['harvest'].data.to_csv(f, header=header, index=False)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _render_template(template_fn: Path, dest_fn: Path, substitutions: dict) -> None:
|
|
152
|
+
dest_fn.write_text(Template(template_fn.read_text()).substitute(substitutions) + '\n')
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _generate_comment(executable: str, options: str) -> str:
|
|
156
|
+
result = subprocess.run(
|
|
157
|
+
[executable, '-V'],
|
|
158
|
+
shell=os.name == 'nt',
|
|
159
|
+
capture_output=True,
|
|
160
|
+
text=True,
|
|
161
|
+
)
|
|
162
|
+
version = ''.join(result.stdout.splitlines())
|
|
163
|
+
parts = [
|
|
164
|
+
f'# {version}',
|
|
165
|
+
'with spin-up' if 's' in options else 'without spin-up',
|
|
166
|
+
'with calibration' if 'n' in options else None,
|
|
167
|
+
'grain model turned on' if 'g' in options else None,
|
|
168
|
+
'dynamically reduced fertilization rates' if 'x' in options else None,
|
|
169
|
+
]
|
|
170
|
+
return ', '.join(p for p in parts if p) + '\n'
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from .control_file import generate_control_file
|
|
2
|
+
from .control_file import read_control_file
|
|
3
|
+
from .control_file import ControlConfig
|
|
4
|
+
from .nudge_file import generate_nudge_file
|
|
5
|
+
from .operation_file import read_operation_file
|
|
6
|
+
from .operation_file import Operation, Planting, Tillage, Harvest, Kill, FixedFertilization, FixedIrrigation, AutoIrrigation
|
|
7
|
+
from .output_file import read_output
|
|
8
|
+
from .soil_file import MAPPABLE_PARAMETERS
|
|
9
|
+
from .soil_file import SoilLayer
|
|
10
|
+
from .soil_file import generate_soil_file
|
|
11
|
+
from .soil_file import read_soil_file
|
|
12
|
+
from .soil_file import map_to_dataframe
|
|
13
|
+
from .soil_file import from_dataframe
|
|
14
|
+
from .weather_file import read_weather_file
|
|
15
|
+
from .reinit_file import generate_reinit_file
|
|
16
|
+
from .plot_tools import plot_yield
|
|
17
|
+
from .plot_tools import plot_operations
|
|
18
|
+
from .plot_tools import plot_map
|
|
19
|
+
from ._base_file import resolve_dict_values
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"generate_control_file",
|
|
23
|
+
"read_control_file",
|
|
24
|
+
"generate_nudge_file",
|
|
25
|
+
"read_operation_file",
|
|
26
|
+
"read_output",
|
|
27
|
+
"generate_soil_file",
|
|
28
|
+
"read_soil_file",
|
|
29
|
+
"read_weather_file",
|
|
30
|
+
"generate_reinit_file",
|
|
31
|
+
"plot_yield",
|
|
32
|
+
"plot_operations",
|
|
33
|
+
"plot_map",
|
|
34
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import types
|
|
3
|
+
from dataclasses import fields
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Union, Any
|
|
6
|
+
|
|
7
|
+
def _format_block(label: str, block) -> str:
|
|
8
|
+
lines = [f'## {label.replace("_", " ").upper()} ##']
|
|
9
|
+
for f in fields(block):
|
|
10
|
+
lines.append('%-27s\t%s' % (f.name.upper(), getattr(block, f.name)))
|
|
11
|
+
lines.append('')
|
|
12
|
+
|
|
13
|
+
return '\n'.join(lines)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def write_file(fn: Path, config) -> None:
|
|
17
|
+
content = '\n'.join(
|
|
18
|
+
_format_block(f.name, getattr(config, f.name))
|
|
19
|
+
for f in fields(config)
|
|
20
|
+
if getattr(config, f.name) is not None
|
|
21
|
+
)
|
|
22
|
+
fn.write_text(content)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def resolve_dict_values(user_dict: dict, simulation: dict[str, Any] | None) -> dict:
|
|
26
|
+
return {key: func(simulation) if callable(func) else func for key, func in user_dict.items()}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def extract(dc_class, resolved: dict) -> dict:
|
|
30
|
+
return {f.name: resolved[f.name] for f in fields(dc_class) if f.name in resolved}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_value(raw: str, name: str, hint: type) -> int | float | str:
|
|
34
|
+
hint = unwrap_optional(hint)
|
|
35
|
+
if raw.split()[0].lower() == name.lower():
|
|
36
|
+
if hint is int: return int(raw.split()[1])
|
|
37
|
+
if hint is float: return float(raw.split()[1])
|
|
38
|
+
return raw.split()[1]
|
|
39
|
+
else:
|
|
40
|
+
raise ValueError(f"Expected field name '{name}' not found in line: {raw}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def unwrap_optional(t) -> type:
|
|
44
|
+
origin = getattr(t, '__origin__', None)
|
|
45
|
+
if origin is Union or origin is types.UnionType:
|
|
46
|
+
return next(arg for arg in t.__args__ if arg is not type(None))
|
|
47
|
+
return t
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import warnings
|
|
3
|
+
from dataclasses import dataclass, fields
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, get_type_hints
|
|
6
|
+
from ._base_file import write_file, resolve_dict_values, extract, parse_value, unwrap_optional
|
|
7
|
+
|
|
8
|
+
@dataclass(kw_only=True)
|
|
9
|
+
class SimulationYears:
|
|
10
|
+
simulation_start_year: int
|
|
11
|
+
simulation_end_year: int
|
|
12
|
+
rotation_size: int
|
|
13
|
+
|
|
14
|
+
@dataclass(kw_only=True)
|
|
15
|
+
class InputFiles:
|
|
16
|
+
crop_file: str = 'GenericCrops.crop'
|
|
17
|
+
operation_file: str
|
|
18
|
+
soil_file: str
|
|
19
|
+
weather_file: str
|
|
20
|
+
reinit_file: str = 'N/A'
|
|
21
|
+
|
|
22
|
+
@dataclass(kw_only=True)
|
|
23
|
+
class SimulationOptions:
|
|
24
|
+
soil_layers: int
|
|
25
|
+
co2_level: float = -999
|
|
26
|
+
use_reinitialization: int = 0
|
|
27
|
+
adjusted_yields: int = 0
|
|
28
|
+
hydrology_option: int = 1
|
|
29
|
+
automatic_nitrogen: int = 0
|
|
30
|
+
automatic_phosphorus: int = 0
|
|
31
|
+
automatic_sulfur: int = 0
|
|
32
|
+
|
|
33
|
+
@dataclass(kw_only=True)
|
|
34
|
+
class OutputControl:
|
|
35
|
+
daily_weather_out: int = 0
|
|
36
|
+
daily_crop_out: int = 0
|
|
37
|
+
daily_residue_out: int = 0
|
|
38
|
+
daily_water_out: int = 0
|
|
39
|
+
daily_nitrogen_out: int = 0
|
|
40
|
+
daily_soil_carbon_out: int = 0
|
|
41
|
+
daily_soil_lyr_cn_out: int = 0
|
|
42
|
+
annual_soil_out: int = 0
|
|
43
|
+
annual_profile_out: int = 0
|
|
44
|
+
annual_nflux_out: int = 0
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class ControlConfig:
|
|
48
|
+
simulation_years: SimulationYears
|
|
49
|
+
input_files: InputFiles
|
|
50
|
+
simulation_options: SimulationOptions
|
|
51
|
+
output_control: OutputControl
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build_control_config(control_dict: dict, simulation_dict: dict[str, Any] | None, input_dir: Path) -> ControlConfig:
|
|
55
|
+
resolved = resolve_dict_values(control_dict, simulation_dict)
|
|
56
|
+
|
|
57
|
+
if 'soil_layers' not in resolved:
|
|
58
|
+
resolved['soil_layers'] = _get_soil_layers(input_dir / resolved['soil_file'])
|
|
59
|
+
|
|
60
|
+
return ControlConfig(
|
|
61
|
+
simulation_years=SimulationYears(**extract(SimulationYears, resolved)),
|
|
62
|
+
input_files=InputFiles(**extract(InputFiles, resolved)),
|
|
63
|
+
simulation_options=SimulationOptions(**extract(SimulationOptions, resolved)),
|
|
64
|
+
output_control=OutputControl(**extract(OutputControl, resolved)),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _get_soil_layers(fn: Path) -> int:
|
|
69
|
+
NUM_HEADER_LINES = 2
|
|
70
|
+
try:
|
|
71
|
+
lines = [line for line in fn.read_text().splitlines() if line.strip() and not line.strip().startswith('#')]
|
|
72
|
+
return len(lines) - NUM_HEADER_LINES - 1
|
|
73
|
+
except FileNotFoundError:
|
|
74
|
+
warnings.warn(f"Soil file not found: {fn}")
|
|
75
|
+
return -999
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def generate_control_file(fn: str | Path, user_dict: dict, *, simulation_dict: dict[str, Any] | None=None) -> ControlConfig:
|
|
79
|
+
"""Generate and write a Cycles control file.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
fn: Destination control file path.
|
|
83
|
+
user_dict: Values or callables for control fields.
|
|
84
|
+
simulation_dict: Optional simulation row for callable resolution.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The generated control configuration.
|
|
88
|
+
"""
|
|
89
|
+
fn = Path(fn)
|
|
90
|
+
config = _build_control_config(user_dict, simulation_dict, fn.parent)
|
|
91
|
+
write_file(fn, config)
|
|
92
|
+
|
|
93
|
+
return config
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def read_control_file(control: str | Path) -> ControlConfig:
|
|
97
|
+
"""Parse a Cycles control file into a ControlConfig instance.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
control: Path to a Cycles control file path.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Control configuration.
|
|
104
|
+
"""
|
|
105
|
+
with open(Path(control)) as f:
|
|
106
|
+
lines = f.read().splitlines()
|
|
107
|
+
lines = iter([line for line in lines if (not line.strip().startswith('#')) and line.strip()])
|
|
108
|
+
|
|
109
|
+
hints = get_type_hints(ControlConfig) # resolves all string annotations → actual types
|
|
110
|
+
|
|
111
|
+
control_dict = {}
|
|
112
|
+
for f in fields(ControlConfig):
|
|
113
|
+
target_class = unwrap_optional(hints[f.name])
|
|
114
|
+
sub_hints = get_type_hints(target_class)
|
|
115
|
+
control_dict[f.name] = target_class(**{sub_field.name: parse_value(next(lines), sub_field.name, sub_hints[sub_field.name]) for sub_field in fields(target_class)})
|
|
116
|
+
|
|
117
|
+
return ControlConfig(**control_dict)
|