Cycles-utils 4.0.1__tar.gz → 4.0.3__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 (41) hide show
  1. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/Cycles_utils.egg-info/PKG-INFO +1 -1
  2. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/PKG-INFO +1 -1
  3. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/__init__.py +1 -0
  4. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/cycles.py +2 -3
  5. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/cycles_runner.py +2 -4
  6. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/cycles_tools/__init__.py +2 -2
  7. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/cycles_tools/_base_file.py +1 -1
  8. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/cycles_tools/control_file.py +29 -0
  9. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/cycles_tools/nudge_file.py +8 -0
  10. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/cycles_tools/operation_file.py +15 -0
  11. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/cycles_tools/output_file.py +1 -1
  12. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/cycles_tools/plot_tools.py +57 -6
  13. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/cycles_tools/reinit_file.py +4 -4
  14. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/cycles_tools/soil_file.py +5 -49
  15. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/gadm/__init__.py +1 -1
  16. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/rotation_builder.py +3 -4
  17. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/soilgrids/soilgrids.py +4 -6
  18. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/ssurgo/ssurgo.py +59 -61
  19. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/setup.py +1 -1
  20. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/Cycles_utils.egg-info/SOURCES.txt +0 -0
  21. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/Cycles_utils.egg-info/dependency_links.txt +0 -0
  22. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/Cycles_utils.egg-info/requires.txt +0 -0
  23. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/Cycles_utils.egg-info/top_level.txt +0 -0
  24. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/LICENSE +0 -0
  25. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/MANIFEST.in +0 -0
  26. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/README.md +0 -0
  27. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/cycles_tools/weather_file.py +0 -0
  28. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/data/GLDASp5_elevation_025d.nc4 +0 -0
  29. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/data/GLDASp5_landmask_025d.nc4 +0 -0
  30. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/data/NLDAS_elevation.nc4 +0 -0
  31. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/data/NLDAS_masks-veg-soil.nc4 +0 -0
  32. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/data/fips_gid_conversion.csv +0 -0
  33. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/data/gridMET_elevation_mask.nc +0 -0
  34. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/data/metdata_elevationdata.nc +0 -0
  35. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/data/us_states.csv +0 -0
  36. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/gadm/gadm.py +0 -0
  37. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/soilgrids/__init__.py +0 -0
  38. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/ssurgo/__init__.py +0 -0
  39. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/weather/__init__.py +0 -0
  40. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/cycles/weather/weather.py +0 -0
  41. {cycles_utils-4.0.1 → cycles_utils-4.0.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Cycles-utils
3
- Version: 4.0.1
3
+ Version: 4.0.3
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Cycles-utils
3
- Version: 4.0.1
3
+ Version: 4.0.3
4
4
  Summary: Python scripts to build Cycles input files and post-process Cycles output files
5
5
  Home-page: https://github.com/PSUmodeling/Cycles-utils
6
6
  Author: Yuning Shi
@@ -12,6 +12,7 @@ from .cycles_tools import SoilLayer
12
12
  from .cycles_tools import plot_yield
13
13
  from .cycles_tools import plot_operations
14
14
  from .cycles_tools import plot_map
15
+ from .cycles_tools import plot_satellite_map
15
16
  from .cycles_tools import Operation, Planting, Tillage, Harvest, Kill, FixedFertilization, FixedIrrigation, AutoIrrigation
16
17
  from .cycles_runner import CyclesRunner
17
18
  from .rotation_builder import CyclesRotationBuilder, Crop, CropGroup
@@ -5,7 +5,6 @@ import pandas as pd
5
5
  import subprocess
6
6
  from collections.abc import Collection
7
7
  from dataclasses import dataclass, field
8
- from datetime import datetime
9
8
  from pathlib import Path
10
9
  from matplotlib.axes import Axes
11
10
  from .cycles_tools import SoilLayer, ControlConfig
@@ -27,8 +26,8 @@ class Output:
27
26
  class Cycles:
28
27
  """Interface for executing one Cycles simulation and reading its files.
29
28
 
30
- Provides methods to run simulations, read outputs, and inspect soil/weather/operation
31
- configurations. Automatically loads the control file upon initialization.
29
+ Provides methods to run simulations, read outputs, and inspect soil/weather/operation configurations. Automatically
30
+ loads the control file upon initialization.
32
31
 
33
32
  Attributes:
34
33
  path: Path to the simulation directory (containing input/ and output/ subdirs).
@@ -29,9 +29,8 @@ class SimulationContext:
29
29
  class CyclesRunner:
30
30
  """Run one or many Cycles simulations with templated inputs.
31
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.
32
+ Manages batch execution of Cycles simulations by generating control files, operation files, and nudge files from
33
+ templates and parameter dictionaries. Consolidates results into a summary CSV file.
35
34
 
36
35
  Attributes:
37
36
  executable: Absolute path to the Cycles executable binary.
@@ -110,7 +109,6 @@ class CyclesRunner:
110
109
 
111
110
 
112
111
  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
112
  control = resolve_dict_values(control_dict, simulation)
115
113
  return SimulationContext(
116
114
  name=control['simulation_name'],
@@ -9,13 +9,12 @@ from .soil_file import MAPPABLE_PARAMETERS
9
9
  from .soil_file import SoilLayer
10
10
  from .soil_file import generate_soil_file
11
11
  from .soil_file import read_soil_file
12
- from .soil_file import map_to_dataframe
13
- from .soil_file import from_dataframe
14
12
  from .weather_file import read_weather_file
15
13
  from .reinit_file import generate_reinit_file
16
14
  from .plot_tools import plot_yield
17
15
  from .plot_tools import plot_operations
18
16
  from .plot_tools import plot_map
17
+ from .plot_tools import plot_satellite_map
19
18
  from ._base_file import resolve_dict_values
20
19
 
21
20
  __all__ = [
@@ -31,4 +30,5 @@ __all__ = [
31
30
  "plot_yield",
32
31
  "plot_operations",
33
32
  "plot_map",
33
+ "plot_satellite_map",
34
34
  ]
@@ -23,7 +23,7 @@ def write_file(fn: Path, config) -> None:
23
23
 
24
24
 
25
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()}
26
+ return {key.lower(): func(simulation) if callable(func) else func for key, func in user_dict.items()}
27
27
 
28
28
 
29
29
  def extract(dc_class, resolved: dict) -> dict:
@@ -78,6 +78,35 @@ def _get_soil_layers(fn: Path) -> int:
78
78
  def generate_control_file(fn: str | Path, user_dict: dict, *, 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.
85
+
86
+ The following fields are required in `user_dict`:
87
+
88
+ - `simulation_start_year`
89
+ - `simulation_end_year`
90
+ - `rotation_size`
91
+ - `operation_file`
92
+ - `soil_file`
93
+ - `weather_file`
94
+
95
+ The default values for other fields are:
96
+
97
+ - `crop_file`: `GenericCrops.crop`
98
+ - `reinit_file`: `N/A`
99
+ - `soil_layers`: inferred from the soil file (if not provided)
100
+ - `co2_level`: `-999`
101
+ - `use_reinitialization`: `0`
102
+ - `adjusted_yields`: `0`
103
+ - `hydrology_option`: `1`
104
+ - `automatic_nitrogen`: `0`
105
+ - `automatic_phosphorus`: `0`
106
+ - `automatic_sulfur`: `0`
107
+
108
+ All output control fields default to `0`.
109
+
81
110
  Args:
82
111
  fn: Destination control file path.
83
112
  user_dict: Values or callables for control fields.
@@ -45,6 +45,14 @@ def _build_nudge_config(user_dict: dict, simulation_dict: dict[str, Any] | None)
45
45
  def generate_nudge_file(fn: str | Path, user_dict: dict, *, simulation_dict: dict[str, Any] | None=None) -> None:
46
46
  """Write a Cycles nudge file from user-provided values.
47
47
 
48
+ Provide either direct values or callables that accept a simulation row and return a value in `user_dict`. The
49
+ parameter names should be in lowercase and correspond to the fields in Cycles nudge (calibration) files. If a field
50
+ is not provided, it will be filled with a default value. If a field's value is a callable, it will be called with
51
+ the `simulation_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
+
48
56
  Args:
49
57
  fn: Destination nudge file path.
50
58
  user_dict: Values or callables for nudge parameters.
@@ -8,6 +8,7 @@ class Operation(Protocol):
8
8
  year: int | None
9
9
  doy: int
10
10
  relative_doy: bool
11
+ resolved_doy: int | None
11
12
 
12
13
  @dataclass(kw_only=True)
13
14
  class Planting(Operation):
@@ -33,6 +34,7 @@ class Planting(Operation):
33
34
  harvest_timing: float = -999
34
35
  kill_after_harvest: int = 1
35
36
  relative_doy: bool = field(default=False, metadata={'readable': False})
37
+ resolved_doy: int | None = field(default=None, metadata={'readable': False})
36
38
 
37
39
  @dataclass(kw_only=True)
38
40
  class Tillage(Operation):
@@ -43,6 +45,7 @@ class Tillage(Operation):
43
45
  frac_thermal_time: float = 0.0
44
46
  kill_efficiency: float = 0.0
45
47
  relative_doy: bool = field(default=False, metadata={'readable': False})
48
+ resolved_doy: int | None = field(default=None, metadata={'readable': False})
46
49
 
47
50
  @dataclass(kw_only=True)
48
51
  class Harvest(Operation):
@@ -53,6 +56,7 @@ class Harvest(Operation):
53
56
  frac_thermal_time: float = 0.0
54
57
  kill_efficiency: float = 0.0
55
58
  relative_doy: bool = field(default=False, metadata={'readable': False})
59
+ resolved_doy: int | None = field(default=None, metadata={'readable': False})
56
60
 
57
61
  @dataclass(kw_only=True)
58
62
  class Kill(Operation):
@@ -63,6 +67,7 @@ class Kill(Operation):
63
67
  frac_thermal_time: float = 0.0
64
68
  kill_efficiency: float = 0.0
65
69
  relative_doy: bool = field(default=False, metadata={'readable': False})
70
+ resolved_doy: int | None = field(default=None, metadata={'readable': False})
66
71
 
67
72
  @dataclass(kw_only=True)
68
73
  class FixedFertilization(Operation):
@@ -74,6 +79,7 @@ class FixedFertilization(Operation):
74
79
  method: str = 'Broadcast'
75
80
  depth: float = 0.0
76
81
  relative_doy: bool = field(default=False, metadata={'readable': False})
82
+ resolved_doy: int | None = field(default=None, metadata={'readable': False})
77
83
 
78
84
  @dataclass(kw_only=True)
79
85
  class FixedIrrigation(Operation):
@@ -81,6 +87,7 @@ class FixedIrrigation(Operation):
81
87
  doy: int
82
88
  volume: float = 0.0
83
89
  relative_doy: bool = field(default=False, metadata={'readable': False})
90
+ resolved_doy: int | None = field(default=None, metadata={'readable': False})
84
91
 
85
92
  @dataclass(kw_only=True)
86
93
  class AutoIrrigation:
@@ -99,6 +106,13 @@ OPERATION_PARAMETERS = {
99
106
  }
100
107
 
101
108
 
109
+ def _resolve_reference_doy(operations: list[Operation], relative_doy: int) -> int:
110
+ planting = next((op for op in reversed(operations) if isinstance(op, Planting)), None)
111
+ if planting is None:
112
+ raise ValueError("Relative DOY specified but no planting operation found.")
113
+ return planting.doy + relative_doy
114
+
115
+
102
116
  def read_operation_file(operation: str | Path) -> list:
103
117
  """Parse a Cycles operation file into operation objects.
104
118
 
@@ -137,6 +151,7 @@ def read_operation_file(operation: str | Path) -> list:
137
151
  if isinstance(raw_doy, str) and raw_doy.split()[1].strip().startswith('+'):
138
152
  operation_dict['doy'] = int(raw_doy.split()[1].strip()[1:])
139
153
  operation_dict['relative_doy'] = True
154
+ operation_dict['resolved_doy'] = _resolve_reference_doy(operations, operation_dict['doy'])
140
155
  else:
141
156
  operation_dict['doy'] = parse_value(raw_doy, 'doy', hints['doy'])
142
157
  operation_dict['relative_doy'] = False
@@ -15,7 +15,7 @@ def _parse_units(lines: list[str], columns) -> dict[str, str]:
15
15
 
16
16
 
17
17
  def read_output(path: str | Path, output_type: str) -> tuple[pd.DataFrame, dict[str, str]]:
18
- """Read one Cycles output table and associated unit strings.
18
+ """Read one Cycles output file and associated unit strings.
19
19
 
20
20
  Args:
21
21
  path: Directory containing output CSV files.
@@ -1,7 +1,9 @@
1
1
  from __future__ import annotations
2
2
  import cartopy.crs as ccrs
3
3
  import cartopy.feature as feature
4
+ import cartopy.io.img_tiles as cimgt
4
5
  import geopandas as gpd
6
+ import math
5
7
  import matplotlib.lines as mlines
6
8
  import matplotlib.pyplot as plt
7
9
  import numpy as np
@@ -150,30 +152,34 @@ def plot_operations(operations: list, rotation_size: int, *, axs: Axes | np.ndar
150
152
  for key, value in OPERATION_TYPES.items():
151
153
  sub_list = [op for op in operations if type(op).__name__.lower() == key and op.year == y + 1]
152
154
 
153
- if len(sub_list) == 0: continue
155
+ if not sub_list: continue
154
156
 
155
157
  axs[y].plot(
156
- [op.doy for op in sub_list], [value.yloc] * len(sub_list),
158
+ [op.doy if not op.relative_doy else op.resolved_doy for op in sub_list], [value.yloc] * len(sub_list),
157
159
  'o',
158
- label=value.title + ':\n' + '\n'.join(f'{op.doy}: {getattr(op, value.label)}' if value.label is not None else f'{op.doy}' for op in sub_list),
160
+ label=value.title + ':\n' + '\n'.join(f'{op.doy if not op.relative_doy else op.resolved_doy}: {getattr(op, value.label)}' if value.label is not None else f'{op.doy}' for op in sub_list),
159
161
  color=value.color,
160
162
  ms=10,
161
163
  )
162
164
 
165
+ if key == 'planting':
166
+ for op in sub_list:
167
+ if op.end_doy == -999: continue
168
+ axs[y].fill_betweenx([value.yloc - 0.5, value.yloc + 0.5], op.doy, op.end_doy, alpha=0.25, color=value.color)
169
+
163
170
  axs[y].set_xlim(-1, 370)
164
171
  axs[y].grid(False)
165
- axs[y].spines['right'].set_color('none')
166
- axs[y].spines['left'].set_color('none')
167
172
  axs[y].yaxis.set_ticks_position('none')
168
173
  axs[y].yaxis.set_tick_params(left=False, right=False, which='both', labelleft=False)
169
174
  axs[y].set_ylim(-3, 7)
170
175
  axs[y].text(184, 5, f'Year {y + 1}', ha='center')
176
+ for spine in ('right', 'left', 'top'):
177
+ axs[y].spines[spine].set_color('none')
171
178
 
172
179
  # set the y-spine
173
180
  axs[y].spines['bottom'].set_position('zero')
174
181
 
175
182
  # turn off the top spine/ticks
176
- axs[y].spines['top'].set_color('none')
177
183
  axs[y].xaxis.tick_bottom()
178
184
  axs[y].set_xticks(MDOYS)
179
185
  axs[y].set_xticklabels(MONTHS)
@@ -270,3 +276,48 @@ def plot_map(gdf: gpd.GeoDataFrame, column: str, *, projection: ccrs.Projection=
270
276
  ax.set_title(title) # type: ignore
271
277
 
272
278
  return fig, ax # type: ignore
279
+
280
+
281
+ def _zoom_from_extent(extent, *, desired_pixels: int=1024) -> int:
282
+ """Determine the appropriate zoom level for a given map extent.
283
+ Number of global tiles at each zoom level is 2 ^ zoom_level
284
+ """
285
+ PIXELS_PER_TILE = 256
286
+
287
+ dx = abs(extent[1] - extent[0])
288
+ dy = abs(extent[3] - extent[2])
289
+
290
+ return round(max(math.log(desired_pixels / PIXELS_PER_TILE * 180 / dy) / math.log(2), math.log(desired_pixels / PIXELS_PER_TILE * 360 / dx) / math.log(2)))
291
+
292
+
293
+ def plot_satellite_map(fig: Figure, extent: tuple[float, float, float, float], *,
294
+ alpha: float=1.0, ax: tuple[float, float, float, float] | None=None, desired_pixels: int=1024, style: str | None=None) -> tuple[GeoAxes, ccrs.Projection]:
295
+ """Render a satellite basemap over a geographic extent.
296
+
297
+ Args:
298
+ fig: Matplotlib figure used to create the map axes.
299
+ extent: Geographic bounds as ``(west, east, south, north)`` in degrees.
300
+ alpha: Basemap transparency where ``1.0`` is fully opaque.
301
+ ax: Optional axes rectangle passed to ``Figure.add_axes``. If omitted, a full-figure subplot is created.
302
+ desired_pixels: Target figure resolution in pixels used to estimate tile zoom.
303
+ style: Optional Cartopy GoogleTiles style string. If omitted, ArcGIS World Imagery tiles are used.
304
+
305
+ Returns:
306
+ Tuple containing:
307
+ - GeoAxes with the satellite basemap.
308
+ - Projection associated with the tile source.
309
+ """
310
+ GOOGLE_URL = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}.jpg'
311
+
312
+ google_satellite = cimgt.GoogleTiles(url=GOOGLE_URL) if style is None else cimgt.GoogleTiles(style=style)
313
+
314
+ if ax is None:
315
+ ax = fig.add_subplot(1, 1, 1, projection=google_satellite.crs) # type: ignore
316
+ else:
317
+ ax = fig.add_axes(ax, projection=google_satellite.crs) # type: ignore
318
+
319
+ assert isinstance(ax, GeoAxes)
320
+ ax.set_extent(extent, crs=ccrs.PlateCarree())
321
+ ax.add_image(google_satellite, _zoom_from_extent(extent, desired_pixels=desired_pixels), alpha=alpha)
322
+
323
+ return ax, google_satellite.crs
@@ -74,12 +74,12 @@ def _format_reinit_block(row: pd.Series, doy: int, n_layers: int) -> list[str]:
74
74
 
75
75
 
76
76
  def generate_reinit_file(out_path: str | Path, in_path: str | Path, doy: int) -> None:
77
- """Write a reinit file for all years at a selected day-of-year.
77
+ """Write a re-initialization file from Cycles output for all years at a selected day-of-year.
78
78
 
79
79
  Args:
80
- out_path: Destination reinit file path.
81
- in_path: Simulation output directory or stem used by ``read_output``.
82
- doy: Day-of-year extracted from reinit output.
80
+ out_path: Destination re-initialization file path.
81
+ in_path: Simulation output directory that contains Cycles output files.
82
+ doy: Day-of-year extracted from re-initialization.
83
83
  """
84
84
  out_path = Path(out_path)
85
85
  in_path = Path(in_path)
@@ -215,50 +215,18 @@ def _parse_token(token: str, param: SoilParameter) -> str | int | float | None:
215
215
  return token
216
216
 
217
217
 
218
- def _row_to_layer(row: pd.Series) -> SoilLayer:
219
- valid_fields = {f.name for f in fields(SoilLayer)}
220
- kwargs = {col: (None if pd.isna(val) else val) for col, val in row.items() if col in valid_fields}
221
- return SoilLayer(**kwargs) # type: ignore
222
-
223
-
224
- def map_layers(profile: list[SoilLayer], target: list[SoilLayer]=DEFAULT_PROFILE, parameters: list[str]=MAPPABLE_PARAMETERS, soil_depth: float | None=None) -> list[SoilLayer]:
225
- """Map measured soil properties onto a target profile.
226
-
227
- The mapping is performed by overlap-weighted averaging for each requested
228
- parameter, after trimming target layers to the effective profile depth.
229
-
230
- Args:
231
- profile: Soil profile layers in depth order.
232
- target: Target layer structure to map onto.
233
- parameters: Soil parameters to map from measured profile to target.
234
- soil_depth: Optional maximum depth (m) to include in the mapped profile.
235
-
236
- Returns:
237
- A list of mapped soil layers aligned to the target profile.
238
- """
218
+ def _map_layers(profile: list[SoilLayer], target: list[SoilLayer]=DEFAULT_PROFILE, parameters: list[str]=MAPPABLE_PARAMETERS, soil_depth: float | None=None) -> list[SoilLayer]:
239
219
  trimmed = _trim(target, profile[-1].bottom, soil_depth)
240
220
  return [_map_layer(layer, profile, parameters) for layer in trimmed]
241
221
 
242
222
 
243
- def map_to_dataframe(layers: list[SoilLayer]) -> pd.DataFrame:
244
- """Convert soil layers to a tabular representation.
245
-
246
- Args:
247
- layers: Soil layers to convert.
248
-
249
- Returns:
250
- A DataFrame with one row per layer, including computed ``thickness``.
251
- """
252
- return pd.DataFrame([{**asdict(layer), 'thickness': layer.thickness} for layer in layers])
253
-
254
-
255
223
  def generate_soil_file(fn: str | Path, profile: list[SoilLayer], *,
256
224
  target: list[SoilLayer]=DEFAULT_PROFILE, parameters: list[str]=MAPPABLE_PARAMETERS, soil_depth: float | None=None,
257
225
  desc: str='', slope: float | None=None, curve_number: float | None=None, hsg: str='') -> list[SoilLayer]:
258
- """Map layers and write a Cycles-formatted soil file.
226
+ """Generate a Cycles-formatted soil file from a list of soil layers.
259
227
 
260
- The input profile is first mapped to the target layering scheme, then
261
- rendered to Cycles soil-file format and written to ``fn``.
228
+ The input profile is first mapped to the target layering scheme, then rendered to Cycles soil-file format and
229
+ written to ``fn``.
262
230
 
263
231
  Args:
264
232
  fn: Output soil file path.
@@ -279,7 +247,7 @@ def generate_soil_file(fn: str | Path, profile: list[SoilLayer], *,
279
247
  """
280
248
  if curve_number is not None and hsg:
281
249
  raise ValueError("Only one of curve_number and hsg can be provided.")
282
- layers = map_layers(profile, target, parameters, soil_depth)
250
+ layers = _map_layers(profile, target, parameters, soil_depth)
283
251
  Path(fn).write_text('\n'.join(_render_soil_file(layers, desc, slope, curve_number, hsg)) + '\n')
284
252
  return layers
285
253
 
@@ -301,15 +269,3 @@ def read_soil_file(fn: str | Path) -> tuple[list[SoilLayer], dict]:
301
269
  meta, data_lines = _parse_header(lines)
302
270
  layers = _parse_layers(data_lines)
303
271
  return layers, meta
304
-
305
-
306
- def from_dataframe(df: pd.DataFrame) -> list[SoilLayer]:
307
- """Convert a DataFrame into ``SoilLayer`` objects.
308
-
309
- Args:
310
- df: DataFrame containing columns matching ``SoilLayer`` fields.
311
-
312
- Returns:
313
- A list of ``SoilLayer`` instances created row-by-row.
314
- """
315
- return [_row_to_layer(row) for _, row in df.iterrows()]
@@ -7,7 +7,7 @@ from .gadm import county_fips
7
7
  from .gadm import county_gid
8
8
  from .gadm import county_name
9
9
 
10
- _all__ = [
10
+ __all__ = [
11
11
  "read_gadm",
12
12
  "state_abbreviation",
13
13
  "state_fips",
@@ -93,10 +93,9 @@ class RotationResult(NamedTuple):
93
93
  class CyclesRotationBuilder:
94
94
  """Run Cycles iteratively and append economically optimal operations.
95
95
 
96
- Executes Cycles simulations for candidate crop-operation combinations,
97
- selects the highest-return option at each break-point, and iteratively
98
- builds a multi-year rotation. Integrates economic scoring with agronomic
99
- constraints (e.g., minimum planting interval, crop group penalties).
96
+ Executes Cycles simulations for candidate crop-operation combinations, selects the highest-return option at each
97
+ break-point, and iteratively builds a multi-year rotation. Integrates economic scoring with agronomic constraints
98
+ (e.g., minimum planting interval, crop group penalties).
100
99
 
101
100
  Attributes:
102
101
  simulation: Base simulation name for generated input/output directories.
@@ -90,10 +90,8 @@ class SoilGrids:
90
90
  """Reproject loaded maps to a reference raster grid.
91
91
 
92
92
  Args:
93
- reference_xds: Reference raster whose CRS, transform, and resolution
94
- are used for reprojection.
95
- reference_name: Column name used for the reference raster values in
96
- the generated matched table.
93
+ reference_xds: Reference raster whose CRS, transform, and resolution are used for reprojection.
94
+ reference_name: Column name used for the reference raster values in the generated matched table.
97
95
  boundary: Boundary geometry used to clip all rasters after reprojection.
98
96
 
99
97
  Returns:
@@ -211,8 +209,8 @@ def download_soilgrids_data(path: str | Path, *,
211
209
  maps: list[str]=ALL_MAPS, boundary: Polygon | None=None, bbox: tuple[float, float, float, float] | None=None, crs: str='epsg:4326') -> None:
212
210
  """Download SoilGrids raster layers via WCS.
213
211
 
214
- You can provide either a boundary polygon or an explicit bounding box.
215
- Bounding boxes are expected in ``(west, south, east, north)`` order.
212
+ You can provide either a boundary polygon or an explicit bounding box. Bounding boxes are expected in
213
+ ``(west, south, east, north)`` order.
216
214
 
217
215
  Args:
218
216
  path: Directory where downloaded GeoTIFF files are written.
@@ -1,8 +1,12 @@
1
1
  from __future__ import annotations
2
2
  import geopandas as gpd
3
+ import matplotlib.colors as colors
4
+ import matplotlib.pyplot as plt
5
+ import numpy as np
3
6
  import pandas as pd
4
7
  import shapely
5
8
  from dataclasses import dataclass
9
+ from matplotlib.axes import Axes
6
10
  from pathlib import Path
7
11
  from shapely.geometry import Point
8
12
  from cycles.cycles_tools import generate_soil_file as _generate_soil_file
@@ -65,12 +69,32 @@ SSURGO_PARAMETERS: dict[str, SsurgoParameter] = {
65
69
 
66
70
  LatLon = tuple[float, float]
67
71
 
72
+
73
+ class MapUnitGeoDataFrame(gpd.GeoDataFrame):
74
+ def plot(self, **kwargs) -> Axes:
75
+ n = len(self)
76
+ kwargs.setdefault('column', 'musym')
77
+ kwargs.setdefault('legend', True)
78
+ kwargs.setdefault(
79
+ 'cmap',
80
+ 'tab20' if n > 20 else _truncate_colormap(plt.get_cmap('tab20'), 0, n / 20),
81
+ )
82
+ ax = super().plot(**kwargs)
83
+ ax.axis('off')
84
+ ax.set_aspect('equal')
85
+ return ax
86
+
87
+
68
88
  class Ssurgo:
69
89
  """Load SSURGO lookup tables and extract soil profiles for locations."""
70
90
 
71
91
  def __init__(self, path: str | Path, state: str, *, lat_lon: LatLon | None=None, boundary: gpd.GeoDataFrame | None=None) -> None:
72
92
  """Initialize SSURGO lookup tables and optional spatial subset.
73
93
 
94
+ When a location (`lat_lon`) or boundary polygon is provided, the map-unit table is filtered to include only
95
+ those map units that intersect the location or polygon; the map-units are also grouped by name and symbol, and
96
+ the major map unit is selected for profile extraction.
97
+
74
98
  Args:
75
99
  path: Directory containing SSURGO geodatabase and lookup CSV files.
76
100
  state: State identifier used in SSURGO file naming.
@@ -86,10 +110,10 @@ class Ssurgo:
86
110
  _validate_geographic_input(lat_lon, boundary)
87
111
 
88
112
  self.state: str = state
89
- self._mapunits: gpd.GeoDataFrame | pd.DataFrame | None = None
90
- self.grouped_mapunits: gpd.GeoDataFrame | pd.DataFrame | None = None
91
- self.components: pd.DataFrame | None = None
92
- self.horizons: pd.DataFrame | None = None
113
+ self._mapunits: gpd.GeoDataFrame | pd.DataFrame
114
+ self.components: pd.DataFrame
115
+ self.horizons: pd.DataFrame
116
+ self.grouped_mapunits: MapUnitGeoDataFrame | None = None
93
117
  self.mukey: int | None = None
94
118
  self.slope: float | None = None
95
119
  self.hsg: str = ''
@@ -111,21 +135,25 @@ class Ssurgo:
111
135
  )
112
136
  gdf = _read_mupolygon(path, state, boundary)
113
137
  self._mapunits = gdf.merge(luts['mapunit'], on='mukey', how='left')
114
-
115
138
  self.components = self.components[self.components['mukey'].isin(self._mapunits['mukey'].unique())]
116
139
  self.horizons = self.horizons[self.horizons['cokey'].isin(self.components['cokey'].unique())]
117
140
 
141
+ self._group_map_units()
142
+ self._select_major_mapunit()
118
143
  self._average_slope_hsg()
119
144
 
120
145
 
121
146
  @property
122
- def mapunits(self) -> gpd.GeoDataFrame | pd.DataFrame | None:
147
+ def mapunits(self) -> MapUnitGeoDataFrame | pd.DataFrame:
123
148
  """Return loaded map-unit table.
124
149
 
125
150
  Returns:
126
151
  Map-unit table as GeoDataFrame/DataFrame, or None if not loaded.
127
152
  """
128
- return self._mapunits
153
+ if isinstance(self._mapunits, gpd.GeoDataFrame):
154
+ return MapUnitGeoDataFrame(self._mapunits, geometry=self._mapunits.geometry.name, crs=self._mapunits.crs)
155
+ else:
156
+ return self._mapunits
129
157
 
130
158
 
131
159
  @property
@@ -152,39 +180,22 @@ class Ssurgo:
152
180
  return self._mapunits[self._mapunits['mukey'] == self.mukey]['musym'].iloc[0]
153
181
 
154
182
 
155
- def group_map_units(self, *, geometry: bool=False) -> None:
156
- """Group map units by base soil-series name.
157
-
158
- Many map units share a base soil texture but differ in slope class or
159
- minor qualifiers. This method groups by the base ``muname`` text before
160
- the first comma and optionally dissolves geometries.
161
-
162
- Args:
163
- geometry: If True, dissolve polygons by grouped map-unit name.
164
-
165
- Returns:
166
- None.
167
- """
168
- if self.grouped_mapunits is not None:
169
- return
170
- assert self._mapunits is not None
171
-
183
+ def _group_map_units(self) -> None:
172
184
  gmu = self._mapunits.copy()
173
185
  gmu['muname'] = gmu['muname'].map(lambda name: name.split(',')[0]) # type: ignore
174
- gmu['musym'] = gmu['musym'].map(_strip_slope_suffix)
186
+ gmu['musym'] = gmu['musym'].map(_strip_slope_suffix)
175
187
 
176
188
  mask = self.non_soil_mask(gmu)
177
189
  gmu.loc[mask, 'muname'] = 'Water, urban, etc.'
178
190
  gmu.loc[mask, 'mukey'] = -999
179
191
  gmu.loc[mask, 'musym'] = 'N/A'
180
192
 
181
- if geometry:
182
- gmu = gmu.dissolve(
183
- by='muname',
184
- aggfunc={'mukey': 'first', 'musym': 'first', 'shape_area': 'sum'},
185
- ).reset_index() # type: ignore
193
+ gmu = gmu.dissolve(
194
+ by='muname',
195
+ aggfunc={'mukey': 'first', 'musym': 'first', 'shape_area': 'sum'},
196
+ ).reset_index() # type: ignore
186
197
 
187
- self.grouped_mapunits = gmu
198
+ self.grouped_mapunits = MapUnitGeoDataFrame(gmu, geometry=gmu.geometry.name, crs=gmu.crs)
188
199
 
189
200
 
190
201
  def non_soil_mask(self, mapunits: pd.DataFrame | gpd.GeoDataFrame) -> pd.Series:
@@ -203,18 +214,8 @@ class Ssurgo:
203
214
  )
204
215
 
205
216
 
206
- def select_major_mapunit(self) -> None:
207
- """Select the dominant map unit by area.
208
-
209
- Returns:
210
- None.
211
- """
212
- if self.mukey is not None:
213
- return
214
- if self.grouped_mapunits is None:
215
- self.group_map_units(geometry=True)
217
+ def _select_major_mapunit(self) -> None:
216
218
  assert self.grouped_mapunits is not None
217
-
218
219
  gdf = self.grouped_mapunits[~self.non_soil_mask(self.grouped_mapunits)].copy()
219
220
  gdf['area'] = gdf.area
220
221
  self.mukey = int(gdf.loc[gdf['area'].idxmax(), 'mukey']) # type: ignore
@@ -231,7 +232,6 @@ class Ssurgo:
231
232
  Soil profile as a list of ``SoilLayer`` records.
232
233
  """
233
234
  mukey = mukey or self._ensure_mukey()
234
- assert self.components is not None and self.horizons is not None
235
235
 
236
236
  df = self.components[self.components['mukey'] == int(mukey)].copy()
237
237
  if major_only:
@@ -261,19 +261,15 @@ class Ssurgo:
261
261
  Returns:
262
262
  None.
263
263
  """
264
- if mukey is None:
265
- self.group_map_units(geometry=True)
266
- self.select_major_mapunit()
267
- mukey = self.mukey
268
-
269
- assert mukey is not None
270
- assert self._mapunits is not None
264
+ if mukey is not None:
265
+ hsg = self._mapunits[self._mapunits['mukey'] == mukey]['hydgrpdcd'].iloc[0] if hsg is None else hsg
266
+ slope = self._mapunits[self._mapunits['mukey'] == mukey]['slopegradwta'].iloc[0] if slope is None else slope
267
+ else:
268
+ mukey = self._ensure_mukey()
269
+ hsg = self.hsg if hsg is None else hsg
270
+ slope = self.slope if slope is None else slope
271
271
 
272
- if hsg is None:
273
- hsg = self._mapunits[self._mapunits['mukey'] == mukey]['hydgrpdcd'].iloc[0] if not self.hsg else self.hsg
274
- assert hsg is not None
275
- if slope is None:
276
- slope = self._mapunits[self._mapunits['mukey'] == mukey]['slopegradwta'].iloc[0] if not self.slope else self.slope
272
+ assert mukey is not None and hsg is not None and slope is not None
277
273
 
278
274
  profile = self.get_soil_profile(mukey=mukey)
279
275
  desc = desc if desc is not None else _build_desc(self._get_muname(mukey), mukey, hsg)
@@ -281,20 +277,15 @@ class Ssurgo:
281
277
 
282
278
 
283
279
  def _ensure_mukey(self) -> int:
284
- if self.mukey is None:
285
- self.select_major_mapunit()
286
- assert self.mukey is not None
280
+ assert self.mukey is not None, "A major soil map unit has not been selected, because a location or boundary has not been provided."
287
281
  return self.mukey
288
282
 
289
283
 
290
284
  def _get_muname(self, mukey: int) -> str:
291
- assert self._mapunits is not None
292
285
  return self._mapunits[self._mapunits['mukey'] == mukey]['muname'].iloc[0]
293
286
 
294
287
 
295
288
  def _average_slope_hsg(self) -> None:
296
- assert self._mapunits is not None
297
-
298
289
  gdf = self._mapunits[~self.non_soil_mask(self._mapunits)].copy()
299
290
  gdf['area'] = gdf.area
300
291
 
@@ -302,6 +293,13 @@ class Ssurgo:
302
293
  self.hsg = _dominant_hsg(gdf) # type: ignore
303
294
 
304
295
 
296
+ def _truncate_colormap(cmap, minval=0.0, maxval=1.0, n=100):
297
+ return colors.LinearSegmentedColormap.from_list(
298
+ 'trunc({n},{a:.2f},{b:.2f})'.format(n=cmap.name, a=minval, b=maxval),
299
+ cmap(np.linspace(minval, maxval, n))
300
+ )
301
+
302
+
305
303
  def _validate_geographic_input(lat_lon: LatLon | None, boundary: gpd.GeoDataFrame | None) -> None:
306
304
  if lat_lon is not None and boundary is not None:
307
305
  raise ValueError("lat_lon and boundary are mutually exclusive — provide only one.")
@@ -5,7 +5,7 @@ with open('README.md', 'r') as f:
5
5
 
6
6
  setup(
7
7
  name='Cycles-utils',
8
- version='4.0.1',
8
+ version='4.0.3',
9
9
  author='Yuning Shi',
10
10
  author_email="shiyuning@gmail.com",
11
11
  packages=find_packages(),
File without changes
File without changes
File without changes
File without changes