├── cyclesgym ├── tests │ ├── __init__.py │ ├── DummyWeather.weather │ ├── DummyWeatherShuffle.weather │ ├── DummyWeatherNonShuffle.weather │ ├── DummyOperation.operation │ ├── DummyN.dat │ ├── CropPlanningTest.operation │ ├── DummyControl.ctrl │ ├── CornTestReinit.ctrl │ ├── CropPlanningTest.ctrl │ ├── NCornTestNoFertilization.operation │ ├── test_soil.operation │ ├── NCornTest.ctrl │ ├── CornRandomWeatherTest.ctrl │ ├── NCornTestNoFertilization.ctrl │ ├── DummyCrop.dat │ ├── DummySeason.dat │ ├── GenericHagerstownModified.soil │ ├── test_rewarders.py │ ├── GenericHagerstown.soil │ ├── test_soil_reinit.soil │ ├── NCornTest.operation │ ├── test_soil.reinit │ ├── test_observers.py │ ├── test_policies.py │ ├── test_crop_planning.py │ ├── test_implementers.py │ ├── test_random_weather.py │ ├── test_env.py │ └── test_managers.py ├── utils │ ├── __init__.py │ ├── wandb_utils.py │ ├── paths.py │ ├── plot_utils.py │ └── pricing_utils.py ├── policies │ ├── __init__.py │ ├── dummy_policies.py │ └── informed_policy.py ├── envs │ ├── __init__.py │ ├── utils.py │ ├── rewarders.py │ ├── constrainers.py │ ├── observers.py │ ├── crop_planning.py │ └── weather_generator.py ├── managers │ ├── __init__.py │ ├── season.py │ ├── utils.py │ ├── common.py │ ├── soil_n.py │ ├── crop.py │ ├── control.py │ ├── weather.py │ └── operation.py └── __init__.py ├── .gitattributes ├── AUTHORS ├── .gitconfig ├── .gitignore ├── experiments ├── crop_planning │ ├── crop_planning_experiment.sh │ ├── crop_planning_baselines.py │ ├── tables.py │ └── policy_visualization.py └── fertilization │ ├── fertilization_experiment.sh │ ├── expert.py │ └── corn_soil_refined.py ├── documents ├── 4_examples.md ├── 6_contributions.md ├── 3.3_custom_spaces_and_rewards.md ├── manual.md ├── 0_installation.md ├── 5_experiments.md ├── 2_interface.md ├── 3.1_predefined_envs.md ├── 1_introduction.md ├── 3_logic.md ├── 3.4_default_operations.md └── 3.2_custom_weather_and_soil.md ├── README.md ├── setup.py ├── LICENSE ├── install_cycles.py └── notebooks ├── training_rl_agent.ipynb └── example_corn_fertilization_env.ipynb /cyclesgym/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cyclesgym/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cyclesgym/policies/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.ipynb filter=remove-notebook-output -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | # This is the list of koralabs's significant contributors. 2 | 3 | Matteo Turchetta 4 | Luca Corinzia 5 | Scott Sussex 6 | -------------------------------------------------------------------------------- /cyclesgym/envs/__init__.py: -------------------------------------------------------------------------------- 1 | from cyclesgym.envs.corn import Corn 2 | from cyclesgym.envs.crop_planning import CropPlanningFixedPlanting -------------------------------------------------------------------------------- /cyclesgym/utils/wandb_utils.py: -------------------------------------------------------------------------------- 1 | WANDB_ENTITY = None 2 | 3 | CROP_PLANNING_EXPERIMENT = 'experiments_crop_planning' 4 | FERTILIZATION_EXPERIMENT = 'agro-rl' -------------------------------------------------------------------------------- /.gitconfig: -------------------------------------------------------------------------------- 1 | [filter "remove-notebook-output"] 2 | clean = "jupyter nbconvert --ClearOutputPreprocessor.enabled=True --to=notebook --stdin --stdout --log-level=ERROR" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cycles 2 | wandb 3 | tables 4 | figures 5 | agents 6 | 7 | notebooks/.ipynb_checkpoints 8 | .idea 9 | .ipynb_checkpoints 10 | __pycache__/ 11 | *egg-info 12 | .DS_Store 13 | expert_trajectories/ 14 | 15 | 16 | -------------------------------------------------------------------------------- /cyclesgym/managers/__init__.py: -------------------------------------------------------------------------------- 1 | from cyclesgym.managers.common import * 2 | from cyclesgym.managers.control import * 3 | from cyclesgym.managers.operation import * 4 | from cyclesgym.managers.weather import * 5 | from cyclesgym.managers.crop import * 6 | from cyclesgym.managers.season import * 7 | from cyclesgym.managers.soil_n import * 8 | -------------------------------------------------------------------------------- /cyclesgym/tests/DummyWeather.weather: -------------------------------------------------------------------------------- 1 | LATITUDE 40.6875 2 | ALTITUDE 0.0 3 | SCREENING_HEIGHT 10.0 4 | YEAR DOY PP TX TN SOLAR RHX RHN WIND 5 | #### ### mm deg C deg C MJ/m2 % % m/s 6 | 1980 1 0.0 1.0 2.0 3.0 4.0 5.0 6.0 7 | 1980 2 0.0 0.0 0.0 0.0 0.0 0.0 1.5 -------------------------------------------------------------------------------- /experiments/crop_planning/crop_planning_experiment.sh: -------------------------------------------------------------------------------- 1 | for i in {1..5} 2 | do 3 | python train.py --fixed_weather True --non_adaptive True --seed $i 4 | python train.py --fixed_weather False --non_adaptive True --seed $i 5 | python train.py --fixed_weather True --non_adaptive False --seed $i 6 | python train.py --fixed_weather True --non_adaptive False --seed $i 7 | done 8 | -------------------------------------------------------------------------------- /experiments/fertilization/fertilization_experiment.sh: -------------------------------------------------------------------------------- 1 | for ey in {1980, 1981, 1984} 2 | do 3 | python3 train.py -ey $ey --baseline 4 | for seed in {1..5} 5 | do 6 | python3 train.py -ey ${ey} -na -fw --seed ${seed} 7 | python3 train.py -ey ${ey} -fw --seed ${seed} 8 | python3 train.py -ey ${ey} -na --seed ${seed} 9 | python3 train.py -ey ${ey} --seed ${seed} 10 | done 11 | done -------------------------------------------------------------------------------- /documents/4_examples.md: -------------------------------------------------------------------------------- 1 | ## Examples 2 | Here, we provide a list of examples on how to use cyclesgym: 3 | 1. [Create a fertilization environment with fixed weather and run an expert 4 | policy on it](../notebooks/example_corn_fertilization_env.ipynb) 5 | 2. [Train a reinforcement learning agent in a fertilization environment with fixed weather](../notebooks/training_rl_agent.ipynb) 6 | 3. More coming soon... -------------------------------------------------------------------------------- /documents/6_contributions.md: -------------------------------------------------------------------------------- 1 | ## Contributions 2 | Here, we provide a list of contributions that would help improve 3 | cyclesgym: 4 | 5 | 1. Automatic soil file generation from publicly available data, e.g., 6 | [here](https://www.isric.org/explore/soil-geographic-databases). 7 | 2. Automatic weather file generation from publicly available data. 8 | 3. Implementation of better weather generators. 9 | 4. More coming soon. -------------------------------------------------------------------------------- /documents/3.3_custom_spaces_and_rewards.md: -------------------------------------------------------------------------------- 1 | ## Custom states, actions, and rewards 2 | To specify custom states, actions, and rewards, it is necessary to specify the corresponding 3 | observer, implementer, and rewarder (see [cyclesgym's logic](3_logic.md)). It is possible to do so by 4 | creating completely new ones from scratch or combining existing ones (this is not yet possible for actions). 5 | We explain how to do this with an example in [this notebook](../notebooks/build_custom_environment.ipynb). -------------------------------------------------------------------------------- /cyclesgym/tests/DummyWeatherShuffle.weather: -------------------------------------------------------------------------------- 1 | LATITUDE 40.6875 2 | ALTITUDE 0.0 3 | SCREENING_HEIGHT 10.0 4 | YEAR DOY PP TX TN SOLAR RHX RHN WIND 5 | #### ### mm deg C deg C MJ/m2 % % m/s 6 | 1981 5 0.0 3.0 4.0 5.0 6.0 7.0 8.0 7 | 1981 6 0.0 0.0 0.0 0.0 0.0 0.0 1.5 8 | 1982 1 0.0 1.0 2.0 3.0 4.0 5.0 6.0 9 | 1982 2 0.0 0.0 0.0 0.0 0.0 0.0 1.5 10 | 1983 3 0.0 2.0 3.0 4.0 5.0 6.0 7.0 11 | 1983 4 0.0 0.0 0.0 0.0 0.0 0.0 1.5 -------------------------------------------------------------------------------- /cyclesgym/tests/DummyWeatherNonShuffle.weather: -------------------------------------------------------------------------------- 1 | LATITUDE 40.6875 2 | ALTITUDE 0.0 3 | SCREENING_HEIGHT 10.0 4 | YEAR DOY PP TX TN SOLAR RHX RHN WIND 5 | #### ### mm deg C deg C MJ/m2 % % m/s 6 | 1981 1 0.0 1.0 2.0 3.0 4.0 5.0 6.0 7 | 1981 2 0.0 0.0 0.0 0.0 0.0 0.0 1.5 8 | 1982 3 0.0 2.0 3.0 4.0 5.0 6.0 7.0 9 | 1982 4 0.0 0.0 0.0 0.0 0.0 0.0 1.5 10 | 1983 5 0.0 3.0 4.0 5.0 6.0 7.0 8.0 11 | 1983 6 0.0 0.0 0.0 0.0 0.0 0.0 1.5 -------------------------------------------------------------------------------- /cyclesgym/utils/paths.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | __all__ = ['PROJECT_PATH', 'CYCLES_PATH', 'AGENTS_PATH', 'FIGURES_PATH', 'DATA_PATH'] 4 | 5 | PROJECT_PATH = pathlib.Path(__file__).parents[2] 6 | CYCLES_PATH = PROJECT_PATH.joinpath('cycles') 7 | AGENTS_PATH = PROJECT_PATH.joinpath('agents') 8 | FIGURES_PATH = PROJECT_PATH.joinpath('figures') 9 | DATA_PATH = PROJECT_PATH.joinpath('data') 10 | TEST_PATH = PROJECT_PATH.joinpath('cyclesgym', 'tests') 11 | 12 | CYCLES_PATH.mkdir(exist_ok=True, parents=True) 13 | AGENTS_PATH.mkdir(exist_ok=True, parents=True) 14 | FIGURES_PATH.mkdir(exist_ok=True, parents=True) 15 | DATA_PATH.mkdir(exist_ok=True, parents=True) 16 | -------------------------------------------------------------------------------- /cyclesgym/tests/DummyOperation.operation: -------------------------------------------------------------------------------- 1 | ## Operation file to test base operation manager ## 2 | FIXED_FERTILIZATION 3 | YEAR 1 4 | DOY 106 5 | MASS 150.0 6 | LAYER 1.0 7 | N_NH4 0.75 8 | N_NO3 0.25 9 | 10 | 11 | PLANTING 12 | YEAR 1 13 | DOY 106 14 | CROP CornRM.90 15 | FRACTION 1.0 16 | 17 | TILLAGE 18 | YEAR 1 19 | DOY 106 20 | DEPTH 0.03 21 | SOIL_DISTURB_RATIO 5.0 22 | -------------------------------------------------------------------------------- /experiments/fertilization/expert.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions that output Fixed policies (action sequences) 3 | """ 4 | import numpy as np 5 | 6 | 7 | def create_action_sequence(doy, weight, maxN, n_actions, delta_t, n_weeks=53): 8 | doy = np.atleast_1d(doy) 9 | weight = np.atleast_1d(weight) 10 | assert len(doy) == len(weight) 11 | delta_a = maxN / (n_actions - 1) 12 | action_sequence = np.zeros(n_weeks, dtype=int) 13 | for d, w in zip(doy, weight): 14 | #puts planting at the start of the week from the date selected 15 | ind = np.floor(d / delta_t).astype(int) 16 | a = np.floor(w / delta_a).astype(int) 17 | action_sequence[ind] = a 18 | return action_sequence 19 | 20 | 21 | -------------------------------------------------------------------------------- /cyclesgym/tests/DummyN.dat: -------------------------------------------------------------------------------- 1 | DATE ORG SOIL N PROF SOIL NO3 PROF SOIL NH4 MINERALIZATION IMMOBILIZATION NET MINERALIZ NH4 NITRIFICAT N2O FROM NITRIF NH3 VOLATILIZ NO3 DENITRIF N2O FROM DENIT NO3 LEACHING NH4 LEACHING NO3 BYPASS NH4 BYPASS 2 | YYYY-MM-DD kg N/ha kg N/ha kg N/ha kg N/ha kg N/ha kg N/ha kg N/ha kg N/ha kg N/ha kg N/ha kg N/ha kg N/ha kg N/ha kg N/ha kg N/ha 3 | 1980-01-01 8873.886558 37.068387 8.968119 0.037371 0.000000 0.037371 0.068560 0.00000 0.00000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 -------------------------------------------------------------------------------- /cyclesgym/tests/CropPlanningTest.operation: -------------------------------------------------------------------------------- 1 | PLANTING 2 | YEAR 1 3 | DOY 90 4 | END_DOY 160 5 | MAX_SMC 1.0 6 | MIN_SMC 0.0 7 | MIN_SOIL_TEMP 0.0 8 | CROP CornSilageRM.90 9 | USE_AUTO_IRR 0 10 | USE_AUTO_FERT 0 11 | FRACTION 1.0 12 | CLIPPING_START 1 13 | CLIPPING_END 366 14 | 15 | PLANTING 16 | YEAR 2 17 | DOY 104 18 | END_DOY 160 19 | MAX_SMC 1.0 20 | MIN_SMC 0.0 21 | MIN_SOIL_TEMP 0.0 22 | CROP SoybeanMG.3 23 | USE_AUTO_IRR 0 24 | USE_AUTO_FERT 0 25 | FRACTION 1.0 26 | CLIPPING_START 1 27 | CLIPPING_END 366 -------------------------------------------------------------------------------- /documents/manual.md: -------------------------------------------------------------------------------- 1 | Welcome to the cyclesgym user manual. 2 | 3 | Cyclesgym is an [OpenAI gym](https://gym.openai.com/) environment that provides a reinforcement-learning-friendly interface to the [Cycles 4 | crop growth model](https://plantscience.psu.edu/research/labs/kemanian/models-and-tools/cycles). 5 | 6 | 1. [Installation](0_installation.md) 7 | 2. [Introduction or why Cycles?](1_introduction.md) 8 | 3. [Interfacing with Cycles](2_interface.md) 9 | 4. [Cyclesgym logic](3_logic.md) 10 | 1. [Predefined environments](3.1_predefined_envs.md) 11 | 2. [Custom weather, soil, and crops](3.2_custom_weather_and_soil.md) 12 | 3. [Custom observations, actions, and rewards](3.3_custom_spaces_and_rewards.md) 13 | 4. [Default operations](3.4_default_operations.md) 14 | 5. [Examples](4_examples.md) 15 | 6. [Experiments](5_experiments.md) 16 | 7. [Contributions](6_contributions.md) -------------------------------------------------------------------------------- /cyclesgym/tests/DummyControl.ctrl: -------------------------------------------------------------------------------- 1 | ## SIMULATION YEARS ## 2 | 3 | SIMULATION_START_YEAR 1980 4 | SIMULATION_END_YEAR 1980 5 | ROTATION_SIZE 1 6 | 7 | ## SIMULATION OPTIONS ## 8 | 9 | USE_REINITIALIZATION 0 10 | ADJUSTED_YIELDS 0 11 | HOURLY_INFILTRATION 1 12 | AUTOMATIC_NITROGEN 0 13 | AUTOMATIC_PHOSPHORUS 0 14 | AUTOMATIC_SULFUR 0 15 | DAILY_WEATHER_OUT 1 16 | DAILY_CROP_OUT 1 17 | DAILY_RESIDUE_OUT 1 18 | DAILY_WATER_OUT 1 19 | DAILY_NITROGEN_OUT 1 20 | DAILY_SOIL_CARBON_OUT 1 21 | DAILY_SOIL_LYR_CN_OUT 1 22 | ANNUAL_SOIL_OUT 1 23 | ANNUAL_PROFILE_OUT 1 24 | ANNUAL_NFLUX_OUT 1 25 | 26 | ## OTHER INPUT FILES ## 27 | 28 | CROP_FILE GenericCrops.crop 29 | OPERATION_FILE ContinuousCorn.operation 30 | SOIL_FILE GenericHagerstown.soil 31 | WEATHER_FILE RockSprings.weather 32 | REINIT_FILE N/A -------------------------------------------------------------------------------- /cyclesgym/tests/CornTestReinit.ctrl: -------------------------------------------------------------------------------- 1 | ## SIMULATION YEARS ## 2 | 3 | SIMULATION_START_YEAR 1980 4 | SIMULATION_END_YEAR 1982 5 | ROTATION_SIZE 1 6 | 7 | ## SIMULATION OPTIONS ## 8 | 9 | USE_REINITIALIZATION 0 10 | ADJUSTED_YIELDS 0 11 | HOURLY_INFILTRATION 1 12 | AUTOMATIC_NITROGEN 0 13 | AUTOMATIC_PHOSPHORUS 0 14 | AUTOMATIC_SULFUR 0 15 | DAILY_WEATHER_OUT 1 16 | DAILY_CROP_OUT 1 17 | DAILY_RESIDUE_OUT 1 18 | DAILY_WATER_OUT 1 19 | DAILY_NITROGEN_OUT 1 20 | DAILY_SOIL_CARBON_OUT 1 21 | DAILY_SOIL_LYR_CN_OUT 1 22 | ANNUAL_SOIL_OUT 1 23 | ANNUAL_PROFILE_OUT 1 24 | ANNUAL_NFLUX_OUT 1 25 | 26 | ## OTHER INPUT FILES ## 27 | 28 | CROP_FILE GenericCrops.crop 29 | OPERATION_FILE ContinuousCorn.operation 30 | SOIL_FILE GenericHagerstown.soil 31 | WEATHER_FILE RockSprings.weather 32 | REINIT_FILE N/A 33 | -------------------------------------------------------------------------------- /cyclesgym/tests/CropPlanningTest.ctrl: -------------------------------------------------------------------------------- 1 | ## SIMULATION YEARS ## 2 | 3 | SIMULATION_START_YEAR 1980 4 | SIMULATION_END_YEAR 1990 5 | ROTATION_SIZE 2 6 | 7 | ## SIMULATION OPTIONS ## 8 | 9 | USE_REINITIALIZATION 0 10 | ADJUSTED_YIELDS 0 11 | HOURLY_INFILTRATION 1 12 | AUTOMATIC_NITROGEN 0 13 | AUTOMATIC_PHOSPHORUS 0 14 | AUTOMATIC_SULFUR 0 15 | DAILY_WEATHER_OUT 0 16 | DAILY_CROP_OUT 1 17 | DAILY_RESIDUE_OUT 0 18 | DAILY_WATER_OUT 0 19 | DAILY_NITROGEN_OUT 0 20 | DAILY_SOIL_CARBON_OUT 0 21 | DAILY_SOIL_LYR_CN_OUT 0 22 | ANNUAL_SOIL_OUT 1 23 | ANNUAL_PROFILE_OUT 0 24 | ANNUAL_NFLUX_OUT 0 25 | 26 | ## OTHER INPUT FILES ## 27 | 28 | CROP_FILE GenericCrops.crop 29 | OPERATION_FILE CropPlanningTest.operation 30 | SOIL_FILE GenericHagerstown.soil 31 | WEATHER_FILE RockSprings.weather 32 | REINIT_FILE N/A 33 | -------------------------------------------------------------------------------- /cyclesgym/tests/NCornTestNoFertilization.operation: -------------------------------------------------------------------------------- 1 | ## Operation file to be used for simulation with gym env ## 2 | 3 | PLANTING 4 | YEAR 1 5 | DOY 106 6 | END_DOY -999 7 | MAX_SMC -999 8 | MIN_SMC 0.0 9 | MIN_SOIL_TEMP 0.0 10 | CROP CornRM.90 11 | USE_AUTO_IRR 0 12 | USE_AUTO_FERT 0 13 | FRACTION 1.0 14 | CLIPPING_START 1 15 | CLIPPING_END 366 16 | 17 | TILLAGE 18 | YEAR 1 19 | DOY 106 20 | TOOL Planter_double_disk_opnr 21 | DEPTH 0.03 22 | SOIL_DISTURB_RATIO 5 23 | MIXING_EFFICIENCY 0.071554 24 | CROP_NAME N/A 25 | FRAC_THERMAL_TIME 0.0 26 | KILL_EFFICIENCY 0.0 -------------------------------------------------------------------------------- /documents/0_installation.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | We recommend Python 3.8+ installation using [Anaconda](https://www.anaconda.com/products/individual#downloads). 4 | 5 | First, create and activate a virtual environment using Anaconda: 6 | 7 | ```bash 8 | conda create -yn cyclesgym python=3.8 9 | conda activate cyclesgym 10 | ``` 11 | 12 | Then, clone the repo and change working directory 13 | 14 | ```bash 15 | git clone https://gitlab.inf.ethz.ch/matteotu/cyclesgym.git 16 | cd cyclesgym 17 | ``` 18 | 19 | Subsequently, install the library according to your needs. 20 | If you only need the managers to manipulate cycles files, run: 21 | 22 | To install, run: 23 | 24 | ```bash 25 | pip install -e . 26 | ``` 27 | 28 | If you further want to use some basic libraries to train reinforcement learning agents of the cyclesgym environments use: 29 | ```bash 30 | pip install -e .SOLVERS 31 | ``` 32 | 33 | Or, if you are using zsh: 34 | ```bash 35 | pip install -e .\[SOLVERS\] 36 | ``` 37 | -------------------------------------------------------------------------------- /cyclesgym/tests/test_soil.operation: -------------------------------------------------------------------------------- 1 | ############################################################################## 2 | # A continuous corn rotation, fertilized with 150 kg/ha UAN, completely 3 | # no-till except for slight disturbance by planter disks 4 | ############################################################################## 5 | 6 | 7 | TILLAGE 8 | YEAR 1 9 | DOY 110 10 | TOOL Planter_double_disk_opnr 11 | DEPTH 0.03 12 | SOIL_DISTURB_RATIO 5 13 | MIXING_EFFICIENCY 0.071554 14 | CROP_NAME N/A 15 | FRAC_THERMAL_TIME 0.0 16 | KILL_EFFICIENCY 0.0 17 | 18 | PLANTING 19 | YEAR 1 20 | DOY 110 21 | END_DOY -999 22 | MAX_SMC -999 23 | MIN_SMC 0.0 24 | MIN_SOIL_TEMP 0.0 25 | CROP CornRM.90 26 | USE_AUTO_IRR 0 27 | USE_AUTO_FERT 0 28 | FRACTION 1.0 29 | CLIPPING_START 1 30 | CLIPPING_END 366 31 | -------------------------------------------------------------------------------- /cyclesgym/tests/NCornTest.ctrl: -------------------------------------------------------------------------------- 1 | ## Control file to be used for direct simulation with cycles with no gym env ## 2 | 3 | ## SIMULATION YEARS ## 4 | 5 | SIMULATION_START_YEAR 1980 6 | SIMULATION_END_YEAR 1980 7 | ROTATION_SIZE 1 8 | 9 | ## SIMULATION OPTIONS ## 10 | 11 | USE_REINITIALIZATION 0 12 | ADJUSTED_YIELDS 0 13 | HOURLY_INFILTRATION 1 14 | AUTOMATIC_NITROGEN 0 15 | AUTOMATIC_PHOSPHORUS 0 16 | AUTOMATIC_SULFUR 0 17 | DAILY_WEATHER_OUT 1 18 | DAILY_CROP_OUT 1 19 | DAILY_RESIDUE_OUT 1 20 | DAILY_WATER_OUT 1 21 | DAILY_NITROGEN_OUT 1 22 | DAILY_SOIL_CARBON_OUT 1 23 | DAILY_SOIL_LYR_CN_OUT 1 24 | ANNUAL_SOIL_OUT 1 25 | ANNUAL_PROFILE_OUT 1 26 | ANNUAL_NFLUX_OUT 1 27 | 28 | ## OTHER INPUT FILES ## 29 | 30 | CROP_FILE GenericCrops.crop 31 | OPERATION_FILE NCornTest.operation 32 | SOIL_FILE GenericHagerstown.soil 33 | WEATHER_FILE RockSprings.weather 34 | REINIT_FILE N/A -------------------------------------------------------------------------------- /cyclesgym/tests/CornRandomWeatherTest.ctrl: -------------------------------------------------------------------------------- 1 | ## Control file to be used for direct simulation with cycles with no gym env ## 2 | 3 | ## SIMULATION YEARS ## 4 | 5 | SIMULATION_START_YEAR 1980 6 | SIMULATION_END_YEAR 1982 7 | ROTATION_SIZE 1 8 | 9 | ## SIMULATION OPTIONS ## 10 | 11 | USE_REINITIALIZATION 0 12 | ADJUSTED_YIELDS 0 13 | HOURLY_INFILTRATION 1 14 | AUTOMATIC_NITROGEN 0 15 | AUTOMATIC_PHOSPHORUS 0 16 | AUTOMATIC_SULFUR 0 17 | DAILY_WEATHER_OUT 1 18 | DAILY_CROP_OUT 1 19 | DAILY_RESIDUE_OUT 1 20 | DAILY_WATER_OUT 1 21 | DAILY_NITROGEN_OUT 1 22 | DAILY_SOIL_CARBON_OUT 1 23 | DAILY_SOIL_LYR_CN_OUT 1 24 | ANNUAL_SOIL_OUT 1 25 | ANNUAL_PROFILE_OUT 1 26 | ANNUAL_NFLUX_OUT 1 27 | 28 | ## OTHER INPUT FILES ## 29 | 30 | CROP_FILE GenericCrops.crop 31 | OPERATION_FILE NCornTest.operation 32 | SOIL_FILE GenericHagerstown.soil 33 | WEATHER_FILE weather0.weather 34 | REINIT_FILE N/A -------------------------------------------------------------------------------- /cyclesgym/tests/NCornTestNoFertilization.ctrl: -------------------------------------------------------------------------------- 1 | ## Control file to be used for simulation with gym env ## 2 | 3 | ## SIMULATION YEARS ## 4 | 5 | SIMULATION_START_YEAR 1980 6 | SIMULATION_END_YEAR 1980 7 | ROTATION_SIZE 1 8 | 9 | ## SIMULATION OPTIONS ## 10 | 11 | USE_REINITIALIZATION 0 12 | ADJUSTED_YIELDS 0 13 | HOURLY_INFILTRATION 1 14 | AUTOMATIC_NITROGEN 0 15 | AUTOMATIC_PHOSPHORUS 0 16 | AUTOMATIC_SULFUR 0 17 | DAILY_WEATHER_OUT 1 18 | DAILY_CROP_OUT 1 19 | DAILY_RESIDUE_OUT 1 20 | DAILY_WATER_OUT 1 21 | DAILY_NITROGEN_OUT 1 22 | DAILY_SOIL_CARBON_OUT 1 23 | DAILY_SOIL_LYR_CN_OUT 1 24 | ANNUAL_SOIL_OUT 1 25 | ANNUAL_PROFILE_OUT 1 26 | ANNUAL_NFLUX_OUT 1 27 | 28 | ## OTHER INPUT FILES ## 29 | 30 | CROP_FILE GenericCrops.crop 31 | OPERATION_FILE NCornTestNoFertilization.operation 32 | SOIL_FILE GenericHagerstown.soil 33 | WEATHER_FILE RockSprings.weather 34 | REINIT_FILE N/A -------------------------------------------------------------------------------- /cyclesgym/tests/DummyCrop.dat: -------------------------------------------------------------------------------- 1 | DATE CROP STAGE THERMAL TIME CUM. BIOMASS AG BIOMASS ROOT BIOMASS FRAC INTERCEP TOTAL N AG N ROOT N AG N CONCN N FIXATION N ADDED N STRESS WATER STRESS POTENTIAL TR 2 | #YYYY-MM-DD - - C-day Mg/ha Mg/ha Mg/ha - kg/ha kg/ha kg/ha g/kg kg/ha kg/ha % % mm/day 3 | 1980-01-01 FALLOW N/A 0.0 1.0 2.0 3.0 4.0 5.0 6.0 7.0 8.0 9.0 10.0 11.0 12.0 13.0 4 | 1980-01-02 FALLOW N/A 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.5 5 | -------------------------------------------------------------------------------- /cyclesgym/tests/DummySeason.dat: -------------------------------------------------------------------------------- 1 | DATE CROP PLANT_DATE TOTAL BIOMASS ROOT BIOMASS GRAIN YIELD FORAGE YIELD AG RESIDUE HARVEST INDEX POTENTIAL TR ACTUAL TR SOIL EVAP IRRIGATION TOTAL N ROOT N GRAIN N FORAGE N CUM. N STRESS N IN HARVEST N IN RESIDUE N CONCN FORAGE N FIXATION N ADDED 2 | #YYYY-MM-DD - YYYY-MM-DD Mg/ha Mg/ha Mg/ha Mg/ha Mg/ha Mg/Mg mm mm mm mm Mg/ha Mg/ha Mg/ha Mg/ha - kg/ha kg/ha % kg/ha kg/ha 3 | 1980-09-08 CornRM.90 1980-04-19 01.000000 2.000000 03.000000 4.000000 05.000000 6.000000 007.000000 008.000000 009.000000 10.00000 11.00000 12.00000 13.00000 14.00000 15.00000 16.00000 17.00000 18.00000 19.00000 20.00000 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cyclesgym 2 | 3 | This repository contains an [OpenAI gym](https://gym.openai.com/) interface to the [Cycles 4 | crop growth simulator](https://plantscience.psu.edu/research/labs/kemanian/models-and-tools/cycles). 5 | 6 | For more information about cyclesgym, see our [user manual](documents/manual.md). 7 | 8 | ## Installation 9 | 10 | We recommend Python 3.8+ installation using [Anaconda](https://www.anaconda.com/products/individual#downloads). 11 | 12 | First, create and activate a virtual environment using Anaconda: 13 | 14 | ```bash 15 | conda create -yn cyclesgym python=3.8 16 | conda activate cyclesgym 17 | ``` 18 | 19 | Then, clone the repo and change working directory 20 | 21 | ```bash 22 | git clone https://github.com/kora-labs/cyclesgym.git 23 | cd cyclesgym 24 | ``` 25 | 26 | Subsequently, install the library according to your needs. 27 | To install, run: 28 | 29 | ```bash 30 | pip install -e . 31 | ``` 32 | 33 | If you further want to use some basic libraries to train reinforcement learning agents of the cyclesgym environments use: 34 | ```bash 35 | pip install -e .SOLVERS 36 | ``` 37 | 38 | Or, if you are using zsh: 39 | ```bash 40 | pip install -e .\[SOLVERS\] 41 | ``` 42 | 43 | -------------------------------------------------------------------------------- /cyclesgym/tests/GenericHagerstownModified.soil: -------------------------------------------------------------------------------- 1 | CURVE_NUMBER 75 2 | SLOPE 0 3 | TOTAL_LAYERS 9 4 | LAYER THICK CLAY SAND ORGANIC BD FC PWP SON NO3 NH4 BYP_H BYP_V 5 | # m % % % Mg/m3 m3/m3 m3/m3 kg/ha kg/ha kg/ha - - 6 | 1 0.05 21 15 3 -999 -999 -999 0 0 0 0.0 0.00 7 | 2 0.05 21 15 3 -999 -999 -999 0 0 0 0.0 0.00 8 | 3 0.10 21 15 3 -999 -999 -999 0 0 0 0.0 0.00 9 | 4 0.20 37 17 0.47 -999 -999 -999 0 0 0 0.0 0.00 10 | 5 0.20 37 17 0.47 -999 -999 -999 0 0 0 0.0 0.00 11 | 6 0.20 55 4 0.3 -999 -999 -999 0 0 0 0.0 0.00 12 | 7 0.20 55 4 0.3 -999 -999 -999 0 0 0 0.0 0.00 13 | 8 0.20 55 4 0.3 -999 -999 -999 0 0 0 0.0 0.00 14 | 9 0.20 55 4 0.3 -999 -999 -999 0 0 0 0.0 0.00 15 | -------------------------------------------------------------------------------- /cyclesgym/tests/test_rewarders.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | 4 | from cyclesgym.envs.rewarders import CropRewarder 5 | from cyclesgym.utils.pricing_utils import crop_prices 6 | from cyclesgym.managers import * 7 | 8 | from cyclesgym.utils.paths import TEST_PATH 9 | 10 | 11 | class TestRewarders(unittest.TestCase): 12 | def setUp(self) -> None: 13 | # Init managers 14 | self.season_manager = SeasonManager( 15 | TEST_PATH.joinpath('DummySeason.dat')) 16 | 17 | # Init observers 18 | self.corn_rewarder = CropRewarder( 19 | season_manager=self.season_manager, 20 | crop_name='CornRM.90' 21 | ) 22 | 23 | def test_crop_r(self): 24 | # Actual harvest date: 1980-09-08 25 | 26 | # Test during harvest 27 | date = datetime.date(year=1980, month=9, day=12) 28 | r = self.corn_rewarder.compute_reward(date=date, delta=5) 29 | target_r = 3 * crop_prices['CornRM.90'][1980] 30 | assert r == target_r 31 | 32 | # Test out of harvest 33 | r = self.corn_rewarder.compute_reward(date=date, delta=3) 34 | assert r == 0 35 | 36 | 37 | if __name__ == '__main__': 38 | unittest.main() -------------------------------------------------------------------------------- /cyclesgym/tests/GenericHagerstown.soil: -------------------------------------------------------------------------------- 1 | CURVE_NUMBER 75 2 | SLOPE 0 3 | TOTAL_LAYERS 9 4 | LAYER THICK CLAY SAND ORGANIC BD FC PWP SON NO3 NH4 BYP_H BYP_V 5 | # m % % % Mg/m3 m3/m3 m3/m3 kg/ha kg/ha kg/ha - - 6 | 1 0.05 21 15 3 -999 -999 -999 -999 10 1 0.0 0.00 7 | 2 0.05 21 15 3 -999 -999 -999 -999 10 1 0.0 0.00 8 | 3 0.10 21 15 3 -999 -999 -999 -999 7 1 0.0 0.00 9 | 4 0.20 37 17 0.47 -999 -999 -999 -999 4 1 0.0 0.00 10 | 5 0.20 37 17 0.47 -999 -999 -999 -999 2 1 0.0 0.00 11 | 6 0.20 55 4 0.3 -999 -999 -999 -999 1 1 0.0 0.00 12 | 7 0.20 55 4 0.3 -999 -999 -999 -999 1 1 0.0 0.00 13 | 8 0.20 55 4 0.3 -999 -999 -999 -999 1 1 0.0 0.00 14 | 9 0.20 55 4 0.3 -999 -999 -999 -999 1 1 0.0 0.00 15 | -------------------------------------------------------------------------------- /cyclesgym/managers/season.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | 3 | from cyclesgym.managers.common import Manager 4 | from cyclesgym.managers.utils import date_to_ydoy 5 | 6 | 7 | __all__ = ['SeasonManager'] 8 | 9 | 10 | class SeasonManager(Manager): 11 | def __init__(self, fname=None): 12 | self.season_df = pd.DataFrame() 13 | super().__init__(fname) 14 | 15 | def _parse(self, fname): 16 | if fname is not None: 17 | with open(fname, 'r') as f: 18 | value = [] 19 | for i, l in enumerate(f.readlines()): 20 | if i == 0: 21 | columns = [n.strip(' \n') for n in l.split('\t')] 22 | elif i >= 2: 23 | value.append([float(v) if v.replace('.', '', 1).isdigit() 24 | else v for v in l.split()]) 25 | self.season_df = pd.DataFrame(data=value, index=None, columns=columns) 26 | date_to_ydoy(self.season_df, old_col_name='DATE', 27 | new_col_names=['YEAR', 'DOY'], inplace=True) 28 | date_to_ydoy(self.season_df, old_col_name='PLANT_DATE', 29 | new_col_names=['PLANT_YEAR', 'PLANT_DOY'], 30 | inplace=True) -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | from setuptools.command.install import install 3 | 4 | install_requires = [ 5 | 'requests', 6 | 'numpy', 7 | 'pandas', 8 | 'matplotlib', 9 | 'gym', 10 | 'ipykernel', 11 | 'pyglet', 12 | ] 13 | 14 | solve_requires = [ 15 | 'torch >= 1.8.1+cpu', 16 | 'stable-baselines3', 17 | 'tensorboard', 18 | 'wandb', 19 | ] 20 | 21 | 22 | class NewInstall(install): 23 | """Post-installation for installation mode.""" 24 | def __init__(self, *args, **kwargs): 25 | super(NewInstall, self).__init__(*args, **kwargs) 26 | print('POST INSTALL') 27 | from install_cycles import install_cycles 28 | 29 | install_cycles() 30 | 31 | 32 | setuptools.setup( 33 | name='cyclesgym', 34 | version='0.1.0', 35 | description='Open AI gym interface to the cycles crop simulator', 36 | url='https://github.com/koralabs/cyclesgym', 37 | author='Matteo Turchetta', 38 | author_email='matteo.turchetta@inf.ethz.ch', 39 | keywords='Crop growth simulator', 40 | packages=setuptools.find_packages(), 41 | python_requires=">=3.8", 42 | include_package_data=True, 43 | install_requires=install_requires, 44 | extras_require={ 45 | "SOLVERS": solve_requires 46 | }, 47 | cmdclass={'install': NewInstall}, 48 | ) 49 | 50 | -------------------------------------------------------------------------------- /cyclesgym/tests/test_soil_reinit.soil: -------------------------------------------------------------------------------- 1 | CURVE_NUMBER 75 2 | SLOPE 0 3 | TOTAL_LAYERS 9 4 | LAYER THICK CLAY SAND ORGANIC BD FC PWP SON NO3 NH4 BYP_H BYP_V 5 | # m % % % Mg/m3 m3/m3 m3/m3 kg/ha kg/ha kg/ha - - 6 | 1 0.05 21 15 3 -999 -999 -999 1.255521 8.8241E-08 1.7118E-05 0.0 0.00 7 | 2 0.05 21 15 3 -999 -999 -999 1.255496 1.7556E-04 6.0092E-04 0.0 0.00 8 | 3 0.10 21 15 3 -999 -999 -999 2.511524 3.1946E-03 1.9638E-03 0.0 0.00 9 | 4 0.20 37 17 0.5 -999 -999 -999 0.852908 3.9557E-03 2.7902E-04 0.0 0.00 10 | 5 0.20 37 17 0.5 -999 -999 -999 0.852888 1.3962E-03 3.7705E-05 0.0 0.00 11 | 6 0.20 55 4 0.3 -999 -999 -999 0.467462 1.0471E-04 2.5713E-06 0.0 0.00 12 | 7 0.20 55 4 0.3 -999 -999 -999 0.467460 2.5753E-10 4.9129E-08 0.0 0.00 13 | 8 0.20 55 4 0.3 -999 -999 -999 0.467460 1.5470E-05 3.8052E-06 0.0 0.00 14 | 9 0.20 55 4 0.3 -999 -999 -999 0.467460 2.0718E-04 2.2633E-05 0.0 0.00 15 | -------------------------------------------------------------------------------- /cyclesgym/managers/utils.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from datetime import datetime as dt 3 | 4 | 5 | __all__ = ['date_to_ydoy', 'ydoy_to_date', 'num_lines'] 6 | 7 | 8 | def date_to_ydoy(df, old_col_name='DATE', new_col_names=['YEAR', 'DOY'], inplace=False): 9 | new_df = df.copy(deep=True) if not inplace else df 10 | position = new_df.columns.get_loc(old_col_name) 11 | new_df.insert(position, new_col_names[1], pd.to_datetime(new_df[old_col_name]).dt.dayofyear) 12 | new_df.insert(position, new_col_names[0], pd.to_datetime(new_df[old_col_name]).dt.year) 13 | new_df.drop(columns=old_col_name, inplace=True) 14 | return new_df 15 | # # position = new.columns.get_loc(old_col_name) 16 | 17 | 18 | def ydoy_to_date(df, old_col_names=['YEAR', 'DOY'], new_col_name='DATE', inplace=False): 19 | dates = [dt.strftime(dt.strptime(f'{year}-{doy}', '%Y-%j'), format='%Y-%m-%d') for year, doy in zip(df[old_col_names[0]], df[old_col_names[1]])] 20 | new_df = df.copy(deep=True) if not inplace else df 21 | position = new_df.columns.get_loc(old_col_names[0]) 22 | new_df.insert(position, new_col_name, dates) 23 | new_df.drop(columns=old_col_names, inplace=True) 24 | return new_df 25 | 26 | 27 | def num_lines(file): 28 | """ 29 | Count number of lines in a file. 30 | Parameters 31 | ---------- 32 | file: str of pathlib.Path 33 | """ 34 | with open(file, 'r') as f: 35 | for i, l in enumerate(f, 1): 36 | pass 37 | return i 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, The koralabs Authors 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /documents/5_experiments.md: -------------------------------------------------------------------------------- 1 | Here, we give an overview of how to reproduce the experiments given in the paper. All experiments are tracked using 2 | the wandb library for experiment tracking and reproducibility (http://wandb.ai) that is installed together with the 3 | cyclesgym package. Before running the expetiments, please create a profile in http://wandb.ai, then logging into wandb 4 | from the terminal as 5 | 6 | `$ wandb login` 7 | 8 | and follow the instructions in the terminal. 9 | 10 | ### N fertilization environments 11 | 12 | It is sufficient to run the script available in the `experiments/fertilization` folder, named `fertilization_experiment.sh`. 13 | ``` 14 | $ ./experiments/fertilization/fertilization_experiment.sh 15 | ``` 16 | The script will lunch the `experiments/fertilization/train.py` script with fixed and random weather generation, and with 17 | adaptive and non-adaptive policies .... (TODO: and something else?), with 5 fixed seeds. 18 | 19 | ### Crop planning environments 20 | 21 | It is sufficient to run the script available in the `experiments/crop_planning` folder, named `crop_planning_experiment.sh`. 22 | ``` 23 | $ ./experiments/crop_planning/crop_planning_experiment.sh 24 | ``` 25 | The script will lunch the `experiments/crop_planning/train.py` script with fixed and random weather generation, and with 26 | adaptive and non-adaptive policies, with 5 fixed seeds. 27 | 28 | ### Note 29 | In both cases above, the experiments are run in series. We advice to run the different experiments as parallel jobs in a 30 | computation cluster. For a discussion on the computational resources needed to reproduce the experiments, see the appendix 31 | of the paper. -------------------------------------------------------------------------------- /cyclesgym/utils/plot_utils.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | 3 | 4 | NEURIPS_TEXT_WIDTH = 397.48499 5 | NEURIPS_FONT_FAMILY = "Times New Roman" 6 | 7 | 8 | def set_up_plt(font_family): 9 | 10 | plt.rcParams['text.usetex'] = True 11 | 12 | SMALL_SIZE = 6 13 | MEDIUM_SIZE = 8 14 | BIGGER_SIZE = 10 15 | 16 | plt.rc('font', size=MEDIUM_SIZE) # controls default text sizes 17 | plt.rc('axes', titlesize=SMALL_SIZE) # fontsize of the axes title 18 | plt.rc('axes', labelsize=MEDIUM_SIZE) # fontsize of the x and y labels 19 | plt.rc('xtick', labelsize=SMALL_SIZE) # fontsize of the tick labels 20 | plt.rc('ytick', labelsize=SMALL_SIZE) # fontsize of the tick labels 21 | plt.rc('legend', fontsize=SMALL_SIZE) # legend fontsize 22 | plt.rc('figure', titlesize=BIGGER_SIZE) # fontsize of the figure title 23 | plt.rcParams["font.family"] = font_family 24 | 25 | 26 | def set_size(width, fraction=1, subplots=(1, 1)): 27 | if width == 'thesis': 28 | width_pt = 426.79135 29 | elif width == 'beamer': 30 | width_pt = 307.28987 31 | else: 32 | width_pt = width 33 | 34 | # Width of figure (in pts) 35 | fig_width_pt = width_pt * fraction 36 | # Convert from pt to inches 37 | inches_per_pt = 1 / 72.27 38 | 39 | # Golden ratio to set aesthetic figure height 40 | # https://disq.us/p/2940ij3 41 | golden_ratio = (5**.5 - 1) / 2 42 | 43 | # Figure width in inches 44 | fig_width_in = fig_width_pt * inches_per_pt 45 | # Figure height in inches 46 | fig_height_in = fig_width_in * golden_ratio * (subplots[0] / subplots[1]) 47 | 48 | return (fig_width_in, fig_height_in) -------------------------------------------------------------------------------- /cyclesgym/tests/NCornTest.operation: -------------------------------------------------------------------------------- 1 | ## Operation file to be used for direct simulation with cycles with no gym env ## 2 | 3 | FIXED_FERTILIZATION 4 | YEAR 1 5 | DOY 106 6 | SOURCE UreaAmmoniumNitrate 7 | MASS 150 8 | FORM Liquid 9 | METHOD Broadcast 10 | LAYER 1 11 | C_Organic 0 12 | C_Charcoal 0 13 | N_Organic 0 14 | N_Charcoal 0 15 | N_NH4 0.75 16 | N_NO3 0.25 17 | P_Organic 0 18 | P_CHARCOAL 0 19 | P_INORGANIC 0 20 | K 0 21 | S 0 22 | 23 | PLANTING 24 | YEAR 1 25 | DOY 106 26 | END_DOY -999 27 | MAX_SMC -999 28 | MIN_SMC 0.0 29 | MIN_SOIL_TEMP 0.0 30 | CROP CornRM.90 31 | USE_AUTO_IRR 0 32 | USE_AUTO_FERT 0 33 | FRACTION 1.0 34 | CLIPPING_START 1 35 | CLIPPING_END 366 36 | 37 | TILLAGE 38 | YEAR 1 39 | DOY 106 40 | TOOL Planter_double_disk_opnr 41 | DEPTH 0.03 42 | SOIL_DISTURB_RATIO 5 43 | MIXING_EFFICIENCY 0.071554 44 | CROP_NAME N/A 45 | FRAC_THERMAL_TIME 0.0 46 | KILL_EFFICIENCY 0.0 -------------------------------------------------------------------------------- /documents/2_interface.md: -------------------------------------------------------------------------------- 1 | ## Interface 2 | Cycles simulations are fully specified via a set of configuration files (see 3 | [Cycles documentation](https://psumodeling.github.io/Cycles/)). 4 | The most imporant files are the following: 5 | 6 | 1. Control: It specifies the generic parameters of the simulation, e.g., the 7 | duration, the outputs to write, the weather, soil, crop, and management files 8 | to use. 9 | 2. Weather: It specifies the weather in daily steps. It contains information 10 | regarding temperature, humidity, radiation, wind, precipitation, wind, and 11 | geographical location of the weather station. 12 | 3. Soil: It contains several parameters characterizing each layer of the soil 13 | at the start of the simulation. 14 | 4. Crop: It contains the physiological parameters of the crops that will be 15 | simulated. 16 | 5. Operation: It contains all the management practices that will be adopted 17 | during the simulation, including tillage, irrigation, planting, and 18 | fertilization. Each one of these macro operations is specified by several 19 | parameters. 20 | 21 | Similarly, Cycles returns the results of its simulation via a set of files 22 | including those describing the soil, water, and crop status daily. 23 | 24 | To interact with Cycles, cyclesgym uses a set of managers that are implemented 25 | in `cyclesgym/managers`. Each type of file has a dedicated manager. Each 26 | manager can parse the corresponding file type and load it into an appropriate 27 | data structure (dictionary or pandas.Dataframe). The managers of input 28 | files can also write to file. This way, it is possible to create new weather 29 | conditions, soils, crops, and management files from Python. 30 | 31 | -------------------------------------------------------------------------------- /cyclesgym/managers/common.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from abc import ABC, abstractmethod 3 | 4 | class Manager(ABC): 5 | def __init__(self, fname=None): 6 | if not self._valid_input_file(fname): 7 | raise ValueError('File not existing. To initialize manager without a file, pass None as input') 8 | self._parse(fname) 9 | 10 | @staticmethod 11 | def _valid_input_file(fname): 12 | if fname is not None: 13 | return fname.is_file() 14 | else: 15 | return True 16 | 17 | @abstractmethod 18 | def _parse(self, fname): 19 | raise NotImplementedError 20 | 21 | def update_file(self, fname): 22 | # self.fname = fname 23 | # if not self._valid_input_file(self.fname): 24 | # raise ValueError(f'{self.fname} File not existing. To initialize manager without a file, pass None as input') 25 | # self._parse() 26 | self.__init__(fname) 27 | 28 | 29 | class InputFileManager(Manager): 30 | 31 | @staticmethod 32 | def _valid_output_file(fname): 33 | return fname is not None 34 | 35 | @abstractmethod 36 | def _to_str(self): 37 | raise NotImplementedError 38 | 39 | def __str__(self): 40 | return self._to_str() 41 | 42 | def save(self, fname, force=True): 43 | if not force and fname.is_file(): 44 | raise RuntimeError(f'The file {fname} already exists. Use force=True if you want to overwrite it') 45 | if not self._valid_output_file(fname): 46 | raise ValueError(f'{fname} is not a valid path to save the file') 47 | else: 48 | s = self._to_str() 49 | with open(fname, 'w') as fp: 50 | fp.write(s) 51 | -------------------------------------------------------------------------------- /cyclesgym/tests/test_soil.reinit: -------------------------------------------------------------------------------- 1 | YEAR 1980 DOY 100 2 | STANRESIDUEC FLATRESIDUEC STANRESIDUEN FLATRESIDUEN MANURESURFACEC MANURESURFACEN STANRESIDUEWATER FLATRESIDUEWATER INFILTRATION 3 | 0.000003 0.000003 0.000000 0.000000 0.000000 0.000000 0.000001 0.000002 -999 4 | LAYER SMC NO3 NH4 SOC SON MBC MBN RESABGDC RESRTC RESRZC RESIDUEABGDN RESIDUERTN RESIDUERZN MANUREC MANUREN SATURATION 5 | 1 0.335028 4.3874E-04 9.3587E-04 11.299615 1.255521 0.308354 0.034462 0.000000 0.000000 0.041347 0.000000 0.000000 7.7001E-04 0.000000 0.000000 -999 6 | 2 0.335028 1.1892E-03 1.4554E-03 11.299394 1.255496 0.306674 0.034284 0.000000 0.000000 0.039182 0.000000 0.000000 7.3730E-04 0.000000 0.000000 -999 7 | 3 0.337605 3.3749E-03 1.5230E-03 22.603599 2.511524 0.608912 0.068030 0.000000 0.000000 0.077646 0.000000 0.000000 1.4700E-03 0.000000 0.000000 -999 8 | 4 0.242069 1.6037E-03 1.9189E-04 7.676174 0.852908 0.250295 0.027863 0.000000 0.000000 0.117982 0.000000 0.000000 2.2567E-03 0.000000 0.000000 -999 9 | 5 0.250821 1.2528E-03 1.7052E-04 7.675991 0.852888 0.241533 0.026862 0.000000 0.000000 0.053118 0.000000 0.000000 9.9901E-04 0.000000 0.000000 -999 10 | 6 0.369866 1.0364E-03 1.3313E-04 4.207154 0.467462 0.133312 0.014820 0.000000 0.000000 0.027593 0.000000 0.000000 5.1467E-04 0.000000 0.000000 -999 11 | 7 0.401171 1.0168E-03 1.3916E-04 4.207141 0.467460 0.129070 0.014344 0.000000 0.000000 0.014732 0.000000 0.000000 2.7158E-04 0.000000 0.000000 -999 12 | 8 0.421148 9.8035E-04 1.4028E-04 4.207138 0.467460 0.127185 0.014132 0.000000 0.000000 7.5920E-03 0.000000 0.000000 1.3566E-04 0.000000 0.000000 -999 13 | 9 0.431419 9.7525E-04 1.4284E-04 4.207137 0.467460 0.126464 0.014052 0.000000 0.000000 3.8163E-03 0.000000 0.000000 6.6268E-05 0.000000 0.000000 -999 -------------------------------------------------------------------------------- /cyclesgym/utils/pricing_utils.py: -------------------------------------------------------------------------------- 1 | # Conversion rate for corn from bushel to metric ton from 2 | # https://grains.org/markets-tools-data/tools/converting-grain-units/ 3 | CORN_BUSHEL_PER_TONNE = 39.3680 4 | SOYBEAN_BUSHEL_PER_TONNE = 36.7437 5 | 6 | # Avg anhydrous ammonia cost in 2020 from 7 | # https://farmdocdaily.illinois.edu/2021/08/2021-fertilizer-price-increases-in-perspective-with-implications-for-2022-costs.html 8 | # Computed as 496 * 0.001 ($/ton * ton/kg) 9 | N_price_dollars_per_kg = {y: 496 * 0.001 for y in range(1980, 2020)} 10 | 11 | # Avg US price of corn for 2020 from 12 | # https://quickstats.nass.usda.gov/results/BA8CCB81-A2BB-3C5C-BD23-DBAC365C7832 13 | corn_price_dollars_per_bushel = {y: 4.53 for y in range(1980, 2020)} 14 | corn_price_dollars_per_tonne = {y: CORN_BUSHEL_PER_TONNE * corn_price_dollars_per_bushel[y] 15 | for y in corn_price_dollars_per_bushel.keys()} 16 | 17 | # US price of corn silage (forage) in 1970 (not available more recently) 18 | # https://quickstats.nass.usda.gov/results/6C3AADDF-25D2-31E7-9E0A-F050F345F91D 19 | # https://beef.unl.edu/beefwatch/2020/corn-crop-worth-more-silage-or-grain#:~:text=Corn%20Silage%20Packed%20in%20the%20Silo&text=The%202020%20Nebraska%20Farm%20Custom,price%20per%20ton%20is%20%2434.95. 20 | corn_silage_price_dollars_per_tonne = {y: 10 for y in range(1980, 2020)} 21 | 22 | # Avg US price of soybean for 2020 from 23 | # https://quickstats.nass.usda.gov/results/1A09097A-EFA4-3C47-B1D4-E7ACDFAA2575 24 | soy_beans_price_dollars_per_bushel = {y: 9.89 for y in range(1980, 2020)} 25 | soy_beans_price_dollars_per_tonne = {y: SOYBEAN_BUSHEL_PER_TONNE * soy_beans_price_dollars_per_bushel[y] 26 | for y in soy_beans_price_dollars_per_bushel.keys()} 27 | 28 | #crop prices in $ per tonne 29 | crop_prices = {'CornRM.90': corn_price_dollars_per_tonne, 30 | 'CornRM.100': corn_price_dollars_per_tonne, 31 | 'SoybeanMG.5': soy_beans_price_dollars_per_tonne, 32 | 'SoybeanMG.3': soy_beans_price_dollars_per_tonne, 33 | 'CornSilageRM.90': corn_silage_price_dollars_per_tonne 34 | } 35 | 36 | crop_type = {'CornRM.90': 'GRAIN YIELD', 37 | 'CornRM.100': 'GRAIN YIELD', 38 | 'CornSilageRM.90': 'FORAGE YIELD', 39 | 'SoybeanMG.5': 'GRAIN YIELD', 40 | 'SoybeanMG.3': 'GRAIN YIELD' 41 | } -------------------------------------------------------------------------------- /install_cycles.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from sys import platform 3 | import pathlib 4 | import zipfile 5 | from cyclesgym.utils.paths import CYCLES_PATH 6 | import stat 7 | import subprocess 8 | 9 | 10 | def test_cycles_installation(): 11 | if pathlib.Path.joinpath(CYCLES_PATH, pathlib.Path('Cycles')).is_file(): 12 | proc = subprocess.run(['./Cycles', '-b', 'ContinuousCorn'], cwd=CYCLES_PATH) 13 | out = pathlib.Path.joinpath(CYCLES_PATH, pathlib.Path('output/ContinuousCorn')) 14 | files = [x for x in out.iterdir() if x.is_file()] 15 | if len(files) >= 5 and proc.returncode == 0: 16 | print('Cycles installed correctly') 17 | return True 18 | 19 | print('Cycles not installed properly') 20 | return False 21 | 22 | 23 | def install_cycles(): 24 | # Skip if already exists 25 | install_dir = pathlib.Path.cwd().joinpath('cycles') 26 | cycles_installed = False 27 | if install_dir.is_dir(): 28 | cycles_installed = test_cycles_installation() 29 | if cycles_installed: 30 | return 31 | 32 | if not cycles_installed: 33 | print('Installing cycles..') 34 | # Define file name 35 | if platform == "linux" or platform == "linux2": 36 | fname = 'Cycles_debian_0.12.9-alpha.zip' 37 | elif platform == "darwin": 38 | fname = 'Cycles_macos_0.12.9-alpha.zip' 39 | elif platform == "win32": 40 | fname = 'Cycles_win_0.12.9-alpha.zip' 41 | else: 42 | print('Installation aborted\nOperating system not recognized') 43 | return 44 | 45 | if not pathlib.Path.cwd().joinpath(fname).is_file(): 46 | # Get file 47 | baseurl = 'https://github.com/PSUmodeling/Cycles/releases/download/v0.12.9-alpha/' 48 | url = baseurl + fname 49 | r = requests.get(url, allow_redirects=True) 50 | open(fname, 'wb').write(r.content) 51 | 52 | # Unzip 53 | with zipfile.ZipFile(pathlib.Path.cwd().joinpath(fname), 'r') as zip_ref: 54 | install_dir.mkdir(exist_ok=True) 55 | zip_ref.extractall(install_dir) 56 | 57 | # Remove zip 58 | pathlib.Path.cwd().joinpath(fname).unlink() 59 | CYCLES_PATH.joinpath('Cycles').chmod(stat.S_IEXEC) 60 | 61 | test_cycles_installation() 62 | 63 | 64 | if __name__ == '__main__': 65 | install_cycles() 66 | -------------------------------------------------------------------------------- /documents/3.1_predefined_envs.md: -------------------------------------------------------------------------------- 1 | Here, we give an overview of the pre-registered environments and their naming convention 2 | 3 | ### N fertilization environments 4 | 5 | The fertilization environments differ along the following dimensions: weather generation, location, duration. 6 | All these environments are readily available and can be created with 7 | ``` 8 | import gym 9 | gym.make(id=env_id) 10 | ``` 11 | where `env_id=f{'Corn{duration}{location}{weather}-v1'}`. Below, we explain the values that these variables can have and 12 | their corresponding meaning. 13 | - `duration` indicates time horizon of the experiment. It can take the values `Short` (1 year), `Middle` (2 years), 14 | or `Long` (5 years). 15 | - `location` indicates the location where the experiment takes place, which affects the historical weather data that 16 | is used. It can take the values `RockSprings` or `NewHolland`. 17 | - `weather` indicates how the weather is generated, which can either be random (random shuffled years from historical 18 | - data) or fixed. It can take the values `RW` (random) or `FW` (fixed). 19 | 20 | All of these environments come with costs that can be used to define 21 | constraints on the number of fertilization events, the amount of N applied, and 22 | N leaching, volatilization, and emission. Following the interface from 23 | [safety gym](https://github.com/openai/safety-gym), these costs are given in 24 | the info dictionary that is returned by the `step` function of each environment, 25 | which does not break the standard OpenAI gym interface. 26 | 27 | ### Crop planning environments 28 | 29 | The crop planning environments differ along the following dimensions: weather generation, location. The duration is 30 | fixed to 19 years as these experiments only make sense over long time horizons. All these environments are readily 31 | available and can be created with 32 | ``` 33 | import gym 34 | gym.make(id=env_id) 35 | ``` 36 | where `env_id=f{'CropPlanning{location}{weather}-v1'}`. Below, we explain the values that these variables can have and 37 | their corresponding meaning. 38 | - `location` indicates the location where the experiment takes place, which affects the historical weather data that 39 | is used. It can take the values `RockSprings` or `NewHolland`. 40 | - `weather` indicates how the weather is generated, which can either be random (random shuffled years from historical 41 | data) or fixed. It can take the values `RW` (random) or `FW` (fixed). 42 | -------------------------------------------------------------------------------- /cyclesgym/policies/dummy_policies.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from itertools import cycle 3 | from stable_baselines3.common.policies import BasePolicy 4 | from abc import ABC, abstractmethod 5 | 6 | 7 | class OpenLoopPolicy(BasePolicy): 8 | """ 9 | Dummy policy that cycles through a sequence of actions 10 | """ 11 | def __init__(self, action_sequence): 12 | self.actions = cycle(action_sequence) 13 | 14 | def forward(self, *args, **kwargs): 15 | pass 16 | 17 | def _predict(self, observation, deterministic=False): 18 | pass 19 | 20 | def predict(self, observation, state=None, episode_start=None, 21 | deterministic=False): 22 | obs = np.asarray(observation) 23 | if obs.ndim == 1: 24 | return next(self.actions), None 25 | else: 26 | return np.atleast_1d(next(self.actions)), None 27 | 28 | 29 | class ActionProcesser(ABC): 30 | @abstractmethod 31 | def __init__(self, *args, **kwargs): 32 | pass 33 | 34 | @abstractmethod 35 | def process(self, action): 36 | pass 37 | 38 | 39 | class ActionBinner(ActionProcesser): 40 | def __init__(self, n_bins, lower, upper): 41 | self.n_bins = np.array(n_bins) 42 | self.lower = np.asarray(lower) 43 | self.upper = np.asarray(upper) 44 | assert self.n_bins.size == self.lower.size == self.upper.size 45 | 46 | def process(self, action): 47 | processed_action = np.zeros_like(self.lower).astype(int) 48 | for i, (n, l, u, a) in enumerate(zip(self.n_bins, self.lower, self.upper, action)): 49 | a = np.clip(a, l, u) 50 | processed_action[i] = np.digitize(a, np.linspace(l, u, n)) - 1 51 | if processed_action.size == 1: 52 | return processed_action[0] 53 | else: 54 | return processed_action 55 | 56 | 57 | class LinearPolicy(BasePolicy): 58 | def __init__(self, K, action_post_processing): 59 | self.K = K 60 | self.action_post_processing = action_post_processing 61 | 62 | def forward(self, *args, **kwargs): 63 | pass 64 | 65 | def _predict(self, observation, deterministic=False): 66 | pass 67 | 68 | def predict(self, observation, state=None, episode_start=None, 69 | deterministic=False): 70 | obs = np.asarray(observation) 71 | raw_action = np.matmul(self.K, obs) 72 | return self.action_post_processing.process(raw_action), None 73 | -------------------------------------------------------------------------------- /cyclesgym/envs/utils.py: -------------------------------------------------------------------------------- 1 | import os as _os 2 | import weakref 3 | import numpy as np 4 | import datetime 5 | from uuid import uuid4 6 | 7 | from pathlib import Path 8 | from tempfile import TemporaryDirectory 9 | 10 | 11 | __all__ = ['MyTemporaryDirectory', 'create_sim_id', 'date2ydoy', 'ydoy2date', 12 | 'cap_date'] 13 | 14 | 15 | class MyTemporaryDirectory(TemporaryDirectory): 16 | """ 17 | Subclass of temporary directory with specific name rather than randomly 18 | generated. 19 | """ 20 | 21 | def __init__(self, path): 22 | assert isinstance(path, Path) 23 | _os.mkdir(path, 0o700) 24 | self.name = path 25 | self._finalizer = weakref.finalize( 26 | self, self._cleanup, self.name, 27 | warn_message="Implicitly cleaning up {!r}".format(self)) 28 | 29 | 30 | def create_sim_id(): 31 | return datetime.datetime.now().strftime('%Y_%m_%d_%H_%M_%S-') + str(uuid4()) 32 | 33 | 34 | def date2ydoy(date): 35 | def date2ydoy_single(date: datetime.date): 36 | tmp = date.timetuple() 37 | return tmp.tm_year, tmp.tm_yday 38 | 39 | if hasattr(date, '__iter__'): 40 | y = [] 41 | doy = [] 42 | for d in date: 43 | y_tmp, doy_tmp = date2ydoy_single(d) 44 | y.append(y_tmp) 45 | doy.append(doy_tmp) 46 | return y, doy 47 | 48 | else: 49 | return date2ydoy_single(date) 50 | 51 | 52 | def ydoy2date(y, doy): 53 | def ydoy2date_single(y: int, doy: int): 54 | # Timedelta does not work with np.int64 55 | if isinstance(y, np.int64): 56 | y = int(y) 57 | if isinstance(doy, np.int64): 58 | doy = int(doy) 59 | return datetime.date(y, 1, 1) + datetime.timedelta(doy - 1) 60 | 61 | if hasattr(y, '__iter__') and hasattr(doy, '__iter__'): 62 | if len(y) == len(doy): 63 | dates = [] 64 | for y_tmp, doy_tmp in zip(y, doy): 65 | dates.append(ydoy2date_single(y_tmp, doy_tmp)) 66 | return dates 67 | else: 68 | raise ValueError(f'year and doy list should have the same length. ' 69 | f'They have {len(y)} and {len(doy)} lengths ' 70 | f'instead.') 71 | else: 72 | return ydoy2date_single(y, doy) 73 | 74 | 75 | def cap_date(date: datetime.date, end_year: int): 76 | """ 77 | Clip date to final year. 78 | """ 79 | return min([date, 80 | datetime.date(year=end_year, month=12, day=31)]) 81 | -------------------------------------------------------------------------------- /cyclesgym/envs/rewarders.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from cyclesgym.envs.utils import date2ydoy, ydoy2date 3 | from cyclesgym.utils.pricing_utils import crop_prices, N_price_dollars_per_kg, crop_type 4 | import datetime 5 | 6 | __all__ = ['CropRewarder'] 7 | 8 | 9 | class CropRewarder(object): 10 | 11 | def __init__(self, season_manager, crop_name): 12 | self.season_manager = season_manager 13 | self.crop_name = crop_name 14 | self.dollars_per_tonne = crop_prices[self.crop_name] 15 | self.yield_column = crop_type[self.crop_name] 16 | 17 | def _harvest_profit(self, date, delta, action=None): 18 | # Date of previous time step 19 | del action 20 | 21 | previous_date = date - timedelta(days=delta) 22 | y_prev, doy_prev = date2ydoy(previous_date) 23 | 24 | # Did we harvest between this and previous time step? 25 | df = self.season_manager.season_df 26 | harverst_df = df.loc[(df['YEAR'] == y_prev) & (df['CROP'] == self.crop_name)] 27 | harvest_dollars_per_hectare = 0 28 | if not harverst_df.empty: 29 | harverst_doy = harverst_df.iloc[0]['DOY'] 30 | harvest_date = ydoy2date(y_prev, harverst_doy) 31 | 32 | if previous_date < harvest_date <= date: 33 | # Compute harvest profit 34 | dollars_per_tonne = self.dollars_per_tonne[y_prev] 35 | harvest = harverst_df[self.yield_column].sum() 36 | # Metric tonne per hectare 37 | harvest_dollars_per_hectare = harvest * dollars_per_tonne 38 | return harvest_dollars_per_hectare 39 | 40 | def compute_reward(self, date, delta, action=None): 41 | return self._harvest_profit(date, delta, action=action) 42 | 43 | 44 | class NProfitabilityRewarder(object): 45 | 46 | def compute_reward(self, date, delta, action=None): 47 | Nkg_per_heactare = action 48 | assert Nkg_per_heactare >= 0, f'We cannot have negative fertilization' 49 | y, doy = date2ydoy(date) 50 | N_dollars_per_hectare = Nkg_per_heactare * N_price_dollars_per_kg[y] 51 | return -N_dollars_per_hectare 52 | 53 | 54 | def compound_rewarder(rewarder_list: list): 55 | 56 | class Compound(object): 57 | def __init__(self, rewarder_list): 58 | self.rewarder_list = rewarder_list 59 | 60 | def compute_reward(self, date: datetime.date, delta, action=None): 61 | return sum([r.compute_reward(date, delta, action=action) for r in self.rewarder_list]) 62 | 63 | return Compound(rewarder_list) 64 | 65 | 66 | -------------------------------------------------------------------------------- /cyclesgym/managers/soil_n.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | from matplotlib import pyplot as plt 4 | import os 5 | 6 | from cyclesgym.managers.common import Manager 7 | from cyclesgym.managers.utils import date_to_ydoy, ydoy_to_date 8 | 9 | __all__ = ['SoilNManager'] 10 | 11 | 12 | class SoilNManager(Manager): 13 | def __init__(self, fname=None): 14 | self.soil_n_state = pd.DataFrame() 15 | super().__init__(fname) 16 | 17 | def _valid_input_file(self, fname): 18 | if fname is None: 19 | return True 20 | else: 21 | return super(SoilNManager, self)._valid_input_file(fname) and fname.suffix == '.dat' 22 | 23 | def _valid_output_file(self, fname): 24 | return super(SoilNManager, self)._valid_output_file(fname) and fname.suffix == '.dat' 25 | 26 | def _parse(self, fname): 27 | if fname is not None: 28 | if os.path.isfile(fname) and os.path.getsize(fname) > 0: 29 | # Read and remove unit of measurement row 30 | df = pd.read_csv(fname, sep='\t').drop(index=0) 31 | df.reset_index(drop=True, inplace=True) 32 | 33 | # Remove empty spaces and cast as floats 34 | df.columns = df.columns.str.strip(' ') 35 | numeric_cols = df.columns[1:] 36 | df[numeric_cols] = df[numeric_cols].astype(float) 37 | 38 | # Convert date to be consistent with weather 39 | date_to_ydoy(df, old_col_name='DATE', 40 | new_col_names=['YEAR', 'DOY'], inplace=True) 41 | self.soil_n_state = df 42 | 43 | def _to_str(self): 44 | # Not necessary since this is not an input file but I had already implemented it 45 | return ydoy_to_date(self.soil_n_state, old_col_names=['YEAR', 'DOY'], 46 | new_col_name='DATE').to_csv(index=False, sep='\t') 47 | 48 | def __str__(self): 49 | return self._to_str() 50 | 51 | def get_day(self, year, doy): 52 | if 'YEAR' in self.soil_n_state.columns and 'DOY' in self.soil_n_state.columns: 53 | return self.soil_n_state.loc[(self.soil_n_state['YEAR'] == year) & (self.soil_n_state['DOY'] == doy)] 54 | else: 55 | return pd.DataFrame() 56 | 57 | def plot(self, columns): 58 | columns = np.atleast_2d(columns) 59 | shape = columns.shape 60 | fig, axes = plt.subplots(*shape) 61 | for i in range(shape[0]): 62 | for j in range(shape[1]): 63 | axes[i, j].plot(self.soil_n_state[columns[i, j]]) 64 | plt.show() -------------------------------------------------------------------------------- /documents/1_introduction.md: -------------------------------------------------------------------------------- 1 | ## Introduction 2 | The scientific community has developed several crop growth models (CGMs) and each one has 3 | its strengths and weaknesses. Therefore, which one to use depends on the 4 | research question under investigation and there is not a single best one [1]. 5 | 6 | Cycles is a mechanistic multi-year and multi-species agroecosystem model, 7 | which evolved from C-Farm [2] and shares biophysical modules with CropSyst 8 | [3]. It simulates the water and energy balance, the coupled cycling of carbon 9 | and nitrogen, and plant growth at daily time steps. Its ability to simulate a 10 | wide range of perturbations of biogeochemical processes caused by management 11 | practices for multiple crops and its focus on long-term simulations make it a 12 | suitable CGM to study the application of RL to sustainable agriculture, where 13 | these aspects are crucial. 14 | 15 | In summary, our choice for Cycles is dictated by: 16 | 1. Its accurate modeling of Nitrogen dynamics. 17 | 2. Its focus on long-term simulations. 18 | 3. Its ability to simulate multiple crops, making adept at modelling complex 19 | agricultural systems and the resulting interactions including crop rotations 20 | and polycultures. 21 | 22 | #### Alternative RL environments for agricultural management 23 | To the best of our knowledge, two other OpenAI gym environments based on crop 24 | growth models currently exist: 25 | 1. [cropgym](https://github.com/BigDataWUR/crop-gym) based on LINTUL3. 26 | 2. [gym-DSSAT](https://rgautron.gitlabpages.inria.fr/gym-dssat-docs/) based on DSSAT for maize. 27 | 28 | Cropgym can simulate multiple crops but it is limited to single-year 29 | experiments. gym-DSSAT can simulate multiple-year scenarios but is limited to 30 | maize. Ultimately, the choice of environment depends on the research that 31 | is being pursued and on aspects that include modelling accuracy, speed, ease of 32 | customization, and more. Therefore, which environment is application 33 | dependent and there is no single best option. 34 | 35 | 36 | 37 | 38 | ## References 39 | [1] Di Paola, A., Valentini, R., and Santini, M., "An overview of available 40 | crop growth and yield models for studies and assessments in agriculture." 41 | Journal of the Science of Food and Agriculture 96.3 (2016): 709-714. 42 | 43 | [2] Kemanian, A. R., Stöckle., C. O., "C-Farm: A simple model to evaluate the 44 | carbon balance of soil profiles." European Journal of Agronomy 32.1 (2010): 22-29. 45 | 46 | [3] Stöckle, C. O., Donatelli, M., Nelson., R., "CropSyst, a cropping systems 47 | simulation model." European journal of agronomy 18.3-4 (2003): 289-307. -------------------------------------------------------------------------------- /cyclesgym/managers/crop.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | from matplotlib import pyplot as plt 4 | import os 5 | 6 | from cyclesgym.managers.common import Manager 7 | from cyclesgym.managers.utils import date_to_ydoy, ydoy_to_date 8 | 9 | __all__ = ['CropManager'] 10 | 11 | 12 | class CropManager(Manager): 13 | def __init__(self, fname=None): 14 | self.crop_state = pd.DataFrame() 15 | super().__init__(fname) 16 | 17 | def _valid_input_file(self, fname): 18 | if fname is None: 19 | return True 20 | else: 21 | return super(CropManager, self)._valid_input_file(fname) and fname.suffix == '.dat' 22 | 23 | def _valid_output_file(self, fname): 24 | return super(CropManager, self)._valid_output_file(fname) and fname.suffix == '.dat' 25 | 26 | def _parse(self, fname): 27 | if fname is not None: 28 | if os.path.isfile(fname) and os.path.getsize(fname) > 0: 29 | # Read and remove unit of measurement row 30 | df = pd.read_csv(fname, sep='\t').drop(index=0) 31 | df.reset_index(drop=True, inplace=True) 32 | 33 | # Remove empty spaces and cast as floats 34 | df.columns = df.columns.str.strip(' ') 35 | df['CROP'] = df['CROP'].str.strip(' ') 36 | df['STAGE'] = df['STAGE'].str.strip(' ') 37 | numeric_cols = df.columns[3:] 38 | df[numeric_cols] = df[numeric_cols].astype(float) 39 | 40 | # Convert date to be consistent with weather 41 | date_to_ydoy(df, old_col_name='DATE', 42 | new_col_names=['YEAR', 'DOY'], inplace=True) 43 | self.crop_state = df 44 | 45 | def _to_str(self): 46 | # Not necessary since this is not an input file but I had already implemented it 47 | return ydoy_to_date(self.crop_state, old_col_names=['YEAR', 'DOY'], 48 | new_col_name='DATE').to_csv(index=False, sep='\t') 49 | def __str__(self): 50 | return self._to_str() 51 | 52 | def get_day(self, year, doy): 53 | if 'YEAR' in self.crop_state.columns and 'DOY' in self.crop_state.columns: 54 | return self.crop_state.loc[(self.crop_state['YEAR'] == year) & (self.crop_state['DOY'] == doy)] 55 | else: 56 | return pd.DataFrame() 57 | 58 | def plot(self, columns): 59 | columns = np.atleast_2d(columns) 60 | shape = columns.shape 61 | fig, axes = plt.subplots(*shape) 62 | for i in range(shape[0]): 63 | for j in range(shape[1]): 64 | axes[i, j].plot(self.crop_state[columns[i, j]]) 65 | plt.show() -------------------------------------------------------------------------------- /cyclesgym/managers/control.py: -------------------------------------------------------------------------------- 1 | from cyclesgym.managers.common import InputFileManager 2 | from copy import copy, deepcopy 3 | 4 | 5 | __all__ = ['ControlManager'] 6 | 7 | 8 | class ControlManager(InputFileManager): 9 | _keys = [ 10 | 'SIMULATION_START_YEAR', 11 | 'SIMULATION_END_YEAR', 12 | 'ROTATION_SIZE', 13 | 'USE_REINITIALIZATION', 14 | 'ADJUSTED_YIELDS', 15 | 'HOURLY_INFILTRATION', 16 | 'AUTOMATIC_NITROGEN', 17 | 'AUTOMATIC_PHOSPHORUS', 18 | 'AUTOMATIC_SULFUR', 19 | 'DAILY_WEATHER_OUT', 20 | 'DAILY_CROP_OUT', 21 | 'DAILY_RESIDUE_OUT', 22 | 'DAILY_WATER_OUT', 23 | 'DAILY_NITROGEN_OUT', 24 | 'DAILY_SOIL_CARBON_OUT', 25 | 'DAILY_SOIL_LYR_CN_OUT', 26 | 'ANNUAL_SOIL_OUT', 27 | 'ANNUAL_PROFILE_OUT', 28 | 'ANNUAL_NFLUX_OUT', 29 | 'CROP_FILE', 30 | 'OPERATION_FILE', 31 | 'SOIL_FILE', 32 | 'WEATHER_FILE', 33 | 'REINIT_FILE'] 34 | 35 | _non_numeric = _keys[-5:] 36 | 37 | def __init__(self, fname=None): 38 | self.ctrl_dict = dict() 39 | super().__init__(fname) 40 | 41 | def _parse(self, fname): 42 | if fname is not None: 43 | with open(fname, 'r') as f: 44 | for line in f.readlines(): 45 | if line.startswith(('\n', '#')): 46 | pass 47 | else: 48 | k, v = line.split()[0:2] 49 | v = v if k in self._non_numeric else int(v) 50 | self.ctrl_dict.update({k: v}) 51 | 52 | def _to_str(self): 53 | s = '' 54 | for k, v in self.ctrl_dict.items(): 55 | s += f"{k:30}{v}\n" 56 | if k.startswith('ROTATION_SIZE'): 57 | s += '\n\n\n' 58 | elif k.startswith('ANNUAL_NFLUX_OUT'): 59 | s += '\n\n\n' 60 | return s 61 | 62 | @classmethod 63 | def from_dict(cls, d): 64 | l1 = list(set(d.keys()) - set(cls._keys)) 65 | l2 = list(set(cls._keys) - set(d.keys())) 66 | if len(l1) > 0: 67 | raise ValueError( 68 | f'The keys {l1} are not needed when specifying a {cls.__name__} from a dictionary') 69 | if len(l2) > 0: 70 | raise ValueError( 71 | f'The keys {l2} are necessary when specifying a {cls.__name__} from a dictionary') 72 | manager = cls(fname=None) 73 | manager.ctrl_dict = d.copy() 74 | return manager 75 | 76 | def __copy__(self): 77 | cls = self.__class__ 78 | result = cls.from_dict(copy(self.ctrl_dict)) 79 | return result 80 | 81 | def __deepcopy__(self): 82 | cls = self.__class__ 83 | result = cls.from_dict(deepcopy(self.ctrl_dict)) 84 | return result 85 | -------------------------------------------------------------------------------- /cyclesgym/managers/weather.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | 4 | from cyclesgym.managers.common import InputFileManager 5 | from cyclesgym.managers.utils import num_lines 6 | 7 | 8 | __all__ = ['WeatherManager'] 9 | 10 | 11 | class WeatherManager(InputFileManager): 12 | def __init__(self, fname=None): 13 | self.immutables = pd.DataFrame() 14 | self.mutables = pd.DataFrame 15 | super().__init__(fname) 16 | 17 | def _parse(self, fname): 18 | if fname is not None: 19 | self._parse_immutable(fname) 20 | self._parse_mutables(fname) 21 | 22 | def _parse_immutable(self, fname): 23 | immutables = {} 24 | with open(fname, 'r') as f: 25 | for _ in range(3): 26 | line = next(f) 27 | immutables.update({line.split()[0]: [float(line.split()[1])]}) 28 | self.immutables = pd.DataFrame(immutables) 29 | 30 | def _parse_mutables(self, fname): 31 | n = num_lines(fname) 32 | values = np.zeros((n, 9)) 33 | 34 | lines_to_skip = [] 35 | with open(fname, 'r') as f: 36 | for i, l in enumerate(f.readlines()): 37 | if l.startswith(('#', 'LATITUDE', 'ALTITUDE', 38 | 'SCREENING_HEIGHT')): 39 | lines_to_skip.append(i) 40 | pass 41 | elif l.startswith('YEAR'): 42 | columns = l.split() 43 | lines_to_skip.append(i) 44 | else: 45 | values[i, :] = [float(j) if i >= 2 else int(j) for i, j in enumerate(l.split())] 46 | values = np.delete(values, lines_to_skip, 0) 47 | 48 | self.mutables = pd.DataFrame(data=values, index=None, columns=columns) 49 | self.mutables = self.mutables.astype({'YEAR': int, 50 | 'DOY': int}) 51 | 52 | def _to_str_immutables(self): 53 | s = '' 54 | for (name, data) in self.immutables.iteritems(): 55 | s += f'{name:<20}{data.values[0]}\n' 56 | return s 57 | 58 | def _to_str_mutables(self): 59 | # units_of_measurement = '#### ### mm deg C deg C MJ/m2 % % m/s\n' 60 | return self.mutables.to_csv(index=False, sep=' ') 61 | 62 | def _to_str(self): 63 | return f'{self._to_str_immutables()}{self._to_str_mutables()}' 64 | 65 | def get_day(self, year, doy): 66 | return self.mutables.loc[(self.mutables['YEAR'] == year) & (self.mutables['DOY'] == doy)] 67 | 68 | @classmethod 69 | def from_df(cls, immutables_df, mutables_df): 70 | # TODO: Perform sanity checks on df 71 | manager = cls(fname=None) 72 | manager.immutables = immutables_df.copy() 73 | manager.mutables = mutables_df.copy() 74 | return manager -------------------------------------------------------------------------------- /experiments/crop_planning/crop_planning_baselines.py: -------------------------------------------------------------------------------- 1 | from cyclesgym.envs.crop_planning import CropPlanningFixedPlanting 2 | from cyclesgym.utils.paths import CYCLES_PATH, TEST_PATH 3 | 4 | import shutil 5 | import subprocess 6 | import time 7 | 8 | 9 | class CropPlanningBaselines(object): 10 | 11 | @staticmethod 12 | def _call_cycles(ctrl): 13 | subprocess.run(['./Cycles', '-b', ctrl], cwd=CYCLES_PATH) 14 | 15 | def setUp(self): 16 | self.fnames = ['CropPlanningTest.ctrl', 17 | 'CropPlanningTest.operation'] 18 | for n in self.fnames: 19 | src = TEST_PATH.joinpath(n) 20 | dest = CYCLES_PATH.joinpath('input', n) 21 | shutil.copy(src, dest) 22 | self.custom_sim_id = lambda: '1' # This way the output does not depend on time and can be deleted by teardown 23 | 24 | def _test_policy(self, policy, start, end, weather_file): 25 | env = CropPlanningFixedPlanting(start_year=start, end_year=end, weather_file=weather_file, 26 | rotation_crops=['CornRM.100', 'SoybeanMG.3']) 27 | env.reset() 28 | year = 0 29 | 30 | start = time.time() 31 | rewards = [] 32 | while True: 33 | a = policy[year] 34 | _, r, done, _ = env.step(a) 35 | rewards.append(r) 36 | year += 1 37 | if done: 38 | break 39 | 40 | crop_from_env_1 = env.crop_output_manager[0] 41 | crop_from_env_2 = env.crop_output_manager[1] 42 | print(time.time() - start) 43 | return crop_from_env_1, crop_from_env_2, rewards 44 | 45 | def test_equal(self, start, end, weather_file): 46 | long_rotation = [(1, 2), (1, 2), (0, 1)] * (end - start) 47 | short_rotation = [(1, 2), (0, 1)] * (end - start + 1) 48 | only_soy = [(1, 2)] * (end - start + 1) 49 | only_corn = [(0, 1)] * (end - start + 1) 50 | 51 | def test_policy(policy, text): 52 | crop_from_env_1, crop_from_env_2, rewards = self._test_policy(policy, start, end, weather_file) 53 | print(text, start, end, weather_file, sum(rewards) / (end - start + 1)) 54 | 55 | test_policy(long_rotation, 'long_rotation') 56 | test_policy(short_rotation, 'short rotation') 57 | test_policy(only_soy, 'only soy') 58 | test_policy(only_corn, 'only_corn') 59 | 60 | 61 | if __name__ == '__main__': 62 | train_start_year = 1980 63 | train_end_year = 1998 64 | test_end_year = 2016 65 | 66 | weather_train_file = 'RockSprings.weather' 67 | weather_test_file = 'NewHolland.weather' 68 | 69 | CropPlanningBaselines().test_equal(1980, 1998, weather_train_file) 70 | CropPlanningBaselines().test_equal(1998, 2016, weather_train_file) 71 | CropPlanningBaselines().test_equal(1980, 1998, weather_test_file) 72 | CropPlanningBaselines().test_equal(1980, 2015, weather_test_file) -------------------------------------------------------------------------------- /cyclesgym/tests/test_observers.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | import numpy as np 4 | 5 | from cyclesgym.envs.observers import WeatherObserver, CropObserver, compound_observer, NToDateObserver, SoilNObserver 6 | from cyclesgym.managers import * 7 | 8 | from cyclesgym.utils.paths import TEST_PATH 9 | 10 | 11 | class TestObservers(unittest.TestCase): 12 | def setUp(self) -> None: 13 | # Init managers 14 | self.weather_manager = WeatherManager( 15 | TEST_PATH.joinpath('DummyWeather.weather')) 16 | self.crop_manager = CropManager( 17 | TEST_PATH.joinpath('DummyCrop.dat')) 18 | self.soil_manager = SoilNManager( 19 | TEST_PATH.joinpath('DummyN.dat')) 20 | 21 | # Init observers 22 | self.weather_observer = WeatherObserver( 23 | weather_manager=self.weather_manager, 24 | end_year=1980 25 | ) 26 | self.crop_observer = CropObserver( 27 | crop_manager=self.crop_manager, 28 | end_year=1980 29 | ) 30 | self.soil_observer = SoilNObserver( 31 | soil_n_manager=self.soil_manager, 32 | end_year=1980 33 | ) 34 | self.N_to_date_observer = NToDateObserver(end_year=1980) 35 | self.weather_crop_observer = compound_observer([self.weather_observer, self.crop_observer]) 36 | self.weather_target_obs = np.concatenate((np.array([40.6875, 0, 10]), np.arange(7))) 37 | self.crop_target_obs = np.concatenate(([0], np.arange(14))) 38 | self.N_to_date_target_obs = list(zip(np.arange(1, 11), np.arange(2, 21, step=2))) 39 | self.N_to_date_target_obs =[np.array(ele) for ele in self.N_to_date_target_obs] 40 | self.soil_target_obs = np.array([8873.886558, 37.068387, 8.968119, 0.037371, 0.000000, 0.037371, 0.068560, 41 | 0.00000, 0.00000, 0.000000, 0.000000]) 42 | 43 | def test_weather_obs(self): 44 | date = datetime.date(year=1980, month=1, day=1) 45 | obs = self.weather_observer.compute_obs(date) 46 | assert np.all(obs == self.weather_target_obs) 47 | 48 | def test_crop_obs(self): 49 | date = datetime.date(year=1980, month=1, day=1) 50 | obs = self.crop_observer.compute_obs(date) 51 | assert np.all(obs == self.crop_target_obs) 52 | 53 | def test_compound_observer(self): 54 | date = datetime.date(year=1980, month=1, day=1) 55 | obs = self.weather_crop_observer.compute_obs(date) 56 | assert np.all(obs == np.concatenate([self.weather_target_obs, self.crop_target_obs])) 57 | assert self.weather_crop_observer.Nobs == 25 58 | 59 | def test_N_to_date_observer(self): 60 | date = datetime.date(year=1980, month=1, day=1) 61 | obs = [] 62 | for i in range(10): 63 | obs.append(self.N_to_date_observer.compute_obs(date, N=2)) 64 | date = date + datetime.timedelta(days=1) 65 | 66 | assert np.all(np.all(o == target for o, target in zip(obs, self.N_to_date_target_obs))) 67 | 68 | def test_soil_N_observer(self): 69 | date = datetime.date(year=1980, month=1, day=1) 70 | 71 | obs = self.soil_observer.compute_obs(date) 72 | assert np.all(obs == self.soil_target_obs) 73 | 74 | 75 | if __name__ == '__main__': 76 | unittest.main() 77 | -------------------------------------------------------------------------------- /cyclesgym/tests/test_policies.py: -------------------------------------------------------------------------------- 1 | from cyclesgym.policies.informed_policy import InformedPolicy 2 | import unittest 3 | import gym 4 | from gym import spaces 5 | import numpy as np 6 | from numpy.testing import * 7 | 8 | 9 | class DummyEnv(gym.Env): 10 | def __init__(self): 11 | self.observation_space = spaces.Box(low=np.array([0,0]), 12 | high=np.array([365,120]), dtype=int) 13 | self.action_space = spaces.Discrete(3) 14 | self.state = self.observation_space.sample() 15 | self.n_steps = 0 16 | self.maxN = 100 17 | self.n_actions = 3 18 | 19 | def step(self, action): 20 | self.state += np.random.choice([-1, 1]) * action 21 | self.n_steps += 1 22 | return self.state, np.random.rand(1), self.n_steps > 5, {} 23 | 24 | def reset(self): 25 | self.state = self.observation_space.sample() 26 | self.n_steps = 0 27 | return self.state 28 | 29 | def render(self, mode="human"): 30 | pass 31 | 32 | 33 | class TestInformedPolicy(unittest.TestCase): 34 | def setUp(self) -> None: 35 | self.env = DummyEnv() 36 | params = (14, # 14 * 7 + 1 = 99 start_day 37 | 14, # 99 + 14* 7 = 197 end_day 38 | # 20, # a 39 | 1.0, # max_val 40 | 0.0, # min_val 41 | 5, # 20 * 5for saturation 42 | 2 # 10**2 lengthscale 43 | ) 44 | self.model = InformedPolicy(env=self.env, params=params) 45 | self.obs = np.array([[50, 0], # Before window starts 46 | [250, 40], # After window is over 47 | [102, 50], # Middle of down ramp 48 | [150, 50] # Middle of window 49 | ]) 50 | 51 | def test_action_prob(self): 52 | probs = self.model.action_probability(self.obs) 53 | 54 | Z = 2*np.exp(-50**2 / 100) + 1 55 | p = 1-1/7*(102-99) 56 | target_probs = np.array([[1, 0, 0], 57 | [1, 0, 0], 58 | [p + ((1-p) * np.exp(-50**2 / 100))/Z, (1-p)/Z, ((1-p) * np.exp(-50**2 / 100))/Z], 59 | [np.exp(-50**2 / 100)/Z, 1/Z, np.exp(-50**2 / 100)/Z]]) 60 | assert_allclose(probs, target_probs) 61 | 62 | def test_predict(self): 63 | # Test multiple deterministic 64 | actions, _ = self.model.predict(self.obs, deterministic=True) 65 | target_actions = np.array([0, 0, 0, 1]) 66 | assert_equal(actions, target_actions) 67 | 68 | # Test single deterministic 69 | actions, _ = self.model.predict(self.obs[-1, :], deterministic=True) 70 | target_actions = np.array([1]) 71 | assert_equal(actions, target_actions) 72 | 73 | # Test multiple stochastic (probability is so peaked that results is same as deterministic) 74 | actions, _ = self.model.predict(self.obs[[0, 3], :], deterministic=False) 75 | target_actions = np.array([0, 1]) 76 | assert_equal(actions, target_actions) 77 | 78 | # Test single stochastic 79 | actions, _ = self.model.predict(self.obs[-1, :], deterministic=False) 80 | target_actions = np.array([1]) 81 | assert_equal(actions, target_actions) 82 | 83 | 84 | if __name__ == '__main__': 85 | unittest.main() -------------------------------------------------------------------------------- /cyclesgym/tests/test_crop_planning.py: -------------------------------------------------------------------------------- 1 | from cyclesgym.envs.crop_planning import CropPlanning 2 | import unittest 3 | import shutil 4 | import subprocess 5 | 6 | from cyclesgym.managers import CropManager 7 | from cyclesgym.utils.utils import diff_pd 8 | from cyclesgym.utils.paths import CYCLES_PATH, TEST_PATH 9 | import time 10 | 11 | 12 | class TestCropPlanning(unittest.TestCase): 13 | 14 | @staticmethod 15 | def _call_cycles(ctrl): 16 | subprocess.run(['./Cycles', '-b', ctrl], cwd=CYCLES_PATH) 17 | 18 | def setUp(self): 19 | self.fnames = ['CropPlanningTest.ctrl', 20 | 'CropPlanningTest.operation'] 21 | for n in self.fnames: 22 | src = TEST_PATH.joinpath(n) 23 | dest = CYCLES_PATH.joinpath('input', n) 24 | shutil.copy(src, dest) 25 | self.custom_sim_id = lambda: '1' # This way the output does not depend on time and can be deleted by teardown 26 | 27 | def _test_policy(self, policy): 28 | self._call_cycles(self.fnames[0].replace('.ctrl', '')) 29 | crop_from_sim_1 = CropManager(CYCLES_PATH.joinpath('output', 30 | self.fnames[0].replace('.ctrl', ''), 31 | 'CornSilageRM.90.dat')) 32 | crop_from_sim_2 = CropManager(CYCLES_PATH.joinpath('output', 33 | self.fnames[0].replace('.ctrl', ''), 34 | 'SoybeanMG.3.dat')) 35 | 36 | env = CropPlanning(start_year=1980, end_year=1990, rotation_crops=['CornSilageRM.90', 37 | 'SoybeanMG.3']) 38 | env._create_sim_id = self.custom_sim_id 39 | 40 | env.reset() 41 | year = 0 42 | 43 | start = time.time() 44 | while True: 45 | a = policy[year % 2] 46 | _, _, done, _ = env.step(a) 47 | year += 1 48 | if done: 49 | break 50 | 51 | crop_from_env_1 = env.crop_output_manager[0] 52 | crop_from_env_2 = env.crop_output_manager[1] 53 | print(time.time() - start) 54 | return crop_from_env_1, crop_from_env_2, crop_from_sim_1, crop_from_sim_2 55 | 56 | def test_equal(self): 57 | policy = [(0, 0, 10, 9), (1, 2, 8, 9)] 58 | 59 | crop_from_env_1, crop_from_env_2, crop_from_sim_1, crop_from_sim_2 = self._test_policy(policy) 60 | 61 | assert crop_from_env_1.crop_state.equals(crop_from_sim_1.crop_state), \ 62 | diff_pd(crop_from_env_1.crop_state, crop_from_sim_1.crop_state) 63 | assert crop_from_env_2.crop_state.equals(crop_from_sim_2.crop_state), \ 64 | diff_pd(crop_from_env_2.crop_state, crop_from_sim_2.crop_state) 65 | 66 | def test_different(self): 67 | policy = [(0, 5, 8, 5), (1, 3, 8, 5)] 68 | 69 | crop_from_env_1, crop_from_env_2, crop_from_sim_1, crop_from_sim_2 = self._test_policy(policy) 70 | 71 | assert not crop_from_env_1.crop_state.equals(crop_from_sim_1.crop_state), \ 72 | diff_pd(crop_from_env_1.crop_state, crop_from_sim_1.crop_state) 73 | assert not crop_from_env_2.crop_state.equals(crop_from_sim_2.crop_state), \ 74 | diff_pd(crop_from_env_2.crop_state, crop_from_sim_2.crop_state) 75 | 76 | 77 | if __name__ == '__main__': 78 | unittest.main() -------------------------------------------------------------------------------- /experiments/crop_planning/tables.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import wandb 3 | import csv 4 | from cyclesgym.utils.paths import PROJECT_PATH 5 | 6 | api = wandb.Api() 7 | 8 | # Project is specified by 9 | runs = api.runs("koralabs/experiments_crop_planning") 10 | 11 | summary_list, summary_at_max, config_list, name_list = [], [], [], [] 12 | for run in runs: 13 | # .summary contains the output keys/values for metrics like accuracy. 14 | # We call ._json_dict to omit large files 15 | summary_list.append(run.summary._json_dict) 16 | 17 | # .config contains the hyperparameters. 18 | # We remove special values that start with _. 19 | config_list.append( 20 | {k: v for k,v in run.config.items() 21 | if not k.startswith('_')}) 22 | 23 | hist = run.history() 24 | indx = hist.idxmax(axis=0, skipna=True)['eval_det/mean_reward'] 25 | summary_at_max_train = hist.iloc[indx].to_dict() 26 | summary_at_max_train = {'max_' + str(key): val for key, val in summary_at_max_train.items()} 27 | summary_at_max.append(summary_at_max_train) 28 | # .name is the human-readable name of the run. 29 | name_list.append(run.name) 30 | 31 | runs_df = pd.concat([pd.DataFrame(summary_at_max), pd.DataFrame(summary_list), pd.DataFrame(config_list), 32 | pd.DataFrame(name_list, columns=['run_name'])], axis=1) 33 | 34 | columns = ['max_eval_det/mean_reward', 'max_eval_det_new_years/mean_reward', 35 | 'max_eval_det_other_loc/mean_reward', 'max_eval_det_other_loc_long/mean_reward'] 36 | 37 | for c,l in zip(columns, [19., 19., 19., 35]): 38 | runs_df[c] = runs_df[c] / l 39 | 40 | 41 | groupby = runs_df.groupby(['fixed_weather', 'env_class']) 42 | 43 | results_mean = groupby.mean()[columns] 44 | results_std = groupby.std()[columns] 45 | results_min = groupby.min()[columns] 46 | results_max = groupby.max()[columns] 47 | 48 | rows = [('True', 'CropPlanningFixedPlanting'), 49 | ('True', 'CropPlanningFixedPlantingRotationObserver'), 50 | ('False', 'CropPlanningFixedPlantingRandomWeather'), 51 | ('False', 'CropPlanningFixedPlantingRandomWeatherRotationObserver')] 52 | 53 | results_mean = results_mean.reindex(rows) / 1000. 54 | results_std = results_std.reindex(rows) / 1000. 55 | results_min = results_min.reindex(rows) / 1000. 56 | results_max = results_max.reindex(rows) / 1000. 57 | 58 | tables_dir = PROJECT_PATH.joinpath('tables') 59 | results_mean.to_csv(tables_dir.joinpath('means.csv'), float_format='%.3f') 60 | results_std.to_csv(tables_dir.joinpath('stds.csv'), float_format='%.3f') 61 | round_digit = 2 62 | pm = pd.DataFrame([[' \small{$\pm$ ']*4]*4, index=results_mean.index, columns=results_mean.columns) 63 | curl = pd.DataFrame([['} ']*4]*4, index=results_mean.index, columns=results_mean.columns) 64 | table_string_std = results_mean.round(round_digit).astype('string') + \ 65 | pm + results_std.round(round_digit).astype('string') + curl 66 | table_string_std.to_csv(tables_dir.joinpath('table_std.csv'), header=False, index=False, sep=str('&'), 67 | quoting=csv.QUOTE_NONE) 68 | 69 | par = pd.DataFrame([[' \small{(']*4]*4, index=results_mean.index, columns=results_mean.columns) 70 | comma = pd.DataFrame([[',']*4]*4, index=results_mean.index, columns=results_mean.columns) 71 | curl = pd.DataFrame([[')} ']*4]*4, index=results_mean.index, columns=results_mean.columns) 72 | table_string_min_max = results_mean.round(round_digit).astype('string') + \ 73 | par + results_min.round(round_digit).astype('string') + comma + \ 74 | results_max.round(round_digit).astype('string') + curl 75 | table_string_min_max.to_csv(tables_dir.joinpath('table_min_max.csv'), header=False, index=False, sep=str('&'), 76 | quoting=csv.QUOTE_NONE) 77 | -------------------------------------------------------------------------------- /documents/3_logic.md: -------------------------------------------------------------------------------- 1 | ## Cyclesgym logic 2 | Originally, Cycles does not allow the user to make interactive management 3 | decisions based on intermediate observations. Instead, it requires all 4 | management practices to be pre-specified, which makes it impossible to use it 5 | in a reinforcement learning (RL) loop. Cyclesgym is meant to address this issue. 6 | To this end, it creates all the necessary input files for the desired Cycles 7 | simulation, parses the output after the desired time interval has elapsed 8 | (usually one week but this can be changed), passes the relevant variables to 9 | the decision-maker (the RL agent, usually), updates the operation file if 10 | necessary, and repeats this procedure. 11 | 12 | The shortest possible simulation duration in Cycles is one year. Therefore, to 13 | implement the behavior described above between $t$ and $\delta t$, cyclesgym 14 | needs to: 15 | 1. Read the output at time t and pass it to the decision-maker. 16 | 2. Receive the new decision (which may include "do nothing") and update the 17 | operation file, if necessary. 18 | 3. If the operation file was updated, re-run the simulation and read the values 19 | in the new output files at time $t+\delta t$. Otherwise, directly read the 20 | values in the old output files at time $t+\delta t$. 21 | 22 | 23 | **Speed considerations**: Due step 3 above, the speed of cyclesgym depends on (among other things): 24 | 1. How often the decision-maker updates the operation files (each time triggers a 25 | new simulation). This means it is not easy to time cycles as the duration of 26 | one episode depends on the decisions being made. 27 | 2. The type of outputs that are required from Cycles. This is because some of 28 | the output files can be quite large and writing them to memory may be slow. 29 | 30 | Point 1 has two important consequences for the training of RL agents. First, 31 | as training progresses, policies become more stable, which induces faster 32 | episodes. Second, making the time resolution finer can make the rollout of one 33 | episode slower as there are more occasions to update the operation file 34 | (notice that this matters early in the training when policies keep changing). 35 | 36 | #### CyclesEnv and specific environments 37 | The logic described above is common to all the environments, no matter the 38 | observation space, action space, rewards, and constraints. It is implemented in 39 | generic class `CyclesEnv` that is contained in `cyclesgym/envs/commmon.py`. 40 | This class contains the functionalities to create a unique temporary directory, 41 | writing, copying, or symlinking all the necessary input files there, 42 | initialize all the managers that are necessary to interact with Cycles, and run 43 | Cycles simulations from Python. 44 | 45 | The states, actions, rewards, and constraints are defined in each individual 46 | environment. This is done by implementing the functions `_init_observer`, 47 | `_init_implementer`, `_init_rewarder`, `_init_constrainer`. Cyclesgym comes 48 | with a set of predefined environments which already implement observers, 49 | implementers, rewarders, and constrainers for specific scenarios (see 50 | [here](3.1_predefined_envs.md) for a detailed description of the environments 51 | available). 52 | 53 | New environments can be created by creating new state spaces, action spaces, 54 | rewards, and/or constraints or by combining existing ones (see [here](3.3_custom_spaces_and_rewards.md)). 55 | Moreover, it is possible to modify the initial state distribution using a custom soil generator (see [here](3.2_custom_weather_and_soil.md)). 56 | Finally, the dynamics of the environment can be modified by using a custom weather generator 57 | or different crops. You can find an in depth discussion on how to do this 58 | [here](3.2_custom_weather_and_soil.md). -------------------------------------------------------------------------------- /documents/3.4_default_operations.md: -------------------------------------------------------------------------------- 1 | ## Default operations 2 | 3 | In cyclesgym environments, we usually keep some management aspects fixed and 4 | optimize only a subset of them. For example, in the fertilization environments, 5 | tillage and planting are pre-determined and RL agents cannot affect them. 6 | Here, we explain how to specify and modify these default operations. 7 | 8 | During initialization, the environments take a keyword argument that is 9 | `opearation_file`. This argument specifies the name of the operation file 10 | that contains the default operation that will be executed in the 11 | environments. A few things are important to notice about this file: 12 | 1. It should be in a Cycles-compatible format (see [here](https://psumodeling.github.io/Cycles/#management-operations-file-operation)). 13 | 2. It should be contained in the Cycles input folder, i.e., `cycles/input`. 14 | 3. If it contains operations that belong to the decision space, those will 15 | be removed. We will clarify this with an example below. 16 | 17 | An example of a file containing tillage, planting, and fertilization 18 | operations could be the following. Let us assume that this file is stored 19 | in `cycles/input/corn_operation.operation` (for an example of how to 20 | modify this kind of files directly in Python with cyclesgym's managers, see 21 | [here](../notebooks/example_corn_fertilization_env.ipynb)). 22 | 23 | 24 | ``` 25 | ############################################################################## 26 | # A continuous corn rotation, fertilized with 150 kg/ha UAN, completely 27 | # no-till except for slight disturbance by planter disks 28 | ############################################################################## 29 | 30 | FIXED_FERTILIZATION 31 | YEAR 1 32 | DOY 110 33 | SOURCE UreaAmmoniumNitrate 34 | MASS 200 # kg/ha 35 | FORM Liquid 36 | METHOD Broadcast 37 | LAYER 1 38 | C_Organic 0.25 # % of total mass 39 | C_Charcoal 0 40 | N_Organic 0 41 | N_Charcoal 0 42 | N_NH4 0.50 # % of total mass 43 | N_NO3 0.25 # % of total mass 44 | P_Organic 0 # % of total mass 45 | P_CHARCOAL 0 # % of total mass 46 | P_INORGANIC 0 # % of total mass 47 | K 0 # % of total mass 48 | S 0 # % of total mass 49 | 50 | TILLAGE 51 | YEAR 1 52 | DOY 110 53 | TOOL Planter_double_disk_opnr 54 | DEPTH 0.03 55 | SOIL_DISTURB_RATIO 5 56 | MIXING_EFFICIENCY 0.071554 57 | CROP_NAME N/A 58 | FRAC_THERMAL_TIME 0.0 59 | KILL_EFFICIENCY 0.0 60 | 61 | PLANTING 62 | YEAR 1 63 | DOY 110 64 | END_DOY -999 65 | MAX_SMC -999 66 | MIN_SMC 0.0 67 | MIN_SOIL_TEMP 0.0 68 | CROP CornRM.90 69 | USE_AUTO_IRR 0 70 | USE_AUTO_FERT 0 71 | FRACTION 1.0 72 | CLIPPING_START 1 73 | CLIPPING_END 366 74 | 75 | ``` 76 | 77 | Then, we can initialize a fertilization environment as follows 78 | 79 | ``` 80 | from cyclesgym.envs import Corn 81 | 82 | env = Corn(delta=7, # Time interval is one week 83 | n_actions=11, # There are 11 actions available 84 | maxN=150, # Actions are spaced uniformly in [0, 150] 85 | operation_file='corn_operation.operation') 86 | ``` 87 | 88 | When running this environment, planting and tillage will be performed as 89 | specified by the original file. However, fertilization will be modified. In 90 | particular, since this is a N fertilization environment where RL agents 91 | must make decisions concerning N fertilization, the masses of NH4 and NO3 92 | will be set to zero at the beginning of the simulation (and eventually 93 | modified further as the episode unrolls and the agent makes decisions). The 94 | carbon fertilization is not modified as this is not part of the decision 95 | space of this environment. -------------------------------------------------------------------------------- /cyclesgym/__init__.py: -------------------------------------------------------------------------------- 1 | from gym.envs.registration import register 2 | from cyclesgym.envs.weather_generator import FixedWeatherGenerator, WeatherShuffler 3 | from cyclesgym.utils.paths import CYCLES_PATH 4 | import numpy as np 5 | 6 | 7 | def env_name(location: str, random_weather: bool, exp_type: str, duration: str = ''): 8 | weather = 'RW' if random_weather else 'FW' # Random weather or fixed weather 9 | if exp_type == 'fertilization': 10 | return f'Corn{duration}{location}{weather}-v1' 11 | elif exp_type == 'crop_planning': 12 | return f'CropPlanning{location}{weather}-v1' 13 | 14 | 15 | def get_weather(start_year, end_year, random=False, location='RockSprings', 16 | sampling_start_year=1980, sampling_end_year=2005): 17 | if random: 18 | target_year_range = np.arange(start_year, end_year + 1) 19 | weather_generator_class = WeatherShuffler 20 | weather_generator_kwargs = dict(n_weather_samples=100, 21 | sampling_start_year=sampling_start_year, 22 | sampling_end_year=sampling_end_year, 23 | base_weather_file=CYCLES_PATH.joinpath( 24 | 'input', f'{location}.weather'), 25 | target_year_range=target_year_range) 26 | else: 27 | weather_generator_class = FixedWeatherGenerator 28 | weather_generator_kwargs = dict(base_weather_file=CYCLES_PATH.joinpath( 29 | 'input', f'{location}.weather')) 30 | return weather_generator_class, weather_generator_kwargs 31 | 32 | 33 | def register_fertilization_envs(): 34 | common_kwargs = {'delta': 7, 'n_actions': 11, 'maxN': 150, 'start_year': 1980} 35 | durations = [(1, 'Short'), (2, 'Middle'), (5, 'Long')] 36 | locations = ['RockSprings', 'NewHolland'] 37 | 38 | # Loop through duration, random vs fixed weather, location 39 | for rw in [True, False]: 40 | for l in locations: 41 | for d, d_name in durations: 42 | # Get env name 43 | name = env_name(duration=d_name, location=l, random_weather=rw, exp_type='fertilization') 44 | 45 | # Copy common kwargs and update using duration and weather 46 | kwargs = common_kwargs.copy() 47 | 48 | start_year = kwargs['start_year'] 49 | end_year = start_year + (d - 1) 50 | 51 | weather_generator_class, weather_generator_kwargs = \ 52 | get_weather(start_year, end_year, random=rw, location=l) 53 | 54 | kwargs.update(dict(end_year=end_year, 55 | weather_generator_class=weather_generator_class, 56 | weather_generator_kwargs=weather_generator_kwargs)) 57 | register( 58 | id=name, 59 | entry_point='cyclesgym.envs:Corn', 60 | kwargs=kwargs 61 | ) 62 | 63 | 64 | def register_crop_planning_envs(): 65 | start_year = 1980 66 | end_year = 1998 67 | common_kwargs = dict(start_year=start_year, end_year=end_year, 68 | rotation_crops=['CornSilageRM.90', 'SoybeanMG.3']) 69 | locations = ['RockSprings', 'NewHolland'] 70 | 71 | # Loop through random vs fixed weather, location 72 | for rw in [True, False]: 73 | for l in locations: 74 | name = env_name(location=l, random_weather=rw, exp_type='crop_planning') 75 | 76 | kwargs = common_kwargs.copy() 77 | 78 | weather_generator_class, weather_generator_kwargs = \ 79 | get_weather(start_year, end_year, random=rw, location=l, 80 | sampling_start_year=start_year, 81 | sampling_end_year=end_year) 82 | 83 | kwargs.update(dict(end_year=end_year, 84 | weather_generator_class=weather_generator_class, 85 | weather_generator_kwargs=weather_generator_kwargs)) 86 | register( 87 | id=name, 88 | entry_point='cyclesgym.envs:CropPlanningFixedPlanting', 89 | kwargs=kwargs 90 | ) 91 | 92 | 93 | register_fertilization_envs() 94 | register_crop_planning_envs() 95 | -------------------------------------------------------------------------------- /experiments/crop_planning/policy_visualization.py: -------------------------------------------------------------------------------- 1 | from stable_baselines3 import PPO 2 | from train import Train 3 | from cyclesgym.utils.utils import _evaluate_policy 4 | from cyclesgym.utils.paths import PROJECT_PATH 5 | from cyclesgym.utils.plot_utils import set_up_plt 6 | import numpy as np 7 | import matplotlib.pyplot as plt 8 | import wandb 9 | from matplotlib.cm import get_cmap 10 | 11 | set_up_plt() 12 | 13 | 14 | def plot_crop_planning_policy_prob(episode_probs): 15 | color = get_cmap('Accent').colors 16 | crop_prob = np.array(list(zip(*episode_probs))[0]).squeeze() 17 | planting_prob = np.array(list(zip(*episode_probs))[1]).squeeze() 18 | plt.stackplot(1 + np.arange(crop_prob.shape[0]), *crop_prob.T) 19 | plt.xticks(1 + np.arange(crop_prob.shape[0])) 20 | plt.xlabel('Years') 21 | plt.ylabel('Probability') 22 | plt.xlim(1, crop_prob.shape[0]) 23 | plt.ylim(0, 1) 24 | plt.show() 25 | 26 | plt.stackplot(1 + np.arange(planting_prob.shape[0]), *planting_prob.T, 27 | colors=color[:planting_prob.shape[0]]) 28 | plt.xticks(1 + np.arange(planting_prob.shape[0])) 29 | plt.xlabel('Years') 30 | plt.ylabel('Probability') 31 | plt.xlim(1, planting_prob.shape[0]) 32 | plt.ylim(0, 1) 33 | plt.show() 34 | 35 | 36 | def plot_crop_planning_policy_det(episode_actions, episode_rewards, run, eval_env_class, eval_test): 37 | color = get_cmap('Accent').colors 38 | 39 | if 'Rotation' in eval_env_class: 40 | title = 'Rotation observations' 41 | else: 42 | title = 'SoilN observations' 43 | 44 | episode_actions = episode_actions.squeeze() 45 | planting_date = episode_actions[:, 1] 46 | planting_date = 90 + planting_date * 7 47 | crop = episode_actions[:, 0] 48 | fig, ax = plt.subplots(2, 1, sharex=True) 49 | 50 | x = 1980 + np.arange(planting_date.size) 51 | 52 | ax[0].bar(x, np.array(episode_rewards)/1000., 53 | color=[color[c] for c in crop]) 54 | ax[0].set_ylabel('Year reward [k\$]') 55 | ax[0].set_title(title) 56 | 57 | ax[1].scatter(x, planting_date, color=[color[c] for c in crop], 58 | s=100, marker='o') 59 | ax[1].set_xticks(x) 60 | ax[1].set_xlabel('Years') 61 | ax[1].set_ylabel('Planting date [DOY]') 62 | ax[1].set_xlim(min(x) + 0.5, max(x) + 0.5) 63 | 64 | fig.autofmt_xdate() 65 | 66 | plt.legend() 67 | plt.savefig(PROJECT_PATH.joinpath('figures/crop_planning_policies/' + run.path[-1] + '_' +\ 68 | eval_test + '.pdf'), bbox_inches='tight') 69 | plt.show() 70 | 71 | 72 | if __name__ == '__main__': 73 | 74 | config = dict(train_start_year=1980, train_end_year=1998, eval_start_year=1998, eval_end_year=2016, 75 | total_timesteps=1000000, eval_freq=1000, n_steps=80, batch_size=64, n_epochs=10, run_id=0, 76 | norm_reward=True, method="PPO", verbose=1, n_process=8, device='auto') 77 | api = wandb.Api() 78 | 79 | # Project is specified by 80 | runs = api.runs("koralabs/experiments_crop_planning") 81 | 82 | summary_list, summary_at_max, config_list, name_list = [], [], [], [] 83 | for run in runs: 84 | dir = [dir for dir in PROJECT_PATH.joinpath('wandb').iterdir() if run.path[-1] in str(dir)] 85 | if len(dir) > 0: 86 | print(run) 87 | dir = dir[0] 88 | file = dir.joinpath('files/models/eval_det/best_model') 89 | run_config = run.config 90 | print(run_config) 91 | 92 | eval_env_class = 'CropPlanningFixedPlanting' 93 | if run_config.get('non_adaptive', False) == 'True': 94 | eval_env_class = 'CropPlanningFixedPlantingRotationObserver' 95 | 96 | config['eval_env_class'] = eval_env_class 97 | 98 | envs = Train(config).create_envs() 99 | 100 | model = PPO.load(file, device='cpu') 101 | 102 | def get_plot(model, env, run, eval_env_class, eval_test, deterministic=True): 103 | mean_reward, std_reward, episode_actions, episode_rewards, episode_probs, episode_action_rewards = \ 104 | _evaluate_policy(model, env, n_eval_episodes=1, deterministic=deterministic) 105 | plot_crop_planning_policy_det(episode_actions, episode_action_rewards, run, eval_env_class, eval_test) 106 | 107 | for env, eval_test in zip(envs, ['train', 'rs98', 'nh98', 'nh15']): 108 | get_plot(model, env, run, eval_env_class, eval_test) 109 | 110 | 111 | -------------------------------------------------------------------------------- /cyclesgym/managers/operation.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from cyclesgym.managers.common import InputFileManager 4 | 5 | __all__ = ['OperationManager'] 6 | 7 | 8 | class OperationManager(InputFileManager): 9 | OPERATION_TYPES = ( 10 | 'TILLAGE', 'PLANTING', 'FIXED_FERTILIZATION', 'FIXED_IRRIGATION', 11 | 'AUTO_IRRIGATION') 12 | 13 | def __init__(self, fname=None): 14 | self.op_dict = dict() 15 | super().__init__(fname) 16 | 17 | def _valid_input_file(self, fname): 18 | if fname is None: 19 | return True 20 | else: 21 | return super(OperationManager, self)._valid_input_file(fname) and fname.suffix == '.operation' 22 | 23 | def _valid_output_file(self, fname): 24 | return super(OperationManager, self)._valid_output_file(fname) and fname.suffix == '.operation' 25 | 26 | def _parse(self, fname): 27 | if fname is not None: 28 | with open(fname, 'r') as f: 29 | line_n = None 30 | k = None 31 | self.op_dict = dict() 32 | # TODO: using (year, doy, operation_type) as key, creates conflicts when there is fertilization applied to different layers on the same day 33 | for line in f.readlines(): 34 | if line.startswith(self.OPERATION_TYPES): 35 | operation = line.strip(' \n') 36 | line_n = 0 37 | if k is not None: 38 | self.op_dict.update({k: v}) 39 | if line_n is not None: 40 | if line_n == 1: 41 | year = int(line.split()[1]) 42 | if line_n == 2: 43 | doy = int(line.split()[1]) 44 | k = (year, doy, operation) 45 | v = dict() 46 | if line_n > 2: 47 | if len(line.split()) > 0: 48 | argument = line.split()[0] 49 | value = line.split()[1] 50 | try: 51 | value = float(value) 52 | except ValueError: 53 | pass 54 | v.update({argument: value}) 55 | line_n += 1 56 | if k is not None: 57 | self.op_dict.update({k: v}) 58 | else: 59 | self.op_dict = {} 60 | 61 | def _to_str(self): 62 | s = '' 63 | for k, v in self.op_dict.items(): 64 | year, doy, operation = k 65 | s += operation + f"\n{'YEAR':30}\t{year}\n{'DOY':30}\t{doy}\n" 66 | for argument, value in v.items(): 67 | if argument == 'operation': 68 | pass 69 | else: 70 | s += f'{argument:30}\t{value}\n' 71 | s += '\n' 72 | return s 73 | 74 | def save(self, fname, force=True): 75 | self.sort_operation() 76 | super().save(fname, force) 77 | 78 | def count_same_day_events(self, year, doy): 79 | return len(self.get_same_day_events(year, doy)) 80 | 81 | def get_same_day_events(self, year, doy): 82 | return {k: v for k, v in self.op_dict.items() if k[0] == year and k[1] == doy} 83 | 84 | def sort_operation(self): 85 | self.op_dict = dict(sorted(self.op_dict.items())) 86 | 87 | def _insert_single_operation(self, op_key, op_val, force=True): 88 | year, doy, operation = op_key 89 | assert operation in self.OPERATION_TYPES, \ 90 | f'Operation must be one of the following {self.OPERATION_TYPES}' 91 | collisions = [operation == k[2] for k in self.get_same_day_events(year, doy).keys()] 92 | if any(collisions): 93 | warnings.warn(f'There is already an operation {operation} for they day {doy} of year {year}.') 94 | if force: 95 | self.op_dict.update({op_key: op_val}) 96 | else: 97 | pass 98 | else: 99 | self.op_dict.update({op_key: op_val}) 100 | 101 | def insert_new_operations(self, op, force=True): 102 | for k, v in op.items(): 103 | self._insert_single_operation(k, v, force=force) 104 | self.sort_operation() 105 | 106 | def _delete_single_operation(self, k): 107 | self.op_dict.pop(k) 108 | 109 | def delete_operations(self, keys): 110 | for k in keys: 111 | self._delete_single_operation(k) -------------------------------------------------------------------------------- /notebooks/training_rl_agent.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": { 6 | "pycharm": { 7 | "name": "#%% md\n" 8 | } 9 | }, 10 | "source": [ 11 | "# Training an RL agent with a standard environment\n", 12 | "In this notebook, we show how to train an RL agent using the stable-baselines3 library over an environemnt provided by CyclesGym." 13 | ] 14 | }, 15 | { 16 | "cell_type": "code", 17 | "execution_count": null, 18 | "metadata": { 19 | "pycharm": { 20 | "name": "#%%\n" 21 | } 22 | }, 23 | "outputs": [], 24 | "source": [ 25 | "import gym\n", 26 | "import cyclesgym\n", 27 | "import numpy as np\n", 28 | "import wandb\n", 29 | "\n", 30 | "from stable_baselines3 import PPO\n", 31 | "from stable_baselines3.common.vec_env import SubprocVecEnv, VecMonitor, VecNormalize\n", 32 | "from wandb.integration.sb3 import WandbCallback\n", 33 | "\n", 34 | "from cyclesgym.utils.paths import PROJECT_PATH" 35 | ] 36 | }, 37 | { 38 | "cell_type": "markdown", 39 | "metadata": { 40 | "pycharm": { 41 | "name": "#%% md\n" 42 | } 43 | }, 44 | "source": [ 45 | "First, we initalize a wandb session to track all the parameters of interest as well as statistics recorded during training" 46 | ] 47 | }, 48 | { 49 | "cell_type": "code", 50 | "execution_count": null, 51 | "metadata": { 52 | "pycharm": { 53 | "name": "#%%\n" 54 | } 55 | }, 56 | "outputs": [], 57 | "source": [ 58 | "# Track PPO parameters with wandb\n", 59 | "config = dict(total_timesteps=1000, \n", 60 | " n_steps=80, \n", 61 | " batch_size=80, \n", 62 | " n_epochs=10, run_id=0, \n", 63 | " verbose=1, \n", 64 | " n_process=1, \n", 65 | " device='cpu')\n", 66 | "\n", 67 | "wandb.init(\n", 68 | " config=config,\n", 69 | " sync_tensorboard=True,\n", 70 | " project='notebook_experiments',\n", 71 | " monitor_gym=True,\n", 72 | " save_code=True,\n", 73 | " dir=PROJECT_PATH,\n", 74 | ")\n", 75 | "\n", 76 | "config = wandb.config" 77 | ] 78 | }, 79 | { 80 | "cell_type": "markdown", 81 | "metadata": { 82 | "pycharm": { 83 | "name": "#%% md\n" 84 | } 85 | }, 86 | "source": [ 87 | "Subsequently, we initialize the vectorized environment" 88 | ] 89 | }, 90 | { 91 | "cell_type": "code", 92 | "execution_count": null, 93 | "metadata": { 94 | "pycharm": { 95 | "name": "#%%\n" 96 | } 97 | }, 98 | "outputs": [], 99 | "source": [ 100 | "def env_maker():\n", 101 | " # 1-year fertilization environment with fixed weather in Rock Springs\n", 102 | " env = gym.make('CornShortRockSpringsFW-v1')\n", 103 | " return gym.wrappers.RecordEpisodeStatistics(env)\n", 104 | "\n", 105 | "# Vectorize environment\n", 106 | "env = SubprocVecEnv([env_maker for _ in range(config['n_process'])], start_method='fork')\n", 107 | "\n", 108 | "# Monitor\n", 109 | "env = VecMonitor(env, 'runs')\n", 110 | "\n", 111 | "# Normalize values (clipping range high so that, in practice, it does not happen)\n", 112 | "env = VecNormalize(env, norm_obs=True, norm_reward=True, clip_obs=5000., clip_reward=5000.)\n" 113 | ] 114 | }, 115 | { 116 | "cell_type": "markdown", 117 | "metadata": { 118 | "pycharm": { 119 | "name": "#%% md\n" 120 | } 121 | }, 122 | "source": [ 123 | "Finally, we train the model. We can monitor the training in wandb with the link we obtained above after the initializing the wandb session." 124 | ] 125 | }, 126 | { 127 | "cell_type": "code", 128 | "execution_count": null, 129 | "metadata": { 130 | "pycharm": { 131 | "name": "#%%\n" 132 | } 133 | }, 134 | "outputs": [], 135 | "source": [ 136 | "model = PPO('MlpPolicy', env, n_steps=config['n_steps'], batch_size=config['batch_size'],\n", 137 | " n_epochs=config['n_epochs'], verbose=config['verbose'], tensorboard_log=wandb.run.dir,\n", 138 | " device=config['device'])\n", 139 | "\n", 140 | "model.learn(total_timesteps=config[\"total_timesteps\"], callback=[WandbCallback()])" 141 | ] 142 | } 143 | ], 144 | "metadata": { 145 | "anaconda-cloud": {}, 146 | "kernelspec": { 147 | "display_name": "cyclesgym", 148 | "language": "python", 149 | "name": "cyclesgym" 150 | }, 151 | "language_info": { 152 | "codemirror_mode": { 153 | "name": "ipython", 154 | "version": 3 155 | }, 156 | "file_extension": ".py", 157 | "mimetype": "text/x-python", 158 | "name": "python", 159 | "nbconvert_exporter": "python", 160 | "pygments_lexer": "ipython3", 161 | "version": "3.8.13" 162 | } 163 | }, 164 | "nbformat": 4, 165 | "nbformat_minor": 2 166 | } -------------------------------------------------------------------------------- /cyclesgym/envs/constrainers.py: -------------------------------------------------------------------------------- 1 | from cyclesgym.envs.utils import cap_date, date2ydoy 2 | from abc import ABC, abstractmethod 3 | from cyclesgym.managers import SoilNManager 4 | from typing import List 5 | import datetime 6 | 7 | __all__ = ['DummyConstrainer', 'LeachingConstrainer', 8 | 'FertilizationEventConstrainer', 'TotalNitrogenConstrainer', 9 | 'compound_constrainer'] 10 | 11 | 12 | # TODO: Bad interface, improve 13 | class Constrainer(ABC): 14 | def __init__(self): 15 | self.constraint_names = None 16 | self.n_constraints = None 17 | 18 | def _get_constraint_dict(self, constraint_values): 19 | if not hasattr(constraint_values, '__iter__'): 20 | constraint_values = [constraint_values] 21 | return {f'cost_{name}': value for name, value in 22 | zip(self.constraint_names, constraint_values)} 23 | 24 | @abstractmethod 25 | def compute_constraint(self, date, *args, **kwargs): 26 | pass 27 | 28 | 29 | class DummyConstrainer(Constrainer): 30 | """ 31 | Dummy constrained that returns an empty dictionary for compatibility. 32 | """ 33 | def compute_constraint(self, date, *args, **kwargs): 34 | return {} 35 | 36 | 37 | 38 | class LeachingConstrainer(Constrainer): 39 | def __init__(self, 40 | soil_n_manager: SoilNManager, 41 | end_year: int): 42 | self.manager = soil_n_manager 43 | self.end_year = end_year 44 | self. leaching_columns = [13, # 'NO3 LEACHING' 45 | 14, # 'NH4 LEACHING' 46 | 15, # 'NO3 BYPASS' 47 | 16] # 'NH4 BYPASS' 48 | 49 | self.volatilization_coulumns = [10] # 'NH3 VOLATILIZ' 50 | self.emission_columns = [9, # N2O FROM NITRIF 51 | 12] # N2O FROM DENIT 52 | 53 | self.constraint_names = ['leaching', 'volatilization', 'emission'] 54 | self.n_constraints = len(self.constraint_names) 55 | 56 | def compute_constraint(self, date, *args, **kwargs): 57 | # Make sure we did not go into not simulated year when advancing time 58 | date = cap_date(date, self.end_year) 59 | 60 | # Get data 61 | year, doy = date2ydoy(date) 62 | data = self.manager.get_day(year, doy) 63 | 64 | if not data.empty: 65 | leaching = data.iloc[0, self.leaching_columns] 66 | volatilization = data.iloc[0, self.volatilization_coulumns] 67 | emission = data.iloc[0, self.emission_columns] 68 | constraint_values = [sum(leaching), sum(volatilization), sum(emission)] 69 | else: 70 | print('Empty!') 71 | 72 | return self._get_constraint_dict(constraint_values) 73 | 74 | 75 | class FertilizationEventConstrainer(Constrainer): 76 | def __init__(self): 77 | self.constraint_names = ['n_fertilization_events'] 78 | self.n_constraints = len(self.constraint_names) 79 | 80 | def compute_constraint(self, date, action, *args, **kwargs): 81 | # action = kwargs['action'] 82 | constraint_values = int(action > 0) 83 | return self._get_constraint_dict(constraint_values) 84 | 85 | 86 | class TotalNitrogenConstrainer(Constrainer): 87 | def __init__(self): 88 | self.constraint_names = ['total_n'] 89 | self.n_constraints = len(self.constraint_names) 90 | 91 | def compute_constraint(self, date, action, *args, **kwargs): 92 | return self._get_constraint_dict(constraint_values=action) 93 | 94 | 95 | def compound_constrainer(c_list: List[Constrainer]): 96 | 97 | class Compound(object): 98 | def __init__(self, c_list: List[Constrainer]): 99 | self.c_list = c_list 100 | self.constraint_names = None 101 | self.n_constraints = sum([c.n_constraints for c in c_list]) 102 | 103 | def compute_constraint(self, date: datetime.date, **kwargs): 104 | # List of dicts 105 | list_c_dict = [c.compute_constraint(date, **kwargs) for c in self.c_list] 106 | 107 | # Convert to single dict 108 | constraints_dict = {} 109 | 110 | for c_dict in list_c_dict: 111 | constraints_dict.update(c_dict) 112 | 113 | # Get names 114 | self.constraint_names = [name for c in c_list for name in c.constraint_names] 115 | 116 | # TODO: Implement check we have not modified length 117 | # new_n_constraints = sum([o.size for o in obs]) 118 | # if new_Nobs != self.Nobs: 119 | # print(f'Warning: runtime number of observation for {self} is different then the original' 120 | # f'one: Before: {self.Nobs}, runtime: {new_Nobs}') 121 | # print(self.obs_list) 122 | # self.Nobs = new_Nobs 123 | return constraints_dict 124 | 125 | return Compound(c_list) 126 | 127 | 128 | -------------------------------------------------------------------------------- /cyclesgym/tests/test_implementers.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import shutil 3 | 4 | from cyclesgym.envs.implementers import Fertilizer 5 | from cyclesgym.managers import OperationManager 6 | from cyclesgym.envs.utils import ydoy2date 7 | 8 | from cyclesgym.utils.paths import TEST_PATH 9 | 10 | 11 | class TestFertilizer(unittest.TestCase): 12 | def setUp(self) -> None: 13 | # Copy file to make sure we do not modify the original one 14 | src = TEST_PATH.joinpath('NCornTest.operation') 15 | self.op_fname = TEST_PATH.joinpath('NCornTest_copy.operation') 16 | shutil.copy(src, self.op_fname) 17 | 18 | # Init observer 19 | self.op_manager = OperationManager(self.op_fname) 20 | 21 | # Init Fertilizer 22 | self.fert = Fertilizer(operation_manager=self.op_manager, 23 | operation_fname=self.op_fname, 24 | affected_nutrients= ['N_NH4', 'N_NO3'], 25 | start_year=1980) 26 | 27 | def tearDown(self) -> None: 28 | # Remove copied operation file 29 | self.op_fname.unlink() 30 | 31 | def test_new_action(self): 32 | # No action on a day where nothing used to happen => Not new 33 | assert not self.fert._is_new_action( 34 | year=1, doy=105, new_masses={'N_NH4': 0, 'N_NO3': 0}) 35 | 36 | # Same fertilization action as the one already in the file => Not new 37 | assert not self.fert._is_new_action( 38 | year=1, doy=106, new_masses={'N_NH4': 112.5, 'N_NO3': 37.5}) 39 | 40 | # No action on a day when we used to fertilize => New 41 | assert self.fert._is_new_action( 42 | year=1, doy=106, new_masses={'N_NH4': 0, 'N_NO3': 0}) 43 | 44 | # Fertilize on a day when we used to do nothing => New 45 | assert self.fert._is_new_action( 46 | year=1, doy=105, new_masses={'N_NH4': 112.5, 'N_NO3': 37.5}) 47 | 48 | def test_update_operation(self): 49 | # Incremental mode 50 | base_op = {'SOURCE': 'UreaAmmoniumNitrate', 'MASS': 80, 51 | 'FORM': 'Liquid', 'METHOD': 'Broadcast', 'LAYER': 1, 52 | 'C_Organic': 0.5, 'C_Charcoal': 0., 'N_Organic': 0., 53 | 'N_Charcoal': 0., 'N_NH4': 0., 'N_NO3': 0., 54 | 'P_Organic': 0., 'P_CHARCOAL': 0., 'P_INORGANIC': 0., 55 | 'K': 0.5, 'S': 0.} 56 | 57 | year = 1 58 | doy = 106 59 | new_op = self.fert._update_operation( 60 | year=1, doy=106, old_op=base_op, 61 | new_masses={'N_NH4': 15, 'N_NO3': 5}, mode='increment') 62 | 63 | target_new_op = base_op.copy() 64 | target_new_op.update( 65 | {'MASS': 100., 'C_Organic': 0.4, 'K': 0.4, 'N_NH4': 0.15, 66 | 'N_NO3': 0.05}) 67 | 68 | assert target_new_op == new_op[(year, doy, 'FIXED_FERTILIZATION')] 69 | 70 | # Absolute mode 71 | base_op = {'SOURCE': 'UreaAmmoniumNitrate', 'MASS': 80, 72 | 'FORM': 'Liquid', 'METHOD': 'Broadcast', 'LAYER': 1, 73 | 'C_Organic': 0.25, 'C_Charcoal': 0, 'N_Organic': 0, 74 | 'N_Charcoal': 0, 'N_NH4': 0.25, 'N_NO3': 0.25, 75 | 'P_Organic': 0, 'P_CHARCOAL': 0, 'P_INORGANIC': 0, 76 | 'K': 0.25, 'S': 0} 77 | 78 | new_op = self.fert._update_operation( 79 | year=1, doy=106, old_op=base_op, 80 | new_masses={'N_NH4': 0, 'N_NO3': 0}, mode='absolute') 81 | 82 | target_new_op = base_op.copy() 83 | target_new_op.update( 84 | {'MASS': 40, 'C_Organic': 0.5, 'K': 0.5, 'N_NH4': 0, 'N_NO3': 0}) 85 | 86 | assert target_new_op == new_op[(year, doy, 'FIXED_FERTILIZATION')] 87 | 88 | def test_implement_with_collision(self): 89 | operations = self.fert.operation_manager.op_dict.copy() 90 | target_op = {'SOURCE': 'Unknown', 'MASS': 20.0, 91 | 'FORM': 'Unknown', 'METHOD': 'Unknown', 'LAYER': 1., 92 | 'C_Organic': 0., 'C_Charcoal': 0., 'N_Organic': 0., 93 | 'N_Charcoal': 0., 'N_NH4': 0.75, 'N_NO3': 0.25, 94 | 'P_Organic': 0., 'P_CHARCOAL': 0., 'P_INORGANIC': 0., 95 | 'K': 0., 'S': 0.} 96 | operations.update({(1, 106, 'FIXED_FERTILIZATION'): target_op}) 97 | 98 | self.fert.implement_action(date=ydoy2date(1980, 106), 99 | operation_details={'N_NH4': 15., 100 | 'N_NO3': 5.}) 101 | 102 | # Check manager is equal 103 | assert self.fert.operation_manager.op_dict == operations 104 | 105 | # Check file is equal 106 | new_manager = OperationManager(self.fert.operation_fname) 107 | assert new_manager.op_dict == self.fert.operation_manager.op_dict 108 | 109 | def test_implement_no_collision(self): 110 | operations = self.fert.operation_manager.op_dict.copy() 111 | target_op = {'SOURCE': 'Unknown', 'MASS': 20.0, 112 | 'FORM': 'Unknown', 'METHOD': 'Unknown', 'LAYER': 1., 113 | 'C_Organic': 0., 'C_Charcoal': 0., 'N_Organic': 0., 114 | 'N_Charcoal': 0., 'N_NH4': 0.75, 'N_NO3': 0.25, 115 | 'P_Organic': 0., 'P_CHARCOAL': 0., 'P_INORGANIC': 0., 116 | 'K': 0., 'S': 0.} 117 | operations.update({(1, 105, 'FIXED_FERTILIZATION'): target_op}) 118 | 119 | self.fert.implement_action(date=ydoy2date(1980, 105), 120 | operation_details={'N_NH4': 15., 121 | 'N_NO3': 5.}) 122 | 123 | # Check manager is equal 124 | assert self.fert.operation_manager.op_dict == operations 125 | 126 | # Check file is equal 127 | new_manager = OperationManager(self.fert.operation_fname) 128 | assert new_manager.op_dict == self.fert.operation_manager.op_dict 129 | 130 | def test_reset(self): 131 | pass 132 | 133 | 134 | if __name__ == '__main__': 135 | unittest.main() 136 | 137 | -------------------------------------------------------------------------------- /cyclesgym/tests/test_random_weather.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import shutil 3 | import subprocess 4 | import unittest 5 | import unittest.mock as mock 6 | import numpy as np 7 | from cyclesgym.envs.corn import Corn 8 | from cyclesgym.envs.utils import date2ydoy 9 | from cyclesgym.managers import WeatherManager, CropManager, SeasonManager 10 | from cyclesgym.envs.weather_generator import WeatherShuffler 11 | from cyclesgym.utils.paths import CYCLES_PATH, TEST_PATH 12 | 13 | TEST_FILENAMES = ['CornRandomWeatherTest.ctrl', 14 | 'NCornTest.operation'] 15 | 16 | 17 | def copy_cycles_test_files(): 18 | # Copy test files in cycles input directory 19 | for n in TEST_FILENAMES: 20 | shutil.copy(TEST_PATH.joinpath(n), 21 | CYCLES_PATH.joinpath('input', n)) 22 | 23 | 24 | def remove_cycles_test_files(): 25 | # Remove all files copied to input folder 26 | for n in TEST_FILENAMES: 27 | pathlib.Path(CYCLES_PATH.joinpath('input', n)).unlink() 28 | # Remove the output of simulation started manually 29 | try: 30 | shutil.rmtree(pathlib.Path(CYCLES_PATH.joinpath( 31 | 'output', TEST_FILENAMES[0].replace('.ctrl', '')))) 32 | except FileNotFoundError: 33 | pass 34 | 35 | 36 | def fixed_start_year(valid_start_years): 37 | return valid_start_years[0] 38 | 39 | 40 | def fixed_perm(sampled_years): 41 | copy = sampled_years.copy() 42 | for j, i in enumerate([2, 0, 1]): 43 | sampled_years[j] = copy[i] 44 | 45 | 46 | class TestShuffleWeather(unittest.TestCase): 47 | 48 | def setUp(self): 49 | copy_cycles_test_files() 50 | 51 | def tearDown(self): 52 | remove_cycles_test_files() 53 | 54 | def test_shuffling_dummy_weather(self): 55 | fname = TEST_PATH.joinpath('DummyWeatherNonShuffle.weather') 56 | fname_shuffled = TEST_PATH.joinpath('DummyWeatherShuffle.weather') 57 | 58 | shuffler = WeatherShuffler(n_weather_samples=1, 59 | sampling_start_year=1981, 60 | sampling_end_year=1983, 61 | base_weather_file=fname, 62 | target_year_range=[1981, 1982, 1983]) 63 | 64 | shuffled_manager = WeatherManager(fname_shuffled) 65 | 66 | # Create weather 67 | with mock.patch('numpy.random.choice', fixed_start_year), mock.patch('random.shuffle', fixed_perm): 68 | shuffler.generate_weather() 69 | new_weather = WeatherManager(shuffler._get_weather_dir().joinpath(shuffler.weather_list[0])).mutables 70 | 71 | assert shuffled_manager.mutables.equals(new_weather) 72 | 73 | def test_env_against_cycles_with_shuffled_weather(self): 74 | """ 75 | Check the manual and env simulation are the same for same management 76 | and different for different management. 77 | """ 78 | # Start normal simulation and parse results 79 | base_ctrl_man = TEST_FILENAMES[0].replace('.ctrl', '') 80 | shuffler = WeatherShuffler(n_weather_samples=1, 81 | sampling_start_year=1980, 82 | sampling_end_year=1982, 83 | base_weather_file=CYCLES_PATH.joinpath('input/RockSprings.weather'), 84 | target_year_range=[1980, 1981, 1982]) 85 | with mock.patch('numpy.random.choice', fixed_start_year), mock.patch('random.shuffle', fixed_perm): 86 | shuffler.generate_weather() 87 | 88 | weather_file = shuffler._get_weather_dir().joinpath(shuffler.weather_list[0]) 89 | shutil.copy(weather_file, 90 | CYCLES_PATH.joinpath('input', shuffler.weather_list[0])) 91 | 92 | self._call_cycles(base_ctrl_man) 93 | 94 | pathlib.Path(CYCLES_PATH.joinpath( 95 | 'input', shuffler.weather_list[0])).unlink() 96 | crop_man = CropManager( 97 | CYCLES_PATH.joinpath('output', base_ctrl_man, 'CornRM.90.dat')) 98 | season_man = SeasonManager( 99 | CYCLES_PATH.joinpath('output', base_ctrl_man, 'season.dat')) 100 | 101 | # Start gym env 102 | operation_file = TEST_FILENAMES[1] 103 | with mock.patch('numpy.random.choice', fixed_start_year), mock.patch('random.shuffle', fixed_perm): 104 | start_year = 1980 105 | end_year = 1982 106 | target_year_range = np.arange(start_year, end_year + 1) 107 | weather_generator_kwargs = dict(n_weather_samples=1, 108 | sampling_start_year=1980, 109 | sampling_end_year=1982, 110 | target_year_range=target_year_range, 111 | base_weather_file=CYCLES_PATH.joinpath('input', 'RockSprings.weather')) 112 | 113 | 114 | env = Corn(delta=1, maxN=150, n_actions=16, start_year=start_year, end_year=end_year, operation_file=operation_file, 115 | use_reinit=False, weather_generator_class=WeatherShuffler, weather_generator_kwargs=weather_generator_kwargs) 116 | 117 | # Run simulation with same management and compare 118 | env.reset() 119 | while True: 120 | _, doy = date2ydoy(env.date) 121 | a = 15 if doy == 106 else 0 122 | _, _, done, _ = env.step(a) 123 | if done: 124 | break 125 | 126 | # Check crop 127 | crop_output_file = env._get_output_dir().joinpath('CornRM.90.dat') 128 | crop_env = CropManager(crop_output_file) 129 | assert crop_env.crop_state.equals(crop_man.crop_state) 130 | 131 | # Check yield 132 | season_output_file = env._get_output_dir().joinpath('season.dat') 133 | season_env = SeasonManager(season_output_file) 134 | assert season_env.season_df.equals(season_man.season_df) 135 | 136 | @staticmethod 137 | def _call_cycles(ctrl): 138 | subprocess.run(['./Cycles', '-b', ctrl], cwd=CYCLES_PATH) 139 | 140 | 141 | if __name__ == '__main__': 142 | unittest.main() -------------------------------------------------------------------------------- /cyclesgym/tests/test_env.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import shutil 3 | import unittest 4 | import numpy as np 5 | import subprocess 6 | 7 | from cyclesgym.envs.corn import Corn 8 | from cyclesgym.envs.common import PartialObsEnv 9 | from cyclesgym.envs.utils import date2ydoy 10 | from cyclesgym.managers import * 11 | from cyclesgym.utils.utils import compare_env, maximum_absolute_percentage_error 12 | from cyclesgym.utils.paths import CYCLES_PATH, TEST_PATH 13 | 14 | TEST_FILENAMES = ['NCornTest.ctrl', 15 | 'NCornTest.operation', 16 | 'NCornTestNoFertilization.operation'] 17 | 18 | 19 | def copy_cycles_test_files(): 20 | # Copy test files in cycles input directory 21 | for n in TEST_FILENAMES: 22 | shutil.copy(TEST_PATH.joinpath(n), 23 | CYCLES_PATH.joinpath('input', n)) 24 | 25 | 26 | def remove_cycles_test_files(): 27 | # Remove all files copied to input folder 28 | for n in TEST_FILENAMES: 29 | pathlib.Path(CYCLES_PATH.joinpath('input', n)).unlink() 30 | # Remove the output of simulation started manually 31 | try: 32 | shutil.rmtree(pathlib.Path(CYCLES_PATH.joinpath( 33 | 'output', TEST_FILENAMES[0].replace('.ctrl', '')))) 34 | except FileNotFoundError: 35 | pass 36 | 37 | 38 | class TestCornEnv(unittest.TestCase): 39 | def setUp(self): 40 | copy_cycles_test_files() 41 | 42 | def tearDown(self): 43 | remove_cycles_test_files() 44 | 45 | def test_manual_sim_comparison(self): 46 | """ 47 | Check the manual and env simulation are the same for same management 48 | and different for different management. 49 | """ 50 | # Start normal simulation and parse results 51 | base_ctrl_man = TEST_FILENAMES[0].replace('.ctrl', '') 52 | self._call_cycles(base_ctrl_man) 53 | crop_man = CropManager( 54 | CYCLES_PATH.joinpath('output', base_ctrl_man, 'CornRM.90.dat')) 55 | season_man = SeasonManager( 56 | CYCLES_PATH.joinpath('output', base_ctrl_man, 'season.dat')) 57 | 58 | # Start gym env 59 | operation_file = TEST_FILENAMES[2] 60 | env = Corn(delta=1, maxN=150, n_actions=16, 61 | operation_file=operation_file) 62 | 63 | # Run simulation with same management and compare 64 | env.reset() 65 | while True: 66 | _, doy = date2ydoy(env.date) 67 | a = 15 if doy == 106 else 0 68 | _, _, done, _ = env.step(a) 69 | if done: 70 | break 71 | 72 | # Check crop 73 | crop_output_file = env._get_output_dir().joinpath('CornRM.90.dat') 74 | crop_env = CropManager(crop_output_file) 75 | assert crop_env.crop_state.equals(crop_man.crop_state) 76 | 77 | # Check yield 78 | season_output_file = env._get_output_dir().joinpath('season.dat') 79 | season_env = SeasonManager(season_output_file) 80 | assert season_env.season_df.equals(season_man.season_df) 81 | 82 | # Run simulation with different management and compare 83 | env.reset() 84 | while True: 85 | _, doy = date2ydoy(env.date) 86 | a = 15 if doy == 107 else 0 87 | _, _, done, _ = env.step(a) 88 | if done: 89 | break 90 | crop_output_file = env._get_output_dir().joinpath('CornRM.90.dat') 91 | crop_env = CropManager(crop_output_file) 92 | assert not crop_env.crop_state.equals(crop_man.crop_state) 93 | 94 | season_output_file = env._get_output_dir().joinpath('season.dat') 95 | season_env = SeasonManager(season_output_file) 96 | assert not season_env.season_df.equals(season_man.season_df) 97 | 98 | def test_reward(self): 99 | # Should test reward in no fertilization case to avoid old bug 100 | pass 101 | 102 | def test_fast_multiyear_against_continuous_multiyear(self): 103 | self.START_YEAR = 1980 104 | self.END_YEAR = 1983 105 | 106 | delta = 7 107 | n_actions = 7 108 | maxN = 120 109 | 110 | self.env_cont = Corn(delta=delta, n_actions=n_actions, maxN=maxN, start_year=self.START_YEAR, 111 | end_year=self.END_YEAR, use_reinit=False) 112 | 113 | self.env_impr = Corn(delta=delta, n_actions=n_actions, maxN=maxN, start_year=self.START_YEAR, 114 | end_year=self.END_YEAR) 115 | 116 | obs_cont, obs_impr, time_cont, time_impr = compare_env(self.env_cont, self.env_impr) 117 | print(f'Time of continuous environemnt over {self.END_YEAR - self.START_YEAR} years: {time_cont}') 118 | print(f'Time of improved environemnt over {self.END_YEAR - self.START_YEAR} years: {time_impr}') 119 | 120 | max_ape = maximum_absolute_percentage_error(obs_cont, obs_impr) 121 | print(f'Maximum percentage error: {max_ape} %') 122 | self.assertLess(max_ape, 1, f'Maximum percentage error: {max_ape} % is less then the threshold 1') 123 | 124 | @staticmethod 125 | def _call_cycles(ctrl): 126 | subprocess.run(['./Cycles', '-b', ctrl], cwd=CYCLES_PATH) 127 | 128 | 129 | class TestPartiallyObservableEnv(unittest.TestCase): 130 | def setUp(self) -> None: 131 | copy_cycles_test_files() 132 | self.f_env = Corn(delta=1, maxN=150, n_actions=16, 133 | operation_file=TEST_FILENAMES[2]) 134 | self.base_env = Corn(delta=1, maxN=150, n_actions=16, 135 | operation_file=TEST_FILENAMES[2]) 136 | n_obs = np.prod(self.base_env.observation_space.shape) 137 | 138 | np.random.seed(0) 139 | self.mask = np.random.choice(2, size=n_obs).astype(bool) 140 | self.wrapped_env = PartialObsEnv(self.base_env, mask=self.mask) 141 | 142 | def tearDown(self) -> None: 143 | remove_cycles_test_files() 144 | 145 | def test_full_vs_partial_observation(self): 146 | f_obs = self.f_env.reset() 147 | p_obs = self.wrapped_env.reset() 148 | print(self.wrapped_env.env.observer.obs_names) 149 | 150 | assert np.all(p_obs == f_obs[self.mask]) 151 | for _ in range(5): 152 | a = self.f_env.action_space.sample() 153 | f_obs, f_r, f_done, _ = self.f_env.step(a) 154 | p_obs, p_r, p_done, _ = self.wrapped_env.step(a) 155 | assert np.all(p_obs == f_obs[self.mask]) 156 | assert f_r == p_r 157 | assert f_done == p_done 158 | 159 | 160 | if __name__ == '__main__': 161 | unittest.main() 162 | -------------------------------------------------------------------------------- /documents/3.2_custom_weather_and_soil.md: -------------------------------------------------------------------------------- 1 | Weather conditions and crop characteristics influence the dynamics of the 2 | crops simulated in cyclesgym. The soil determines the initial condition of 3 | the simulation. 4 | 5 | Here, we explain how to use custom weather conditions, soils, and crops in 6 | cyclesgym. 7 | 8 | ## Weather 9 | At the start of each episode, cyclesgym creates a temporary directory with 10 | all the files that are necessary to run the simulation, including the weather 11 | (see [here](3_logic.md)). Which weather file to symlink or copy into this 12 | directory is determined by the weather generator that is passed to the 13 | environment during initialization. Below, we explain how a generic weather 14 | generator works, present those that we have already implemented, and reference 15 | an example that shows how to create a new one. 16 | 17 | #### Generic generators 18 | Each weather generator carries out three fundamental steps: 19 | 1. During initialization, it creates a unique temporary directory where it 20 | will store all the weather files it will generate. 21 | 2. It generates Cycles-compatible weather files (see [here](https://psumodeling.github.io/Cycles/#weather-file-weather) for a 22 | description of such files) calling the `generate_weather` method. 23 | 3. It samples one of the weather files generated in the previous step from 24 | a desired distribution when calling the `sample_weather_path` method. This is 25 | the function that is called at the start of each episode of an environment to 26 | obtain a weather file. 27 | 28 | 29 | **Speed Considerations**: The weather files are generated only once in bulk 30 | rather than every time we sample one because they are relatively large and 31 | writing them to memory at the beginning of each episode would be slow. Instead, 32 | we pre-generate them and only create a symbolic link to one of them at the 33 | beginning of the episode. 34 | 35 | #### Implemented generators 36 | **Dummy generator**: This is the generator to use in case you want an 37 | environment with fixed weather conditions from historical data. The 38 | `generate_weather` method simply creates a symbolic link to the file containing 39 | this data and the `sample_weather_path` always return this unique file. 40 | 41 | **Shuffler**: This generator creates new weather files by shuffling the years 42 | of historical weather data. This shuffling procedure happens in two steps, 43 | which we explain with an example where we sample a 3-year weather sequence 44 | based on historical weather data ranging from 2010 to 2020. First, we sample 45 | the starting year from the interval [2010, 2018]. This is because we do not 46 | have in our data 3-year sequences starting in 2019 or 2020. Assume this step 47 | returns year 2012. The second step consists in shuffling the years in the 48 | 3-year sequence starting from 2012, i.e., [2012, 2013, 2014]. This step 49 | could return something like [2013, 2012, 2014]. The reason behind this two-step 50 | procedure is to avoid creating sequences with years that are very distant 51 | in time. The `sample_weather_path` method samples uniformly at random. 52 | 53 | #### Creating new generators 54 | 55 | To create a new weather generator, one must define a class that inherits from 56 | the `WeatherGenerator` abstract class and implement its abstract method 57 | `generate_weather`. By inheriting from `WeatherGenerator`, the newly 58 | defined class automatically implements steps 1 and 3 outlined above that 59 | must be carried out by each weather generator. Optionally, one can modify 60 | the `sample_weather_path` method that by defaults returns a weather 61 | condition sampled uniformly at random among those that are generated. Below, 62 | we explain how to carry out step 2, i.e., how to generate weather files. 63 | 64 | Generating new weather files can be done by modifying existing weather files (e.g. adding some noise) or by 65 | creating new ones from scratch. In the first case, we recommend to use the 66 | weather file manager in `cyclesgym/managers` to parse an existing weather file 67 | (e.g. those for Rock Springs and New Holland that ship with Cycles), modify 68 | the weather values stored in the `mutables` DataFrame of these managers, and 69 | write the new values to a new file (using the `save` method from the manager 70 | guarantees that the file is formatted properly). We provide an example 71 | where we create a weather generator that adds Gaussian noise to historical 72 | weather data to generate new weather conditions in [this notebook](INSERT_LINK). 73 | In the second case, you can 74 | directly create the `mutables` DataFrame (which should have the same structure 75 | as the one obtained when parsing an existing weather file), the `immutables` 76 | one (which contains latitude, longitude, and altitude of the weather station), 77 | initialize the weather manager with the `from_df` method, and save it to a 78 | file with the `save` method. 79 | 80 | ## Soil 81 | The logic of soil generation will be similar to that of the weather: We 82 | generate different soil files, store them in a dedicated temporary folder, 83 | sample one of them from a given distribution at the start of each episode 84 | and symlink it to the folder where Cycles reads its input files. 85 | 86 | This is still under development. Examples and further details will come soon. 87 | 88 | ## Crop 89 | In Cycles, crops are planted with a PLANTING operation specified in the 90 | operation file. The type of crop that is planted by this operation is 91 | specified in the CROP field of the operation. This entry must match one of 92 | the crops names described in the crop description file that Cycles requires as 93 | input. Cycles ships with a file named `GenericCrops.crop` containing the 94 | description of many commonly used crops that are ready to use. If the 95 | desired crop is not contained in this file, it is possible to specify a new 96 | one by appending its description (which consists of all the parameters 97 | denoted [here](https://psumodeling.github.io/Cycles/#crop-description-file-crop)) 98 | to the file. 99 | 100 | Once the desired crop is in the crop description file, it is possible to 101 | plant it. Here, we consider two scenarios: 102 | 1. The crops to plant are pre-specified: See [how to specify default 103 | operations](3.4_default_operations.md). 104 | 2. The decision-maker (e.g. RL agent) must decide which crops to plant. In 105 | this case, the PLANTING operation is not specified as a default but it 106 | is written to the operation file by the implementer that implements the 107 | action space. In this case, we must include the desired crop in the 108 | decisions available to the agent. TODO: Example. 109 | 110 | -------------------------------------------------------------------------------- /cyclesgym/policies/informed_policy.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import matplotlib.pyplot as plt 3 | from matplotlib import cm 4 | 5 | 6 | __all__ = ['InformedPolicy'] 7 | 8 | 9 | class InformedPolicy(object): 10 | """ 11 | Policy with a fertilization window where the probability of actions depends on a difference between a learned 12 | saturation level and the current levels of N. 13 | 14 | 15 | The probability is given by 16 | pi(0|s) = pi1(0|s) + (1 - pi1(0|s)) * pi2(0|s) 17 | pi(a|s) = (1 - pi1(0|s)) * pi2(a|s) 18 | 19 | This means p1 indicates a fixed probability of selecting a=0 and p2 determines the remaining probability. 20 | pi1 changes depending on whether we are inside or outside the fertilization window. 21 | 22 | """ 23 | def __init__(self, env, params): 24 | """ 25 | The policy parametrization depends on the _parse_parameters function. 26 | 27 | Parameters 28 | ---------- 29 | env: cyclesgym.env.CornEnv 30 | Environment to control 31 | params: list 32 | List of parameters 33 | """ 34 | # Parameters for the fertilization window 35 | self.start_day = None # start of window 36 | self.end_day = None # end of window 37 | self.a = None # steepness of switch from window open to closed 38 | self.max_val = None # P(N=0) when window closed 39 | self.min_val = None # P(N=0) when window open 40 | 41 | # p2 parameters 42 | self.saturation = None # Max amount of N we want to put 43 | self.lengthscale = None # Lengthscale of exponential decay of probability as a function of distance from saturation level 44 | 45 | self._parse_parameters(params) 46 | 47 | self.actions = env.maxN / (env.n_actions - 1) * np.arange(env.n_actions) 48 | 49 | def _parse_parameters(self, params): 50 | """ 51 | Parse parameter vectors. 52 | 53 | This method is useful in case we want to change parametrization of the 54 | policy. Currently, the policy is parametrized by: start_day, end_day, 55 | a, max_val, min_val, saturation, lengthscale 56 | """ 57 | # Better parametrization 58 | self.start_day = 1 + params[0] * 7 59 | delta = params[1] * 7 60 | self.end_day = np.clip(self.start_day + delta, a_min=0, a_max=366) 61 | self.a = 1/7 62 | self.max_val = params[2] 63 | self.min_val = params[3] 64 | 65 | # p2 parameters 66 | self.saturation = 20 * params[4] 67 | self.lengthscale = 10**params[5] 68 | 69 | # Naive parametrization 70 | # p1 parameters 71 | # self.start_day = params[0] 72 | # self.end_day = params[1] 73 | # self.a = 1/params[2] 74 | # self.max_val = params[3] 75 | # self.min_val = params[4] 76 | # 77 | # # p2 parameters 78 | # self.saturation = params[5] 79 | # self.lengthscale = params[6] 80 | 81 | def pi1(self, doy): 82 | """ 83 | Probability of N=0 insisde and outside the fertilization window. 84 | 85 | Parameters 86 | ---------- 87 | doy: int or np.array of ints 88 | Day of the year 89 | """ 90 | doy = np.atleast_1d(doy) 91 | b1 = self.max_val + self.start_day * self.a 92 | b2 = self.max_val - self.end_day * self.a 93 | l1 = np.minimum(np.full_like(doy, self.max_val, dtype=float), -self.a * doy + b1) 94 | l2 = np.full_like(doy, self.min_val, dtype=float) 95 | l3 = np.minimum(np.full_like(doy, self.max_val, dtype=float), self.a * doy + b2) 96 | pi1 = np.maximum(np.maximum(l1, l2), l3) 97 | return pi1 98 | 99 | def pi2(self, deployed_N): 100 | """ 101 | Probability of given amount of N depending on how much N has already been supplied. 102 | 103 | Parameters 104 | ---------- 105 | doy: flaot or array of floats 106 | N already supplied 107 | """ 108 | deployed_N = np.atleast_1d(deployed_N) 109 | delta = np.clip(self.saturation - deployed_N, 110 | a_min=-self.actions[-1], a_max=self.actions[-1]) 111 | 112 | Z = np.sum( 113 | np.exp(-(delta[:, None] - self.actions[None, :]) ** 2 / self.lengthscale), 114 | axis=1) 115 | pi2 = np.empty((self.actions.size, delta.size)) 116 | for i, a in enumerate(self.actions): 117 | pi2[i, :] = np.exp(-(delta - a) ** 2 / self.lengthscale) / Z 118 | return pi2 119 | 120 | def action_probability(self, observation): 121 | """ 122 | Determines probability of each based on the observation. 123 | 124 | Parameters 125 | ---------- 126 | observation: np.ndarray 127 | First column: doy, second column: N 128 | """ 129 | observation = np.atleast_2d(observation) 130 | doy = np.atleast_1d(observation[:, 0]) 131 | deployed_N = np.atleast_1d(observation[:, 1]) 132 | pi1 = self.pi1(doy) 133 | pi2 = self.pi2(deployed_N) 134 | pi = np.empty((doy.size, self.actions.size)) 135 | for i, a in enumerate(self.actions): 136 | if a == 0: 137 | pi[:, i] = (1 - pi1) * pi2[i, :] + pi1 138 | else: 139 | pi[:, i] = (1 - pi1) * pi2[i, :] 140 | return pi 141 | 142 | def predict(self, observation, state=None, episode_start=None, deterministic=False): 143 | """ 144 | Predic action probability based on observation with same interface as stable baselines policy. 145 | """ 146 | probs = self.action_probability(observation) 147 | if deterministic: 148 | action = np.argmax(probs, axis=1) 149 | else: 150 | action = np.empty(probs.shape[0], dtype=int) 151 | for i, p in enumerate(probs): 152 | p/=sum(p) 153 | action[i] = np.random.choice(self.actions.size, size=1, p=p) 154 | 155 | return action, None 156 | 157 | def plot(self, doy, deployed_N): 158 | """ 159 | Plot the give policy as a funciton of doy and deployed N. 160 | """ 161 | doy = np.atleast_1d(doy) 162 | deployed_N = np.atleast_1d(deployed_N) 163 | if doy.size < 2 or deployed_N.size < 2: 164 | raise ValueError 165 | 166 | pi1 = self.pi1(doy) 167 | plt.figure() 168 | plt.plot(doy, pi1) 169 | plt.title('pi1') 170 | plt.xlabel('doy') 171 | 172 | plt.figure() 173 | pi2 = self.pi2(deployed_N) 174 | for a_distribution, a in zip(pi2, self.actions): 175 | plt.plot(deployed_N, a_distribution, label=f'{a}') 176 | plt.xlabel('Deployed N') 177 | plt.title('pi2') 178 | plt.legend() 179 | 180 | X, Y = np.meshgrid(doy, deployed_N) 181 | pi = np.empty((deployed_N.size, doy.size, self.actions.size)) 182 | for i, a in enumerate(self.actions): 183 | fig = plt.figure() 184 | ax = fig.add_subplot(projection='3d') 185 | if a == 0: 186 | pi[:, :, i] = (1 - pi1[None, :]) * pi2[i, :, None] + pi1 187 | else: 188 | pi[:, :, i] = (1 - pi1[None, :]) * pi2[i, :, None] 189 | ax.plot_surface(X, Y, pi[:, :, i], cmap=cm.coolwarm) 190 | plt.title(f'pi for action {a}') 191 | plt.xlabel('doy') 192 | plt.ylabel('deployed N') 193 | plt.show() 194 | 195 | -------------------------------------------------------------------------------- /cyclesgym/envs/observers.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import numpy as np 3 | from cyclesgym.envs.utils import date2ydoy, cap_date 4 | import datetime 5 | from cyclesgym.managers import WeatherManager, CropManager, SoilNManager 6 | 7 | __all__ = ['WeatherObserver', 'CropObserver', 'SoilNObserver', 'compound_observer'] 8 | 9 | 10 | class Observer(object): 11 | 12 | def __init__(self, end_year: int): 13 | self.end_year = end_year 14 | self.obs_names = None 15 | self.Nobs = 0 16 | self.lower_bound = None 17 | self.upper_bound = None 18 | 19 | def compute_obs(self, date: datetime.date, **kwargs): 20 | raise NotImplementedError 21 | 22 | 23 | class WeatherObserver(Observer): 24 | 25 | def __init__(self, 26 | weather_manager: WeatherManager, 27 | end_year: int): 28 | super(WeatherObserver, self).__init__(end_year) 29 | self.weather_manager = weather_manager 30 | self.Nobs = 10 31 | self.lower_bound = np.full((self.Nobs,), -np.inf) 32 | self.upper_bound = np.full((self.Nobs,), np.inf) 33 | 34 | def compute_obs(self, 35 | date: datetime.date, 36 | **kwargs): 37 | # Make sure we did not go into not simulated year when advancing time 38 | date = cap_date(date, self.end_year) 39 | year, doy = date2ydoy(date) 40 | 41 | imm_weather_data = self.weather_manager.immutables.iloc[0, :] 42 | mutable_weather_data = self.weather_manager.get_day(year, doy).iloc[0, 2:] 43 | 44 | obs = pd.concat([imm_weather_data, mutable_weather_data]) 45 | if self.obs_names is None: 46 | self.obs_names = list(obs.index) 47 | 48 | self.Nobs = obs.size 49 | return obs.to_numpy(dtype=float) 50 | 51 | 52 | class DailyOutputObserver(Observer): 53 | 54 | def __init__(self, manager, end_year: int): 55 | super(DailyOutputObserver, self).__init__(end_year) 56 | self.manager = manager 57 | self.observed_columns = None 58 | self.columns_to_process = None 59 | self.processing_maps = None 60 | 61 | def _process_raw_data(self, data, column_list=None, map_list=None): 62 | """ 63 | Data processing to handle non-numeric values. 64 | """ 65 | if column_list is None or map_list is None: 66 | return data 67 | 68 | if len(column_list) != len(map_list): 69 | raise ValueError('Column list and map list should be of the same length') 70 | for c, m in zip(column_list, map_list): 71 | data[c] = m[data[c]] 72 | return data 73 | 74 | def compute_obs(self, 75 | date: datetime.date, 76 | **kwargs): 77 | # Make sure we did not go into not simulated year when advancing time 78 | date = cap_date(date, self.end_year) 79 | year, doy = date2ydoy(date) 80 | 81 | data = self.manager.get_day(year, doy) 82 | if not data.empty: 83 | data = data.iloc[0, self.observed_columns] 84 | 85 | # Handle non-numeric values 86 | data = self._process_raw_data(data, 87 | column_list=self.columns_to_process, 88 | map_list=self.processing_maps) 89 | 90 | obs = data 91 | if self.obs_names is None: 92 | self.obs_names = list(obs.index) 93 | 94 | self.Nobs = obs.size 95 | return obs.to_numpy(dtype=float) 96 | 97 | def reset(self): 98 | pass 99 | 100 | 101 | class CropObserver(DailyOutputObserver): 102 | 103 | # Mapping to assign numeric values to plant life stage 104 | stage_mapping = \ 105 | {'N/A': 0, 106 | 'PLANTING': 1, 107 | 'PRE_EMERGENCE': 2, 108 | 'VEGETATIVE_GROWTH': 3, 109 | 'REPRODUCTIVE_GROWTH': 4, 110 | 'MATURITY': 5, 111 | 'KILLED': 6} 112 | 113 | def __init__(self, 114 | crop_manager: CropManager, 115 | end_year: int): 116 | super(CropObserver, self).__init__(crop_manager, end_year) 117 | self.Nobs = 15 118 | self.lower_bound = np.full((self.Nobs,), -np.inf) 119 | self.upper_bound = np.full((self.Nobs,), np.inf) 120 | self.observed_columns = 3 + np.arange(self.Nobs) 121 | 122 | # Processing plant stage 123 | self.columns_to_process = [0] 124 | self.processing_maps = [self.stage_mapping] 125 | 126 | 127 | class NToDateObserver(Observer): 128 | 129 | def __init__(self, 130 | end_year: int, 131 | with_year: bool = False): 132 | super(NToDateObserver, self).__init__(end_year) 133 | self.N_to_date = 0 134 | self.Nobs = 2 135 | #if including the year then you need another obs 136 | if with_year: 137 | self.Nobs = 3 138 | self.lower_bound = np.full((self.Nobs,), -np.inf) 139 | self.upper_bound = np.full((self.Nobs,), np.inf) 140 | self.with_year = with_year #whether to include the year number in the state 141 | 142 | # TODO: Fix Liskov substitution principle (same signature as parent method) 143 | def compute_obs(self, 144 | date: datetime.date, 145 | N: float): 146 | 147 | # Make sure we did not go into not simulated year when advancing time 148 | date = cap_date(date, self.end_year) 149 | 150 | y, doy = date2ydoy(date) 151 | y = self.end_year - y 152 | self.N_to_date += N 153 | if self.with_year: 154 | if self.obs_names is None: 155 | self.obs_names = ['DOY', 'N TO DATE', 'Y'] 156 | return np.array([doy, self.N_to_date, y]) 157 | if self.obs_names is None: 158 | self.obs_names = ['DOY', 'N TO DATE'] 159 | return np.array([doy, self.N_to_date]) 160 | 161 | def reset(self): 162 | self.N_to_date = 0 163 | 164 | 165 | class SoilNObserver(DailyOutputObserver): 166 | 167 | def __init__(self, 168 | soil_n_manager: SoilNManager, 169 | end_year: int): 170 | super(SoilNObserver, self).__init__(soil_n_manager, end_year) 171 | self.Nobs = 11 172 | self.lower_bound = np.full((self.Nobs,), -np.inf) 173 | self.upper_bound = np.full((self.Nobs,), np.inf) 174 | self.observed_columns = 2 + np.arange(self.Nobs) 175 | 176 | 177 | def compound_observer(obs_list: list): 178 | 179 | class Compound(object): 180 | def __init__(self, obs_list): 181 | self.obs_list = obs_list 182 | self.Nobs = sum([o.Nobs for o in obs_list]) 183 | self.lower_bound = np.full((self.Nobs,), -np.inf) 184 | self.upper_bound = np.full((self.Nobs,), np.inf) 185 | 186 | def compute_obs(self, date: datetime.date, **kwargs): 187 | obs = [o.compute_obs(date, **kwargs).squeeze() for o in self.obs_list] 188 | obs = [o for o in obs if o.size > 0] 189 | self.obs_names = [name for o in obs_list for name in o.obs_names] 190 | new_Nobs = sum([o.size for o in obs]) 191 | if new_Nobs != self.Nobs: 192 | print(f'Warning: runtime number of observation for {self} is different then the original' 193 | f'one: Before: {self.Nobs}, runtime: {new_Nobs}') 194 | print(self.obs_list) 195 | self.Nobs = new_Nobs 196 | return np.concatenate(obs) 197 | 198 | return Compound(obs_list) 199 | 200 | 201 | class CropRotationTrailingWindowObserver(Observer): 202 | 203 | def __init__(self, 204 | end_year: int): 205 | super(CropRotationTrailingWindowObserver, self).__init__(end_year) 206 | self.Nobs = 36 207 | self.lower_bound = np.full((self.Nobs,), -np.inf) 208 | self.upper_bound = np.full((self.Nobs,), np.inf) 209 | self.reset() 210 | 211 | def compute_obs(self, 212 | date: datetime.date, 213 | action: [int]): 214 | 215 | crop_categorical = action[0] 216 | self.window[self.last_year] = crop_categorical 217 | self.last_year = self.last_year + 1 218 | return self.window 219 | 220 | def reset(self): 221 | self.window = np.array([-1]*self.Nobs) 222 | self.last_year = 0 223 | -------------------------------------------------------------------------------- /notebooks/example_corn_fertilization_env.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "ec87d90e", 6 | "metadata": {}, 7 | "source": [ 8 | "## Example of usage of corn fertilization environment\n", 9 | "\n", 10 | "Here, we present an example of how to use the basic corn fertilization environment to simulate an expert fertilizaiton policy based on the [recommendations by the Pennsylvania state university](https://extension.psu.edu/nitrogen-fertilization-of-corn). The simulation will be for the years 1980 and 1981 with fixed weather from Rock Springs, PA." 11 | ] 12 | }, 13 | { 14 | "cell_type": "code", 15 | "execution_count": null, 16 | "id": "215346f4", 17 | "metadata": {}, 18 | "outputs": [], 19 | "source": [ 20 | "from cyclesgym.envs import Corn\n", 21 | "from cyclesgym.envs.weather_generator import FixedWeatherGenerator\n", 22 | "from cyclesgym.utils.paths import CYCLES_PATH # Path to Cycles directory\n", 23 | "from cyclesgym.managers import OperationManager\n", 24 | "from cyclesgym.policies.dummy_policies import OpenLoopPolicy\n", 25 | "\n", 26 | "import matplotlib.pyplot as plt\n", 27 | "import numpy as np\n", 28 | "\n", 29 | "%matplotlib inline" 30 | ] 31 | }, 32 | { 33 | "cell_type": "markdown", 34 | "id": "e7673496", 35 | "metadata": {}, 36 | "source": [ 37 | "First, we want to define the default operations, i.e., those operations that we want to perform but that are not controlled by the RL agents (e.g., irrigation, tillage, planting, and fertilization of nutrients different from N). We do this by loading and inspecting an existing operation file." 38 | ] 39 | }, 40 | { 41 | "cell_type": "code", 42 | "execution_count": null, 43 | "id": "3bd0a198", 44 | "metadata": {}, 45 | "outputs": [], 46 | "source": [ 47 | "op_manager = OperationManager(CYCLES_PATH.joinpath('input', 'ContinuousCorn.operation'))\n", 48 | "print(op_manager)" 49 | ] 50 | }, 51 | { 52 | "cell_type": "markdown", 53 | "id": "711be40c", 54 | "metadata": {}, 55 | "source": [ 56 | "We can see there are 3 operations: N fertilization, planting, and tillage. N fertilization will be zeroed out automatically not to interfere with the decisions of the RL agents. \n", 57 | "\n", 58 | "Let's define a temporary operation file without tillage to see how one can specify custom default operations.\n", 59 | "\n", 60 | "Internally, the operation manager stores all the operations in a dictionary with keys of the type (YEAR, DOY, OP_TYPE). We can provide a list of such keys to the `delete_operations` method of the manager to remove the desired operations." 61 | ] 62 | }, 63 | { 64 | "cell_type": "code", 65 | "execution_count": null, 66 | "id": "09229ae3", 67 | "metadata": {}, 68 | "outputs": [], 69 | "source": [ 70 | "# Delete the operation\n", 71 | "op_manager.delete_operations([(1, 110, 'TILLAGE')])\n", 72 | "print(op_manager)\n", 73 | "\n", 74 | "# Save the new operation file to make it available to Cycles\n", 75 | "new_op_path = CYCLES_PATH.joinpath('input', 'ContinuousCornNoTillageExample.operation')\n", 76 | "op_manager.save(new_op_path)" 77 | ] 78 | }, 79 | { 80 | "cell_type": "markdown", 81 | "id": "8e129a93", 82 | "metadata": {}, 83 | "source": [ 84 | "Now, we can initialize the environment." 85 | ] 86 | }, 87 | { 88 | "cell_type": "code", 89 | "execution_count": null, 90 | "id": "64187977", 91 | "metadata": {}, 92 | "outputs": [], 93 | "source": [ 94 | "env = Corn( \n", 95 | " delta=7, # Time step is one week\n", 96 | " n_actions=11, # 11 actions available between [0, maxN] \n", 97 | " maxN=150,\n", 98 | " operation_file=new_op_path.name, # Using the default operation we defined in the previous steps\n", 99 | " weather_generator_class=FixedWeatherGenerator, # Deterministic weather\n", 100 | " weather_generator_kwargs={'base_weather_file': CYCLES_PATH.joinpath('input', 'RockSprings.weather')}, # Location of the weather data\n", 101 | " start_year=1980,\n", 102 | " end_year=1981)" 103 | ] 104 | }, 105 | { 106 | "cell_type": "markdown", 107 | "id": "13921393", 108 | "metadata": {}, 109 | "source": [ 110 | "Let' reset it and take a look at the observations availalble in this environment. " 111 | ] 112 | }, 113 | { 114 | "cell_type": "code", 115 | "execution_count": null, 116 | "id": "88698380", 117 | "metadata": {}, 118 | "outputs": [], 119 | "source": [ 120 | "obs = env.reset()\n", 121 | "for o, o_name in zip(obs, env.observer.obs_names):\n", 122 | " print(f'Variable name: {o_name:<15}\\tValue: {o}')\n" 123 | ] 124 | }, 125 | { 126 | "cell_type": "markdown", 127 | "id": "5c26d5ff", 128 | "metadata": {}, 129 | "source": [ 130 | "Now we specify the expert policy. This expert policy is open loop in the sense that that it does not take into account any information about the system and the action applied only depends on the day or the year. As a consequence, it can be expressed as a pre-specified sequence of actions rather than as a mapping from observations to actions. In particular, this consits in applying 150kg/ha around the planting date." 131 | ] 132 | }, 133 | { 134 | "cell_type": "code", 135 | "execution_count": null, 136 | "id": "95662410", 137 | "metadata": {}, 138 | "outputs": [], 139 | "source": [ 140 | "# Initalize as no fertilization on any week.\n", 141 | "action_sequence = [0] * 105\n", 142 | "\n", 143 | "# At the beginning of the 16th week (day of the year 16*7=112) apply action 10, i.e., the max action in the set \n", 144 | "# {0, 1, ..., 10}. Given how our environment was defined, this corresponds to 150kg/ha.\n", 145 | "action_sequence [15] = 10 \n", 146 | "\n", 147 | "# Same for the second year\n", 148 | "action_sequence[68] = 10\n", 149 | "\n", 150 | "pi = OpenLoopPolicy(action_sequence) # Provides an interface for open loop policies that is the same as the stable baselines interface" 151 | ] 152 | }, 153 | { 154 | "cell_type": "markdown", 155 | "id": "fada7387", 156 | "metadata": {}, 157 | "source": [ 158 | "Now we can simulate the system and collect info about the policy, the reward, and information about leaching, volatilization, and emission." 159 | ] 160 | }, 161 | { 162 | "cell_type": "code", 163 | "execution_count": null, 164 | "id": "b62dd7e4", 165 | "metadata": {}, 166 | "outputs": [], 167 | "source": [ 168 | "actions = np.zeros(105, dtype=float)\n", 169 | "rewards = np.zeros(105, dtype=float)\n", 170 | "leaching = np.zeros(105, dtype=float)\n", 171 | "volatilization = np.zeros(105, dtype=float)\n", 172 | "emission = np.zeros(105, dtype=float)\n", 173 | "\n", 174 | "\n", 175 | "obs = env.reset()\n", 176 | "week = 0\n", 177 | "\n", 178 | "while True:\n", 179 | " a, _ = pi.predict(obs)\n", 180 | " obs, r, done, info = env.step(a)\n", 181 | " actions[week] = a\n", 182 | " rewards[week] = r\n", 183 | " leaching[week] = info['cost_leaching']\n", 184 | " volatilization[week] = info['cost_volatilization']\n", 185 | " emission[week] = info['cost_emission']\n", 186 | " week += 1\n", 187 | " if done:\n", 188 | " break\n" 189 | ] 190 | }, 191 | { 192 | "cell_type": "markdown", 193 | "id": "f8ac66b0", 194 | "metadata": {}, 195 | "source": [ 196 | "Now, we can plot all the information we collected." 197 | ] 198 | }, 199 | { 200 | "cell_type": "code", 201 | "execution_count": null, 202 | "id": "22a8a7ec", 203 | "metadata": {}, 204 | "outputs": [], 205 | "source": [ 206 | "for data, name in zip([actions, rewards, leaching, volatilization, emission], \n", 207 | " ['Actions', 'Rewards', 'Leaching', 'Volatilization', 'Emission']):\n", 208 | " plt.figure()\n", 209 | " plt.plot(data)\n", 210 | " plt.title(name)\n", 211 | " plt.xlabel('Weeks')\n", 212 | "\n" 213 | ] 214 | }, 215 | { 216 | "cell_type": "code", 217 | "execution_count": null, 218 | "id": "4e71fe52", 219 | "metadata": {}, 220 | "outputs": [], 221 | "source": [ 222 | "# Remove temporary file used for this example.\n", 223 | "new_op_path.unlink()" 224 | ] 225 | } 226 | ], 227 | "metadata": { 228 | "kernelspec": { 229 | "display_name": "Python cyclesgym", 230 | "language": "python", 231 | "name": "cyclesgym" 232 | }, 233 | "language_info": { 234 | "codemirror_mode": { 235 | "name": "ipython", 236 | "version": 3 237 | }, 238 | "file_extension": ".py", 239 | "mimetype": "text/x-python", 240 | "name": "python", 241 | "nbconvert_exporter": "python", 242 | "pygments_lexer": "ipython3", 243 | "version": "3.8.13" 244 | } 245 | }, 246 | "nbformat": 4, 247 | "nbformat_minor": 5 248 | } -------------------------------------------------------------------------------- /cyclesgym/envs/crop_planning.py: -------------------------------------------------------------------------------- 1 | from cyclesgym.envs.common import CyclesEnv 2 | from cyclesgym.envs.observers import SoilNObserver, CropRotationTrailingWindowObserver 3 | from cyclesgym.envs.rewarders import CropRewarder, compound_rewarder 4 | from cyclesgym.envs.implementers import RotationPlanter, RotationPlanterFixedPlanting 5 | from cyclesgym.managers import WeatherManager, CropManager, SeasonManager, OperationManager, SoilNManager 6 | from cyclesgym.utils.paths import CYCLES_PATH 7 | from cyclesgym.envs.weather_generator import WeatherShuffler, FixedWeatherGenerator 8 | 9 | from gym import spaces 10 | from typing import Tuple 11 | import numpy as np 12 | from datetime import date 13 | import os 14 | from pathlib import Path 15 | 16 | 17 | class CropPlanning(CyclesEnv): 18 | def __init__(self, 19 | start_year, 20 | end_year, 21 | rotation_crops, 22 | soil_file='GenericHagerstown.soil', 23 | weather_generator_class=FixedWeatherGenerator, 24 | weather_generator_kwargs={ 25 | 'base_weather_file': CYCLES_PATH.joinpath('input', 'RockSprings.weather')} 26 | ): 27 | 28 | super().__init__(SIMULATION_START_YEAR=start_year, 29 | SIMULATION_END_YEAR=end_year, 30 | ROTATION_SIZE=end_year - start_year + 1, 31 | USE_REINITIALIZATION=0, 32 | ADJUSTED_YIELDS=0, 33 | HOURLY_INFILTRATION=1, 34 | AUTOMATIC_NITROGEN=0, 35 | AUTOMATIC_PHOSPHORUS=0, 36 | AUTOMATIC_SULFUR=0, 37 | DAILY_WEATHER_OUT=0, 38 | DAILY_CROP_OUT=1, 39 | DAILY_RESIDUE_OUT=0, 40 | DAILY_WATER_OUT=0, 41 | DAILY_NITROGEN_OUT=1, 42 | DAILY_SOIL_CARBON_OUT=0, 43 | DAILY_SOIL_LYR_CN_OUT=0, 44 | ANNUAL_SOIL_OUT=0, 45 | ANNUAL_PROFILE_OUT=0, 46 | ANNUAL_NFLUX_OUT=0, 47 | CROP_FILE='GenericCrops.crop', 48 | OPERATION_FILE='CornSilageSoyWheat.operation', 49 | #TODO: right now the operation file is totally ignored 50 | SOIL_FILE=soil_file, 51 | WEATHER_GENERATOR_CLASS=weather_generator_class, 52 | WEATHER_GENERATOR_KWARGS=weather_generator_kwargs, 53 | REINIT_FILE='N / A', 54 | delta=365) 55 | self.rotation_crops = rotation_crops 56 | self.reset() 57 | self._generate_observation_space() 58 | self._generate_action_space(len(rotation_crops)) 59 | 60 | def _generate_action_space(self, n_actions): 61 | self.action_space = spaces.MultiDiscrete([n_actions, 14, 10, 10]) 62 | self.n_actions = n_actions 63 | 64 | def _generate_observation_space(self): 65 | self.observation_space = spaces.Box( 66 | low=np.array(self.observer.lower_bound, dtype=np.float32), 67 | high=np.array(self.observer.lower_bound, dtype=np.float32), 68 | shape=self.observer.lower_bound.shape, 69 | dtype=np.float32) 70 | 71 | def _init_input_managers(self): 72 | #TODO: in common with Corn environment. Should be implemented in and abstract parent class 73 | self.weather_manager = WeatherManager(self.weather_input_file) 74 | self.input_managers = [self.weather_manager] 75 | self.input_files = [self.weather_input_file] 76 | 77 | def _init_implementer(self, *args, **kwargs): 78 | self.implementer = RotationPlanter( 79 | operation_manager=self.op_manager, 80 | operation_fname=self.op_file, 81 | rotation_crops=self.rotation_crops, 82 | start_year=self.ctrl_base_manager.ctrl_dict['SIMULATION_START_YEAR'] 83 | ) 84 | 85 | def _init_output_managers(self): 86 | self.crop_output_file = [self._get_output_dir().joinpath(crop + '.dat') for crop in self.rotation_crops] 87 | self.season_file = self._get_output_dir().joinpath('season.dat') 88 | self.soil_n_file = self._get_output_dir().joinpath('N.dat') 89 | 90 | for file in self.crop_output_file: 91 | if not os.path.exists(file): 92 | with open(file, 'w'): pass 93 | 94 | self.crop_output_manager = [CropManager(file) for file in self.crop_output_file] 95 | self.season_manager = SeasonManager(self.season_file) 96 | self.soil_n_manager = SoilNManager(self.soil_n_file) 97 | 98 | self.output_managers = [*self.crop_output_manager, 99 | self.season_manager, 100 | self.soil_n_manager] 101 | self.output_files = [*self.crop_output_file, 102 | self.season_file, 103 | self.soil_n_file] 104 | 105 | def _init_observer(self, *args, **kwargs): 106 | self.observer = SoilNObserver(soil_n_manager=self.soil_n_manager, 107 | end_year=self.ctrl_base_manager.ctrl_dict['SIMULATION_END_YEAR']) 108 | 109 | def _init_rewarder(self, *args, **kwargs): 110 | self.rewarder = compound_rewarder([CropRewarder(self.season_manager, name) 111 | for name in self.rotation_crops]) 112 | 113 | def step(self, action) -> Tuple[np.ndarray, float, bool, dict]: 114 | rerun_cycles = self.implementer.implement_action(self.date, *action) 115 | 116 | if rerun_cycles: 117 | self._call_cycles(debug=False) 118 | 119 | # Advance time 120 | self.date = date(self.date.year + 1, self.date.month, self.date.day) 121 | 122 | # Compute reward 123 | r = self.rewarder.compute_reward(date=self.date, delta=self.delta) 124 | 125 | # Compute state 126 | obs = self.observer.compute_obs(self.date, action=action) 127 | 128 | # Compute 129 | done = self.date.year > self.ctrl_base_manager.ctrl_dict['SIMULATION_END_YEAR'] 130 | 131 | return obs, r, done, {} 132 | 133 | def _create_operation_file(self): 134 | # deleting all the content of the operation file found. 135 | self.op_file = Path(self.input_dir.name).joinpath( 136 | 'operation.operation') 137 | open(self.op_file, 'w').close() 138 | self.op_manager = OperationManager(self.op_file) 139 | self.op_base_manager = OperationManager(self.op_file) 140 | 141 | def reset(self) -> np.ndarray: 142 | # Set up dirs and files and run first simulation 143 | self._common_reset() 144 | 145 | # Init objects to compute obs, rewards, and implement actions 146 | self._init_observer() 147 | self._init_rewarder() 148 | self._init_implementer() 149 | 150 | # Set to zero all pre-existing fertilization for N 151 | self.implementer.reset() 152 | obs = self.observer.compute_obs(self.date, action=[-1, 0]) 153 | self.observer.reset() 154 | return obs 155 | 156 | 157 | class CropPlanningFixedPlanting(CropPlanning): 158 | 159 | def _generate_action_space(self, n_actions): 160 | self.action_space = spaces.MultiDiscrete([n_actions, 14]) 161 | self.n_actions = n_actions 162 | 163 | def _init_implementer(self, *args, **kwargs): 164 | self.implementer = RotationPlanterFixedPlanting( 165 | operation_manager=self.op_manager, 166 | operation_fname=self.op_file, 167 | rotation_crops=self.rotation_crops, 168 | start_year=self.ctrl_base_manager.ctrl_dict['SIMULATION_START_YEAR'] 169 | ) 170 | 171 | 172 | class CropPlanningFixedPlantingRotationObserver(CropPlanningFixedPlanting): 173 | 174 | def _init_observer(self, *args, **kwargs): 175 | self.observer = CropRotationTrailingWindowObserver( 176 | end_year=self.ctrl_base_manager.ctrl_dict['SIMULATION_END_YEAR']) 177 | 178 | 179 | if __name__ == '__main__': 180 | # Example for crop plannign with random weather 181 | 182 | # Env kwargs 183 | start_year = 1980 184 | end_year = 1990 185 | rotation_crops = ['CornSilageRM.90', 'SoybeanMG.3'] 186 | soil_file = 'GenericHagerstown.soil' 187 | weather_file = 'RockSprings.weather' 188 | 189 | # Weather generator 190 | sampling_start_year = 1980 191 | sampling_end_year = 2015 192 | n_weather_samples = 10 193 | target_year_range = np.arange(start_year, end_year + 1) 194 | weather_generator_kwargs = dict(n_weather_samples=n_weather_samples, 195 | sampling_start_year=sampling_start_year, 196 | sampling_end_year=sampling_end_year, 197 | base_weather_file=CYCLES_PATH.joinpath('input', weather_file), 198 | target_year_range=target_year_range) 199 | 200 | env = CropPlanningFixedPlanting(start_year=start_year, 201 | end_year=end_year, 202 | rotation_crops=rotation_crops, 203 | soil_file=soil_file, 204 | weather_generator_class=WeatherShuffler, 205 | weather_generator_kwargs=weather_generator_kwargs) 206 | 207 | # Compare with loop 208 | n_trials = 5 209 | 210 | rewards = np.zeros(n_trials) 211 | 212 | for i in range(n_trials): 213 | env.reset() # Fix weather file 214 | year = 0 215 | while True: 216 | a = (0, 0) if np.mod(year, 2) == 0 else (1, 0) 217 | s, r, done, info = env.step(a) 218 | rewards[0, i] += r 219 | year += 1 220 | if done: 221 | break 222 | 223 | print(rewards) 224 | 225 | 226 | -------------------------------------------------------------------------------- /experiments/fertilization/corn_soil_refined.py: -------------------------------------------------------------------------------- 1 | from cyclesgym.envs.corn import Corn 2 | import cyclesgym.envs.observers as observers 3 | from cyclesgym.managers import SoilNManager 4 | from cyclesgym.envs.common import CyclesEnv 5 | from cyclesgym.utils.paths import CYCLES_PATH 6 | from cyclesgym.envs.common import PartialObsEnv 7 | from cyclesgym.envs.weather_generator import WeatherShuffler, FixedWeatherGenerator 8 | 9 | import numpy as np 10 | 11 | 12 | class CornSoilCropWeatherObs(Corn): 13 | # Need to write N to output 14 | def __init__(self, 15 | delta, 16 | n_actions, 17 | maxN, 18 | operation_file='ContinuousCorn.operation', 19 | soil_file='GenericHagerstown.soil', 20 | weather_generator_class=FixedWeatherGenerator, 21 | weather_generator_kwargs={ 22 | 'base_weather_file': CYCLES_PATH.joinpath('input', 'RockSprings.weather')}, 23 | start_year=1980, 24 | end_year=1980, 25 | use_reinit=True, 26 | with_obs_year=False, 27 | ): 28 | self.rotation_size = end_year - start_year + 1 29 | self.use_reinit = use_reinit 30 | 31 | CyclesEnv.__init__(self, 32 | SIMULATION_START_YEAR=start_year, 33 | SIMULATION_END_YEAR=end_year, 34 | ROTATION_SIZE=self.rotation_size, 35 | USE_REINITIALIZATION=0, 36 | ADJUSTED_YIELDS=0, 37 | HOURLY_INFILTRATION=1, 38 | AUTOMATIC_NITROGEN=0, 39 | AUTOMATIC_PHOSPHORUS=0, 40 | AUTOMATIC_SULFUR=0, 41 | DAILY_WEATHER_OUT=0, 42 | DAILY_CROP_OUT=1, 43 | DAILY_RESIDUE_OUT=0, 44 | DAILY_WATER_OUT=0, 45 | DAILY_NITROGEN_OUT=1, 46 | DAILY_SOIL_CARBON_OUT=0, 47 | DAILY_SOIL_LYR_CN_OUT=0, 48 | ANNUAL_SOIL_OUT=0, 49 | ANNUAL_PROFILE_OUT=0, 50 | ANNUAL_NFLUX_OUT=0, 51 | CROP_FILE='GenericCrops.crop', 52 | OPERATION_FILE=operation_file, 53 | SOIL_FILE=soil_file, 54 | WEATHER_GENERATOR_CLASS=weather_generator_class, 55 | WEATHER_GENERATOR_KWARGS=weather_generator_kwargs, 56 | REINIT_FILE='N / A', 57 | delta=delta) 58 | 59 | self.with_obs_year = with_obs_year 60 | self._post_init_setup() 61 | self._init_observer() 62 | self._generate_observation_space() 63 | self._generate_action_space(n_actions, maxN) 64 | 65 | # Add N manager to fields 66 | def _post_init_setup(self): 67 | super()._post_init_setup() 68 | self.soil_n_file = None 69 | self.soil_n_manager = None 70 | 71 | # Initialize soil N manager 72 | def _init_output_managers(self): 73 | super()._init_output_managers() 74 | self.soil_n_file = self._get_output_dir().joinpath('N.dat') 75 | self.soil_n_manager = SoilNManager(self.soil_n_file) 76 | self.output_managers.append(self.soil_n_manager) 77 | self.output_files.append(self.soil_n_file) 78 | 79 | # Add observer of soil to compound one 80 | def _init_observer(self, *args, **kwargs): 81 | end_year = self.ctrl_base_manager.ctrl_dict['SIMULATION_END_YEAR'] 82 | self.observer = observers.compound_observer([ 83 | observers.WeatherObserver(weather_manager=self.weather_manager, end_year=end_year), 84 | observers.CropObserver(crop_manager=self.crop_output_manager, end_year=end_year), 85 | observers.SoilNObserver(soil_n_manager=self.soil_n_manager, end_year=end_year), 86 | observers.NToDateObserver(end_year=end_year, with_year=self.with_obs_year) 87 | ]) 88 | 89 | 90 | def CornSoilRefined(delta, n_actions, maxN, start_year, end_year, sampling_start_year, sampling_end_year, 91 | n_weather_samples, fixed_weather, with_obs_year, new_holland=False): 92 | target_obs = ['PP', # Precipitation 93 | 'TX', # Max temperature 94 | 'TN', # Min temperature 95 | 'SOLAR', # Radiation 96 | 'RHX', # Max relative humidity 97 | 'RHN', # Min relative humidity 98 | 'STAGE', # Stage in the plant life cycle 99 | 'CUM. BIOMASS', # Cumulative plant biomass 100 | 'N STRESS', 101 | 'WATER STRESS', 102 | 'ORG SOIL N', # The sum of microbial biomass N and stabilized soil organic N pools. 103 | 'PROF SOIL NO3', # Soil profile nitrate-N content. 104 | 'PROF SOIL NH4', # Soil profile ammonium-N content. 105 | 'Y', # Years left 106 | 'DOY' # Day of the year 107 | ] 108 | 109 | return generate_partially_observable_env(target_obs, delta, n_actions, maxN, start_year, end_year, 110 | sampling_start_year, 111 | sampling_end_year, n_weather_samples, fixed_weather, with_obs_year, 112 | new_holland=new_holland) 113 | 114 | 115 | def NonAdaptiveCorn(delta, n_actions, maxN, start_year, end_year, sampling_start_year, sampling_end_year, 116 | n_weather_samples, fixed_weather, with_obs_year, new_holland=False): 117 | target_obs = ['Y', # Years left 118 | 'DOY', # Day of the year 119 | 'N TO DATE' 120 | ] 121 | return generate_partially_observable_env(target_obs, delta, n_actions, maxN, start_year, end_year, sampling_start_year, 122 | sampling_end_year, n_weather_samples, fixed_weather, with_obs_year, 123 | new_holland=new_holland) 124 | 125 | 126 | def generate_partially_observable_env(target_obs, delta, n_actions, maxN, start_year, end_year, sampling_start_year, 127 | sampling_end_year, n_weather_samples, fixed_weather, with_obs_year, 128 | new_holland=False): 129 | # Weather generator 130 | if fixed_weather: 131 | weather_generator_class = FixedWeatherGenerator 132 | if new_holland: 133 | weather_generator_kwargs = {'base_weather_file': CYCLES_PATH.joinpath('input', 'RockSprings.weather')} 134 | else: 135 | weather_generator_kwargs = {'base_weather_file': CYCLES_PATH.joinpath('input', 'NewHolland.weather')} 136 | else: 137 | weather_generator_class = WeatherShuffler 138 | target_year_range = np.arange(start_year, end_year + 1) 139 | weather_generator_kwargs = dict(n_weather_samples=n_weather_samples, 140 | sampling_start_year=sampling_start_year, 141 | sampling_end_year=sampling_end_year, 142 | target_year_range=target_year_range) 143 | if new_holland: 144 | weather_generator_kwargs.update({'base_weather_file': CYCLES_PATH.joinpath('input', 'RockSprings.weather')}) 145 | else: 146 | weather_generator_kwargs.update({'base_weather_file': CYCLES_PATH.joinpath('input', 'NewHolland.weather')}) 147 | 148 | # Fully observable environment 149 | fully_observable_env = CornSoilCropWeatherObs(delta=delta, 150 | n_actions=n_actions, 151 | maxN=maxN, 152 | start_year=start_year, 153 | end_year=end_year, 154 | with_obs_year=with_obs_year, 155 | weather_generator_kwargs=weather_generator_kwargs, 156 | weather_generator_class=weather_generator_class) 157 | 158 | # Mask it with target obs 159 | mask = compute_mask(target_obs, delta, n_actions, maxN, start_year, end_year, sampling_start_year, 160 | sampling_end_year, n_weather_samples, fixed_weather, with_obs_year) 161 | partially_observable_env = PartialObsEnv(fully_observable_env, mask=mask) 162 | partially_observable_env.reset() 163 | return partially_observable_env 164 | 165 | 166 | def compute_mask(target_obs, 167 | delta, 168 | n_actions, 169 | maxN, 170 | start_year, 171 | end_year, 172 | sampling_start_year, 173 | sampling_end_year, 174 | n_weather_samples, 175 | fixed_weather, 176 | with_obs_year): 177 | 178 | # Initialize environment 179 | if fixed_weather: 180 | large_obs_corn_env = CornSoilCropWeatherObs(delta=delta, 181 | n_actions=n_actions, 182 | maxN=maxN, 183 | start_year=start_year, 184 | end_year=end_year, 185 | with_obs_year=with_obs_year) 186 | else: 187 | # TODO: Probably this part is not necessary since changing weather generator does not affect observation space 188 | weather_generator_kwargs = dict(n_weather_samples=n_weather_samples, 189 | sampling_start_year=sampling_start_year, 190 | sampling_end_year=sampling_end_year, 191 | target_year_range=np.arange(start_year, end_year + 1), 192 | base_weather_file=CYCLES_PATH.joinpath('input', 'RockSprings.weather')) 193 | large_obs_corn_env = CornSoilCropWeatherObs(delta=delta, 194 | n_actions=n_actions, 195 | maxN=maxN, start_year=start_year, 196 | end_year=end_year, 197 | with_obs_year=with_obs_year, 198 | weather_generator_class=WeatherShuffler, 199 | weather_generator_kwargs=weather_generator_kwargs) 200 | 201 | # Initialize observation space to get observation names 202 | s = large_obs_corn_env.reset() 203 | large_obs_corn_env.observer.obs_names 204 | 205 | # Compute mask for partially observable environment 206 | mask = np.isin(np.asarray(large_obs_corn_env.observer.obs_names), target_obs) 207 | return mask 208 | 209 | 210 | 211 | -------------------------------------------------------------------------------- /cyclesgym/tests/test_managers.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import numpy as np 3 | import pandas as pd 4 | from cyclesgym.managers import * 5 | from cyclesgym.managers.utils import * 6 | 7 | from cyclesgym.utils.paths import TEST_PATH 8 | 9 | 10 | class TestOperationManager(unittest.TestCase): 11 | def setUp(self): 12 | self.fname = TEST_PATH.joinpath('DummyOperation.operation') 13 | self.manager = OperationManager(self.fname) 14 | self.target = { 15 | (1, 106, 'FIXED_FERTILIZATION'): {'MASS': 150., 'LAYER': 1., 16 | 'N_NH4': 0.75, 'N_NO3': 0.25}, 17 | (1, 106, 'PLANTING'): {'CROP': 'CornRM.90', 'FRACTION': 1.}, 18 | (1, 106, 'TILLAGE'): {'DEPTH': 0.03, 'SOIL_DISTURB_RATIO': 5.}, 19 | } 20 | 21 | def test_parse(self): 22 | assert self.target.keys() == self.manager.op_dict.keys() 23 | for k in self.target.keys(): 24 | v_target = dict(sorted(self.target[k].items())) 25 | v_actual = dict(sorted(self.manager.op_dict[k].items())) 26 | 27 | assert v_target == v_actual 28 | 29 | def test_to_str(self): 30 | assert compare_stripped_string(read_skip_comments(self.fname), 31 | self.manager._to_str()) 32 | 33 | 34 | class TestCropManager(unittest.TestCase): 35 | def setUp(self): 36 | self.fname = TEST_PATH.joinpath('DummyCrop.dat') 37 | self.manager = CropManager(self.fname) 38 | self.target = pd.DataFrame({'YEAR': pd.Series([1980, 1980], dtype=int), 39 | 'DOY': pd.Series([1, 2], dtype=int), 40 | 'CROP': pd.Series(['FALLOW', 'FALLOW'], dtype=str), 41 | 'STAGE': pd.Series(['N/A', 'N/A'], dtype=str), 42 | 'THERMAL TIME': pd.Series([0, 0], dtype=float), 43 | 'CUM. BIOMASS': pd.Series([1, 0], dtype=float), 44 | 'AG BIOMASS': pd.Series([2, 0], dtype=float), 45 | 'ROOT BIOMASS': pd.Series([3, 0], dtype=float), 46 | 'FRAC INTERCEP': pd.Series([4, 0], dtype=float), 47 | 'TOTAL N': pd.Series([5, 0], dtype=float), 48 | 'AG N': pd.Series([6, 0], dtype=float), 49 | 'ROOT N': pd.Series([7, 0], dtype=float), 50 | 'AG N CONCN': pd.Series([8, 0], dtype=float), 51 | 'N FIXATION': pd.Series([9, 0], dtype=float), 52 | 'N ADDED': pd.Series([10, 0], dtype=float), 53 | 'N STRESS': pd.Series([11, 0], dtype=float), 54 | 'WATER STRESS': pd.Series([12, 0], dtype=float), 55 | 'POTENTIAL TR': pd.Series([13, 1.5], dtype=float)}) 56 | 57 | def test_parse(self): 58 | assert self.target.equals(self.manager.crop_state) 59 | 60 | def test_get_day(self): 61 | assert self.manager.get_day(year=1980, doy=2).equals(self.target.iloc[1:2, :]) 62 | 63 | def test_to_str(self): 64 | s = read_skip_comments(self.fname).split('\n')[0] 65 | old_crop_state = self.manager.crop_state.copy() 66 | 67 | # Make sure writing did not affect the original df 68 | assert old_crop_state.equals(self.manager.crop_state) 69 | assert compare_stripped_string(read_skip_comments(self.fname), 70 | self.manager._to_str()) 71 | 72 | 73 | class TestWeatherManager(unittest.TestCase): 74 | def setUp(self): 75 | self.fname = TEST_PATH.joinpath('DummyWeather.weather') 76 | self.manager = WeatherManager(self.fname) 77 | self.target_immutables = pd.DataFrame({'LATITUDE': [40.687500],'ALTITUDE': [0.0], 'SCREENING_HEIGHT': [10.0]}) 78 | self.target_mutables = pd.DataFrame({'YEAR': pd.Series([1980, 1980], dtype=int), 79 | 'DOY': pd.Series([1, 2], dtype=int), 80 | 'PP': pd.Series([0, 0], dtype=float), 81 | 'TX': pd.Series([1, 0], dtype=float), 82 | 'TN': pd.Series([2, 0], dtype=float), 83 | 'SOLAR': pd.Series([3, 0], dtype=float), 84 | 'RHX': pd.Series([4, 0], dtype=float), 85 | 'RHN': pd.Series([5, 0], dtype=float), 86 | 'WIND': pd.Series([6, 1.5], dtype=float),}) 87 | 88 | def test_parse(self): 89 | assert self.manager.immutables.equals(self.target_immutables) 90 | assert self.manager.mutables.equals(self.target_mutables) 91 | 92 | def test_to_str(self): 93 | # print(read_skip_comments(self.fname)) 94 | # print(self.manager._to_str()) 95 | assert compare_stripped_string(read_skip_comments(self.fname), 96 | self.manager._to_str()) 97 | 98 | def test_consistency(self): 99 | """ 100 | Test that loading a saved file results in same dataframes. 101 | """ 102 | # TODO: Add same test for other managers 103 | fname = Path(__file__).parent.joinpath( 104 | 'DummyWeatherFromManager.weather') 105 | self.manager.save(fname) 106 | load_manager = WeatherManager(fname) 107 | assert self.manager.immutables.equals(load_manager.immutables) 108 | assert self.manager.mutables.equals(load_manager.mutables) 109 | 110 | 111 | class TestControlManager(unittest.TestCase): 112 | def setUp(self): 113 | self.fname = TEST_PATH.joinpath('DummyControl.ctrl') 114 | self.manager = ControlManager(self.fname) 115 | self.target ={'SIMULATION_START_YEAR' :1980, 116 | 'SIMULATION_END_YEAR' :1980, 117 | 'ROTATION_SIZE' :1, 118 | 'USE_REINITIALIZATION' :0, 119 | 'ADJUSTED_YIELDS' :0, 120 | 'HOURLY_INFILTRATION' :1, 121 | 'AUTOMATIC_NITROGEN' :0, 122 | 'AUTOMATIC_PHOSPHORUS' :0, 123 | 'AUTOMATIC_SULFUR' :0, 124 | 'DAILY_WEATHER_OUT' :1, 125 | 'DAILY_CROP_OUT' :1, 126 | 'DAILY_RESIDUE_OUT' :1, 127 | 'DAILY_WATER_OUT' :1, 128 | 'DAILY_NITROGEN_OUT' :1, 129 | 'DAILY_SOIL_CARBON_OUT' :1, 130 | 'DAILY_SOIL_LYR_CN_OUT' :1, 131 | 'ANNUAL_SOIL_OUT' :1, 132 | 'ANNUAL_PROFILE_OUT' :1, 133 | 'ANNUAL_NFLUX_OUT' :1, 134 | 'CROP_FILE' :'GenericCrops.crop', 135 | 'OPERATION_FILE' :'ContinuousCorn.operation', 136 | 'SOIL_FILE' :'GenericHagerstown.soil', 137 | 'WEATHER_FILE' :'RockSprings.weather', 138 | 'REINIT_FILE' :'N/A'} 139 | 140 | def test_parse(self): 141 | assert self.manager.ctrl_dict == self.target 142 | 143 | def test_to_str(self): 144 | assert compare_stripped_string(read_skip_comments(self.fname), 145 | self.manager._to_str()) 146 | 147 | 148 | class TestSeasonManager(unittest.TestCase): 149 | def setUp(self): 150 | self.fname = TEST_PATH.joinpath('DummySeason.dat') 151 | self.manager = SeasonManager(self.fname) 152 | columns = ['YEAR', 'DOY', 'CROP', 'PLANT_YEAR', 'PLANT_DOY', 'TOTAL BIOMASS', 153 | 'ROOT BIOMASS', 'GRAIN YIELD', 'FORAGE YIELD', 'AG RESIDUE', 154 | 'HARVEST INDEX', 'POTENTIAL TR', 'ACTUAL TR', 'SOIL EVAP', 155 | 'IRRIGATION', 'TOTAL N', 'ROOT N', 'GRAIN N', 'FORAGE N', 156 | 'CUM. N STRESS', 'N IN HARVEST', 'N IN RESIDUE', 157 | 'N CONCN FORAGE', 'N FIXATION', 'N ADDED'] 158 | values = [[1980, 252, 'CornRM.90', 1980, 110] + 159 | list(np.arange(1, 21).astype(float))] 160 | self.target = pd.DataFrame(data=values, index=None, columns=columns) 161 | 162 | def test_parse(self): 163 | assert self.target.equals(self.manager.season_df) 164 | 165 | 166 | class TestUtils(unittest.TestCase): 167 | def setUp(self): 168 | self.date_df = pd.DataFrame({ 169 | 'DATE': ['1980-01-01', '1980-01-02'], 170 | 'CROP': ['wheat', 'corn'], 171 | 'PLANT_DATE': ['1981-01-01', '1981-01-02'] 172 | }) 173 | self.ydoy_df = pd.DataFrame({ 174 | 'YEAR': [1980, 1980], 175 | 'DOY': [1, 2], 176 | 'CROP': ['wheat', 'corn'], 177 | 'PLANT_YEAR': [1981, 1981], 178 | 'PLANT_DOY': [1, 2] 179 | }) 180 | 181 | def test_datetoydoy(self): 182 | new_df = date_to_ydoy(self.date_df, 'DATE', 183 | new_col_names=['YEAR', 'DOY'], inplace=False) 184 | new_df = date_to_ydoy(new_df, 'PLANT_DATE', 185 | new_col_names=['PLANT_YEAR', 'PLANT_DOY'], 186 | inplace=False) 187 | assert not new_df.equals(self.date_df) 188 | assert new_df.equals(self.ydoy_df) 189 | 190 | def test_datetoydoy_inplace(self): 191 | new_df = date_to_ydoy(self.date_df, 'DATE', 192 | new_col_names=['YEAR', 'DOY'], inplace=True) 193 | new_df = date_to_ydoy(new_df, 'PLANT_DATE', 194 | new_col_names=['PLANT_YEAR', 'PLANT_DOY'], 195 | inplace=True) 196 | assert new_df.equals(self.date_df) 197 | assert new_df.equals(self.ydoy_df) 198 | 199 | def test_ydoytodate(self): 200 | new_df = ydoy_to_date(self.ydoy_df, old_col_names=['YEAR', 'DOY'], 201 | new_col_name='DATE', inplace=False) 202 | new_df = ydoy_to_date(new_df, old_col_names=['PLANT_YEAR', 'PLANT_DOY'], 203 | new_col_name='PLANT_DATE', inplace=False) 204 | assert not new_df.equals(self.ydoy_df) 205 | assert new_df.equals(self.date_df) 206 | 207 | def test_ydoytodate_inplace(self): 208 | new_df = ydoy_to_date(self.ydoy_df, old_col_names=['YEAR', 'DOY'], 209 | new_col_name='DATE', inplace=True) 210 | new_df = ydoy_to_date(new_df, 211 | old_col_names=['PLANT_YEAR', 'PLANT_DOY'], 212 | new_col_name='PLANT_DATE', inplace=True) 213 | assert new_df.equals(self.ydoy_df) 214 | assert new_df.equals(self.date_df) 215 | 216 | 217 | def compare_stripped_string(s1, s2): 218 | """ 219 | Compared two strings line by line removing white spaces. 220 | """ 221 | l1 = list(filter(None, s1.split('\n'))) 222 | l2 = list(filter(None, s2.split('\n'))) 223 | 224 | if len(l1) != len(l2): 225 | return False 226 | else: 227 | equal = True 228 | for el1, el2 in zip(l1, l2): 229 | equal &= el1.replace(' ', '') == el2.replace(' ', '') 230 | if not equal: 231 | print(el1) 232 | print(el2) 233 | return equal 234 | 235 | 236 | def read_skip_comments(fname): 237 | """ 238 | Read a file removing comments. 239 | """ 240 | s = '' 241 | with open(fname, 'r') as fp: 242 | for line in fp: 243 | line = line.strip(' ') 244 | if line.startswith('#'): 245 | continue 246 | s += line 247 | return s 248 | 249 | 250 | if __name__ == '__main__': 251 | unittest.main() -------------------------------------------------------------------------------- /cyclesgym/envs/weather_generator.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from typing import Sequence, List 3 | import random 4 | import calendar 5 | import numpy as np 6 | from pathlib import Path 7 | from abc import ABC, abstractmethod 8 | import os 9 | from cyclesgym.managers import WeatherManager 10 | from cyclesgym.envs.utils import MyTemporaryDirectory, create_sim_id 11 | from cyclesgym.utils.paths import CYCLES_PATH 12 | 13 | __all__ = ['shuffle_weather', 'adapt_weather_year', 'generate_random_weather', 14 | 'WeatherShuffler'] 15 | 16 | 17 | class WeatherGenerator(ABC): 18 | def __init__(self): 19 | # TODO: Rename create_sim_id if used here as well 20 | self.generator_id = create_sim_id() 21 | self.weather_temporary_directory = MyTemporaryDirectory( 22 | path=self._get_weather_dir()) 23 | self.weather_list = [] 24 | 25 | def _get_weather_dir(self): 26 | # Get directory where sampled weather is stored 27 | return CYCLES_PATH.joinpath('input', f'weather_{self.generator_id}') 28 | 29 | def sample_weather_path(self): 30 | return self._get_weather_dir().joinpath( 31 | np.random.choice(self.weather_list)) 32 | 33 | @abstractmethod 34 | def generate_weather(self, *args, **kwargs): 35 | """ 36 | Popoulate the weather directory with files and weather list with paths. 37 | """ 38 | pass 39 | 40 | 41 | class WeatherShuffler(WeatherGenerator): 42 | def __init__(self, 43 | n_weather_samples: int, 44 | sampling_start_year: int, 45 | sampling_end_year: int, 46 | base_weather_file: Path, 47 | target_year_range: Sequence[int]): 48 | """ 49 | Generate random weather conditions by shuffling years from base file. 50 | 51 | Parameters 52 | ---------- 53 | sampling_start_year: int 54 | Lower end of the year range to sample the weather 55 | sampling_end_year: int 56 | Upper end of the year range to sample the weather (included for 57 | consistency with start year) 58 | n_weather_samples: int 59 | Number of different weather samples 60 | base_manager: Path 61 | Path to weather file containing the base weather data to shuffle 62 | target_year_range: Sequence[int] 63 | Year range for the generated weather 64 | """ 65 | # Store sampling related variables 66 | self.n_weather_samples = n_weather_samples 67 | self.sampling_start_year = sampling_start_year 68 | self.sampling_end_year = sampling_end_year 69 | self.base_weather_file = base_weather_file 70 | self.target_year_range = np.asarray(target_year_range) 71 | 72 | super().__init__() 73 | 74 | def generate_weather(self): 75 | # Get weather base data 76 | print('starting generating weather files') 77 | base_manager = WeatherManager(self.base_weather_file) 78 | 79 | # Restrict weather data to sampling years 80 | mutables = base_manager.mutables 81 | valid_years = np.asarray(mutables['YEAR'].unique()) 82 | 83 | if self.sampling_start_year < valid_years.min(): 84 | raise ValueError(f'Sampling start year {self.sampling_start_year}' 85 | f'is too low. It should be at least ' 86 | f'{valid_years.min()}') 87 | 88 | if self.sampling_end_year > valid_years.max(): 89 | raise ValueError(f'Sampling end year {self.sampling_end_year}' 90 | f'is too high. It should be at most ' 91 | f'{valid_years.max()}') 92 | 93 | sampling_years = np.arange(self.sampling_start_year, 94 | self.sampling_end_year + 1) 95 | 96 | base_manager.mutables = mutables.loc[mutables['YEAR'].isin( 97 | sampling_years)] 98 | 99 | duration = self.target_year_range.max() - self.target_year_range.min() + 1 100 | 101 | # Get shuffled weather 102 | new_weather_list = generate_random_weather( 103 | weather_manager=base_manager, 104 | duration=duration, 105 | n_samples=self.n_weather_samples, 106 | target_year_range=self.target_year_range) 107 | 108 | # Save shuffled weather 109 | for i, m in enumerate(new_weather_list): 110 | weather_fname = f'weather{i}.weather' 111 | self.weather_list.append(weather_fname) 112 | m.save(self._get_weather_dir().joinpath(weather_fname)) 113 | 114 | print('done generating weather files') 115 | 116 | 117 | class FixedWeatherGenerator(WeatherGenerator): 118 | def __init__(self, base_weather_file: Path): 119 | """ 120 | Dummy weather generator that uses fixed weather conditions specified in base file. 121 | 122 | Parameters 123 | ---------- 124 | base_manager: Path 125 | Path to weather file. 126 | """ 127 | self.base_weather_file = base_weather_file 128 | super().__init__() 129 | 130 | def generate_weather(self): 131 | weather_fname = self.base_weather_file.name 132 | os.symlink(self.base_weather_file, self._get_weather_dir().joinpath(weather_fname)) 133 | self.weather_list.append(weather_fname) 134 | 135 | 136 | def shuffle_weather(weather_manager: WeatherManager, 137 | duration: int, 138 | n_samples: int = 1) -> List[WeatherManager]: 139 | """ 140 | Sample random weather subsequnces of given duration from base manager. 141 | 142 | Given a weather manager containing weather conditions for years from x to 143 | y, we first sample an initial year from this range, then select all the 144 | years in [initial_year, initial_year + duration], and shuffle them around. 145 | We repeat this procedure to generate n_samples of shuffled weather. 146 | 147 | Parameters 148 | ---------- 149 | weather_manager: WeatherManager 150 | Weatther manager containing the original weather 151 | duration: int 152 | Duration in years of the intervals to subsample at random 153 | n_samples: int 154 | Number of different weather samples to collect 155 | 156 | Returns 157 | ------- 158 | shuffled_weather: List[WeatherManager] 159 | List of weather managers containing the shuffled weather years of 160 | specific duration 161 | 162 | """ 163 | 164 | # Extract mutables and immutables data frames 165 | imm_df = weather_manager.immutables 166 | mutables = weather_manager.mutables 167 | 168 | # Get years 169 | years = np.asarray(mutables['YEAR'].unique()) 170 | 171 | # Get years that can be used as a starting point for the sequence 172 | valid_start_years = years[years + (duration - 1) <= np.max(years)] 173 | grouped_by_year = list(mutables.groupby(by='YEAR')) 174 | shuffled_weather = [] 175 | 176 | for _ in range(n_samples): 177 | # Sample starting year 178 | start_year = np.random.choice(valid_start_years) 179 | 180 | # Shuffled sequence of years in [start_year, start_year + duration] 181 | sampled_years = np.arange(start_year, start_year + duration) 182 | random.shuffle(sampled_years) 183 | 184 | # Concatenates the dfs corresponding to sampled years 185 | # (assumes grouped by year is sorted) 186 | new_mutables_df = pd.concat( 187 | [grouped_by_year[y - years.min()][1] for y in sampled_years], 188 | ignore_index=True) 189 | 190 | # Create weather managers 191 | new_weather = WeatherManager.from_df(immutables_df=imm_df, 192 | mutables_df=new_mutables_df) 193 | shuffled_weather.append(new_weather) 194 | return shuffled_weather 195 | 196 | 197 | def adapt_weather_year(weather_manager: WeatherManager, 198 | target_year_range: Sequence[int]): 199 | """ 200 | Set the years for the weather contained in the weather manager. 201 | 202 | Reset the years in the weather file to those specified in 203 | target_year_range. When resetting, if the original year is leap and the 204 | target one is not, we drop the last day of the year. If the original year 205 | is not leap and the target one is, we add one day at the end of the year 206 | using the average of the last 7 days. 207 | 208 | Parameters 209 | ---------- 210 | weather_manager: WeatherManager 211 | Manager containing the weather whose years we adjust 212 | target_year_range: Sequence[int] 213 | Sequence of years that we want to set for the manager (must be of the 214 | same length as number of years in the manager). 215 | """ 216 | # Extract mutables and immutables data frames 217 | imm_df = weather_manager.immutables 218 | mutables = weather_manager.mutables 219 | 220 | # Read years in input 221 | original_years = np.asarray(mutables['YEAR'].unique()) 222 | grouped_by_year = list(mutables.groupby(by='YEAR', sort=False)) 223 | target_year_range = np.asarray(target_year_range) 224 | 225 | # Validate input 226 | if not target_year_range.size == original_years.size: 227 | raise ValueError(f'Target year range should be of the same size as ' 228 | f'original years ({original_years.size}). It is of' 229 | f'size {target_year_range.size} instead') 230 | 231 | for (o_y, o_y_df), t_y in zip(grouped_by_year, target_year_range): 232 | 233 | # If turning a leap year into a non-leap one, remove last day 234 | if calendar.isleap(o_y) and not calendar.isleap(t_y): 235 | o_y_df.drop(o_y_df.tail(1).index, inplace=True) 236 | 237 | # If turning a non-leap year into a leap one, add one day that is the 238 | # avg of the year's last week 239 | elif not calendar.isleap(o_y) and calendar.isleap(t_y): 240 | last_week_average = o_y_df.iloc[-7:, :].mean(axis=0) 241 | last_week_average.loc['DOY'] = 366 242 | 243 | new_ind = o_y_df.index.values.max() + 1 244 | o_y_df.loc[new_ind, :] = last_week_average 245 | 246 | # Set target year 247 | o_y_df['YEAR'] = t_y 248 | 249 | # TODO: This can probably be done in a much smarter way 250 | # Cast doy as integer because intepreted as float in last_week_average 251 | new_grouped_by_year = [] 252 | 253 | for year, year_df in grouped_by_year: 254 | year_df = year_df.astype({'YEAR': int, 'DOY': int}) 255 | new_grouped_by_year.append((year, year_df)) 256 | 257 | # Create mutable weather df from adapted years 258 | adapted_weather_df = pd.concat( 259 | [element[1] for element in new_grouped_by_year], ignore_index=True) 260 | 261 | return WeatherManager.from_df(immutables_df=imm_df, 262 | mutables_df=adapted_weather_df) 263 | 264 | 265 | def generate_random_weather(weather_manager: WeatherManager, 266 | duration: int, 267 | target_year_range: Sequence[int], 268 | n_samples: int = 1): 269 | # TODO: To improve merging the two functions in one 270 | shuffled_weather = shuffle_weather(weather_manager=weather_manager, 271 | duration=duration, 272 | n_samples=n_samples) 273 | new_weather = [] 274 | for manager in shuffled_weather: 275 | new_weather.append(adapt_weather_year(manager, target_year_range)) 276 | return new_weather 277 | 278 | 279 | if __name__ == '__main__': 280 | import time 281 | 282 | # Load base weather data 283 | fname = CYCLES_PATH.joinpath('input', 'RockSprings.weather') 284 | manager = WeatherManager(fname) 285 | 286 | t = time.time() 287 | 288 | # Create weather 289 | new_weather = generate_random_weather(weather_manager=manager, 290 | duration=10, 291 | target_year_range=np.arange(1980, 1990), 292 | n_samples=50) 293 | 294 | directory = MyTemporaryDirectory(Path().cwd().joinpath('tmp')) 295 | 296 | for i, w in enumerate(new_weather): 297 | 298 | # Save new weather 299 | w.save(directory.name.joinpath(f'{i}.weather')) 300 | print(f'elapsed time {time.time() - t}') 301 | --------------------------------------------------------------------------------