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.
Files changed (51) hide show
  1. {cycles_utils-3.1.0 → cycles_utils-4.0.0}/Cycles_utils.egg-info/PKG-INFO +2 -2
  2. {cycles_utils-3.1.0 → cycles_utils-4.0.0}/Cycles_utils.egg-info/SOURCES.txt +13 -3
  3. {cycles_utils-3.1.0 → cycles_utils-4.0.0}/LICENSE +1 -1
  4. {cycles_utils-3.1.0 → cycles_utils-4.0.0}/PKG-INFO +2 -2
  5. {cycles_utils-3.1.0 → cycles_utils-4.0.0}/README.md +1 -1
  6. cycles_utils-4.0.0/cycles/__init__.py +17 -0
  7. cycles_utils-4.0.0/cycles/cycles.py +173 -0
  8. cycles_utils-4.0.0/cycles/cycles_runner.py +170 -0
  9. cycles_utils-4.0.0/cycles/cycles_tools/__init__.py +34 -0
  10. cycles_utils-4.0.0/cycles/cycles_tools/_base_file.py +47 -0
  11. cycles_utils-4.0.0/cycles/cycles_tools/control_file.py +117 -0
  12. cycles_utils-4.0.0/cycles/cycles_tools/nudge_file.py +55 -0
  13. cycles_utils-4.0.0/cycles/cycles_tools/operation_file.py +167 -0
  14. cycles_utils-4.0.0/cycles/cycles_tools/output_file.py +37 -0
  15. cycles_utils-4.0.0/cycles/cycles_tools/plot_tools.py +272 -0
  16. cycles_utils-4.0.0/cycles/cycles_tools/reinit_file.py +98 -0
  17. cycles_utils-4.0.0/cycles/cycles_tools/soil_file.py +315 -0
  18. cycles_utils-4.0.0/cycles/cycles_tools/weather_file.py +63 -0
  19. {cycles_utils-3.1.0 → cycles_utils-4.0.0}/cycles/gadm/__init__.py +11 -0
  20. cycles_utils-4.0.0/cycles/gadm/gadm.py +193 -0
  21. cycles_utils-4.0.0/cycles/rotation_builder.py +507 -0
  22. {cycles_utils-3.1.0 → cycles_utils-4.0.0}/cycles/soilgrids/__init__.py +5 -0
  23. cycles_utils-4.0.0/cycles/soilgrids/soilgrids.py +255 -0
  24. cycles_utils-4.0.0/cycles/ssurgo/__init__.py +8 -0
  25. cycles_utils-4.0.0/cycles/ssurgo/ssurgo.py +400 -0
  26. cycles_utils-4.0.0/cycles/weather/__init__.py +10 -0
  27. cycles_utils-4.0.0/cycles/weather/weather.py +551 -0
  28. {cycles_utils-3.1.0 → cycles_utils-4.0.0}/setup.py +1 -1
  29. cycles_utils-3.1.0/cycles/__init__.py +0 -12
  30. cycles_utils-3.1.0/cycles/cycles.py +0 -99
  31. cycles_utils-3.1.0/cycles/cycles_runner.py +0 -110
  32. cycles_utils-3.1.0/cycles/cycles_tools.py +0 -575
  33. cycles_utils-3.1.0/cycles/gadm/gadm.py +0 -100
  34. cycles_utils-3.1.0/cycles/gssurgo/__init__.py +0 -4
  35. cycles_utils-3.1.0/cycles/gssurgo/gssurgo.py +0 -283
  36. cycles_utils-3.1.0/cycles/soilgrids/soilgrids.py +0 -192
  37. cycles_utils-3.1.0/cycles/weather/__init__.py +0 -6
  38. cycles_utils-3.1.0/cycles/weather/weather.py +0 -571
  39. {cycles_utils-3.1.0 → cycles_utils-4.0.0}/Cycles_utils.egg-info/dependency_links.txt +0 -0
  40. {cycles_utils-3.1.0 → cycles_utils-4.0.0}/Cycles_utils.egg-info/requires.txt +0 -0
  41. {cycles_utils-3.1.0 → cycles_utils-4.0.0}/Cycles_utils.egg-info/top_level.txt +0 -0
  42. {cycles_utils-3.1.0 → cycles_utils-4.0.0}/MANIFEST.in +0 -0
  43. {cycles_utils-3.1.0 → cycles_utils-4.0.0}/cycles/data/GLDASp5_elevation_025d.nc4 +0 -0
  44. {cycles_utils-3.1.0 → cycles_utils-4.0.0}/cycles/data/GLDASp5_landmask_025d.nc4 +0 -0
  45. {cycles_utils-3.1.0 → cycles_utils-4.0.0}/cycles/data/NLDAS_elevation.nc4 +0 -0
  46. {cycles_utils-3.1.0 → cycles_utils-4.0.0}/cycles/data/NLDAS_masks-veg-soil.nc4 +0 -0
  47. {cycles_utils-3.1.0 → cycles_utils-4.0.0}/cycles/data/fips_gid_conversion.csv +0 -0
  48. {cycles_utils-3.1.0 → cycles_utils-4.0.0}/cycles/data/gridMET_elevation_mask.nc +0 -0
  49. {cycles_utils-3.1.0 → cycles_utils-4.0.0}/cycles/data/metdata_elevationdata.nc +0 -0
  50. {cycles_utils-3.1.0 → cycles_utils-4.0.0}/cycles/data/us_states.csv +0 -0
  51. {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.1.0
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
- Coming soon
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/cycles_tools.py
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) 2025 Penn State Earth System Modeling Team
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.1.0
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
- Coming soon
56
+ [psumodeling.github.io/Cycles-utils](https://psumodeling.github.io/Cycles-utils)
@@ -15,4 +15,4 @@ pip install Cycles-utils
15
15
 
16
16
  # API reference
17
17
 
18
- Coming soon
18
+ [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)