Buckets:
| """ | |
| CropRL Dynamics Engine. | |
| All simulation physics live here, separate from the step() orchestration. | |
| Each function is pure (given inputs → deterministic output for a given rng state), | |
| making them independently unit-testable. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| from typing import Optional, Tuple | |
| import numpy as np | |
| from .config import EnvConfig | |
| from .enums import MONTH_NAMES, Season, get_season | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # A. Weather Generation | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| def _get_seasonal_baseline(month: int, config: EnvConfig) -> float: | |
| """Return the seasonal rainfall baseline μ(m) for the given month.""" | |
| season = get_season(month) | |
| for cfg_season, baseline in config.weather_seasonal_baselines: | |
| if cfg_season == season: | |
| return baseline | |
| # Fallback (should not happen with well-configured baselines) | |
| return 0.5 | |
| def generate_rainfall( | |
| month: int, config: EnvConfig, rng: np.random.Generator | |
| ) -> float: | |
| """ | |
| Generate expected (forecasted) seasonal rainfall with Gaussian noise. | |
| W_expected = clip(μ(m) + ε, 0, 1) where ε ~ N(0, weather_sigma²) | |
| This is the forecast shown to the agent. The actual realised rainfall | |
| is sampled separately via ``realise_rainfall()`` when the month ticks. | |
| """ | |
| baseline = _get_seasonal_baseline(month, config) | |
| noise = rng.normal(0.0, config.weather_sigma) | |
| noise = float(np.clip(noise, -3 * config.weather_sigma, | |
| 3 * config.weather_sigma)) | |
| return float(np.clip(baseline + noise, 0.0, 1.0)) | |
| def realise_rainfall( | |
| expected: float, | |
| sigma: float, | |
| rng: np.random.Generator, | |
| ) -> float: | |
| """ | |
| Sample actual rainfall close to the expected forecast. | |
| W_actual = clip(W_expected + ε, 0, 1) where ε ~ N(0, σ_realisation²) | |
| Called once per month when the ``wait`` action triggers a month advance. | |
| """ | |
| noise = rng.normal(0.0, sigma) | |
| noise = float(np.clip(noise, -3 * sigma, 3 * sigma)) | |
| return float(np.clip(expected + noise, 0.0, 1.0)) | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # B. Dynamic Interest Rate | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| def calculate_interest_rate( | |
| base_rate: float, | |
| month: int, | |
| rainfall: float, | |
| optimal_water_level: float, | |
| ) -> float: | |
| """ | |
| Calculate the current annual interest rate. | |
| R = R_base + Δ_liquidity(m) + Δ_risk(W_deficit) | |
| Parameters | |
| ---------- | |
| base_rate : float | |
| Base annual interest rate (e.g. 0.08). | |
| month : int | |
| Calendar month 1-12. | |
| rainfall : float | |
| Expected rainfall this month (used as drought proxy). | |
| optimal_water_level : float | |
| Optimal water level for the active crop (0.0 if fallow). | |
| Returns | |
| ------- | |
| float | |
| Current annual interest rate (always >= 0). | |
| """ | |
| # Liquidity premium | |
| if month in (6, 7): | |
| delta_liquidity = 0.03 # planting season demand | |
| elif month in (10, 11): | |
| delta_liquidity = -0.02 # harvest season surplus | |
| else: | |
| delta_liquidity = 0.0 | |
| # Risk premium (drought) | |
| w_deficit = max(0.0, optimal_water_level - rainfall) | |
| delta_risk = 0.05 if w_deficit > 0.3 else 0.0 | |
| return max(0.0, base_rate + delta_liquidity + delta_risk) | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # C. Market Price Generation | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| def _get_market_seasonal_multiplier(month: int, config: EnvConfig) -> float: | |
| """Return the seasonal price multiplier for the given month.""" | |
| season = get_season(month) | |
| for cfg_season, multiplier in config.market_seasonal_multipliers: | |
| if cfg_season == season: | |
| return multiplier | |
| return 1.0 | |
| def generate_market_prices( | |
| month: int, | |
| config: EnvConfig, | |
| rng: np.random.Generator, | |
| prev_prices: Optional[Tuple[float, ...]] = None, | |
| effective_base_prices: Optional[Tuple[float, ...]] = None, | |
| ) -> Tuple[float, ...]: | |
| """ | |
| Generate market prices for each crop type. | |
| Supports two modes: | |
| (a) Independent monthly draw (when autocorrelation disabled or no prev_prices): | |
| P_t(i) = base_i × seasonal(m) × (1 + ε_i) | |
| (b) Mean-reverting random walk (when autocorrelation enabled + prev_prices): | |
| target_i = base_i × seasonal(m) | |
| drift_i = reversion_speed × (target_i - P_{t-1,i}) / target_i | |
| P_t(i) = P_{t-1,i} × (1 + drift_i + ε_i) | |
| All prices are clamped to [base × price_min_multiplier, base × price_max_multiplier]. | |
| Parameters | |
| ---------- | |
| month : int | |
| Calendar month 1-12. | |
| config : EnvConfig | |
| Environment configuration. | |
| rng : np.random.Generator | |
| Seeded random generator. | |
| prev_prices : tuple of 3 floats, optional | |
| Previous month's prices (used for autocorrelation mode). | |
| effective_base_prices : tuple of floats, optional | |
| Inflated base prices. If None, uses config.base_market_prices. | |
| """ | |
| seasonal_mult = _get_market_seasonal_multiplier(month, config) | |
| base_prices = effective_base_prices or config.base_market_prices | |
| prices = [] | |
| use_rw = ( | |
| config.enable_price_autocorrelation | |
| and prev_prices is not None | |
| ) | |
| for i in range(1, config.num_crop_types): # all crops except fallow | |
| base = base_prices[i] | |
| target = base * seasonal_mult | |
| noise = rng.normal(0.0, config.market_price_sigma) | |
| # Clamp noise to ±3σ | |
| noise = float(np.clip(noise, -3 * config.market_price_sigma, | |
| 3 * config.market_price_sigma)) | |
| if use_rw: | |
| prev = prev_prices[i - 1] | |
| drift = config.price_reversion_speed * (target - prev) / max(target, 1.0) | |
| price = prev * (1.0 + drift + noise) | |
| else: | |
| price = target * (1.0 + noise) | |
| # Clamp: floor at base × min_multiplier, ceiling at base × max_multiplier | |
| floor = base * config.price_min_multiplier | |
| ceiling = base * config.price_max_multiplier | |
| price = float(np.clip(price, floor, ceiling)) | |
| prices.append(price) | |
| # Demand shock: rare event affecting one random crop | |
| if config.demand_shock_probability > 0 and rng.random() < config.demand_shock_probability: | |
| crop_idx = rng.integers(0, config.num_crop_types - 1) | |
| direction = rng.choice([-1, 1]) | |
| lo, hi = config.demand_shock_magnitude | |
| magnitude = rng.uniform(lo, hi) | |
| shock_mult = 1.0 + direction * magnitude | |
| base = base_prices[crop_idx + 1] | |
| floor = base * config.price_min_multiplier | |
| ceiling = base * config.price_max_multiplier | |
| prices[crop_idx] = float(np.clip(prices[crop_idx] * shock_mult, floor, ceiling)) | |
| return tuple(prices) | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # D. Yield Calculation | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| def _maturity_factor(crop_age: int, growth_months: int) -> float: | |
| """ | |
| Maturity sub-factor for yield. | |
| - Age 0: always 0 (just planted). | |
| - Growing: quadratic ramp (crop_age / growth_months)². | |
| - Peak: 1.0 at exactly growth_months. | |
| - Rotting: drops 0.5 per month past peak → reaches 0.0 in 2 months. | |
| """ | |
| if crop_age == 0: | |
| return 0.0 | |
| if crop_age < growth_months: | |
| return (crop_age / growth_months) ** 2 | |
| months_over = crop_age - growth_months | |
| return max(0.0, 1.0 - 0.5 * months_over) | |
| def _nitrogen_factor(soil_nitrogen: float, min_requirement: float) -> float: | |
| """ | |
| Nitrogen sub-factor for yield (piecewise smooth saturation). | |
| Below minimum requirement: | |
| Linear ramp from 0 → 0.3 as nitrogen goes 0 → min_req. | |
| Above minimum requirement: | |
| Quadratic saturation from 0.3 → 1.0 as nitrogen goes min_req → 1.0. | |
| Real-world analogy: Liebig's law of the minimum — below a threshold, | |
| growth is severely limited. Above it, returns diminish. | |
| """ | |
| if soil_nitrogen <= 0: | |
| return 0.0 | |
| if min_requirement <= 0: | |
| return 1.0 | |
| if min_requirement >= 1.0: | |
| return float(soil_nitrogen >= 1.0) | |
| if soil_nitrogen < min_requirement: | |
| return 0.3 * (soil_nitrogen / min_requirement) | |
| # Above minimum: quadratic saturation toward 1.0 | |
| ratio = (soil_nitrogen - min_requirement) / (1.0 - min_requirement) | |
| ratio = min(ratio, 1.0) # guard against division issues | |
| return 0.3 + 0.7 * (1.0 - (1.0 - ratio) ** 2) | |
| def _water_factor(current_water_level: float, optimal_water_level: float) -> float: | |
| """ | |
| Water sub-factor for yield (square-root model). | |
| Inspired by FAO crop-water response curves (Doorenbos & Kassam): | |
| the first unit of water rescues a dying crop (high marginal value), | |
| while topping up from 75% to 100% gives modest improvement. | |
| - Full water (≥ optimal): 1.0 | |
| - Zero water: 0.1 (crop barely survives) | |
| - In between: sqrt(ratio), floored at 0.1 | |
| """ | |
| if optimal_water_level <= 0: | |
| return 1.0 # fallow, water irrelevant | |
| if current_water_level >= optimal_water_level: | |
| return 1.0 | |
| ratio = max(0.0, current_water_level / optimal_water_level) | |
| return max(0.1, math.sqrt(ratio)) | |
| def _season_factor( | |
| month: int, | |
| crop_type: int, | |
| config: EnvConfig, | |
| ) -> float: | |
| """ | |
| Seasonal sub-factor for yield. | |
| Returns 1.0 if the current season is optimal for this crop, | |
| otherwise returns the configured penalty multiplier (default 0.4). | |
| """ | |
| if crop_type == 0: | |
| return 1.0 | |
| season = get_season(month) | |
| optimal_seasons = config.optimal_seasons_per_crop[crop_type] | |
| if season in optimal_seasons: | |
| return 1.0 | |
| return config.non_optimal_season_multiplier | |
| def calculate_yield( | |
| crop_type: int, | |
| crop_age: int, | |
| soil_nitrogen: float, | |
| current_water_level: float, | |
| current_month: int, | |
| config: EnvConfig, | |
| rng: Optional[np.random.Generator] = None, | |
| ) -> float: | |
| """ | |
| Calculate harvest yield in tons. | |
| yield = base_yield × maturity × nitrogen × water × season × (1 + ε) | |
| Parameters | |
| ---------- | |
| crop_type : int | |
| 1, 2, or 3. Returns 0.0 if 0 (fallow). | |
| crop_age : int | |
| Months since planting. | |
| soil_nitrogen : float | |
| Current soil nitrogen level (0-1). | |
| current_water_level : float | |
| Current water level in the field (0-1). | |
| current_month : int | |
| Calendar month 1-12 (for seasonal factor). | |
| config : EnvConfig | |
| Environment configuration. | |
| rng : np.random.Generator, optional | |
| If provided, adds Gaussian noise to yield. If None, yield is | |
| deterministic (used for expected_yield_potential). | |
| Returns | |
| ------- | |
| float | |
| Tons of crop produced (>= 0). | |
| """ | |
| if crop_type == 0: | |
| return 0.0 | |
| base = config.base_yield_tons[crop_type] | |
| maturity = _maturity_factor(crop_age, config.growth_months[crop_type]) | |
| nitrogen = _nitrogen_factor(soil_nitrogen, config.minimum_nitrogen_requirement[crop_type]) | |
| water = _water_factor(current_water_level, config.optimal_water_level[crop_type]) | |
| season = _season_factor(current_month, crop_type, config) | |
| deterministic_yield = base * maturity * nitrogen * water * season | |
| # Yield noise (stochastic harvest outcomes) | |
| if rng is not None and config.yield_sigma > 0: | |
| noise = rng.normal(0.0, config.yield_sigma) | |
| noise = float(np.clip(noise, -3 * config.yield_sigma, 3 * config.yield_sigma)) | |
| deterministic_yield *= (1.0 + noise) | |
| return max(0.0, deterministic_yield) | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # E. Expected Yield Potential | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| def calculate_expected_yield_potential( | |
| crop_type: int, | |
| crop_age: int, | |
| soil_nitrogen: float, | |
| current_water_level: float, | |
| current_month: int, | |
| config: EnvConfig, | |
| ) -> float: | |
| """ | |
| Estimate the normalized yield potential if harvested this step. | |
| potential = raw_yield / max_possible_yield, clipped to [0.0, 1.0] | |
| max_possible_yield = base_yield × 1.0 (all sub-factors at maximum) | |
| Uses deterministic yield (no noise) as a planning aide for the agent. | |
| """ | |
| if crop_type == 0: | |
| return 0.0 | |
| raw_yield = calculate_yield( | |
| crop_type=crop_type, | |
| crop_age=crop_age, | |
| soil_nitrogen=soil_nitrogen, | |
| current_water_level=current_water_level, | |
| current_month=current_month, | |
| config=config, | |
| rng=None, # deterministic | |
| ) | |
| max_possible = config.base_yield_tons[crop_type] | |
| if max_possible <= 0: | |
| return 0.0 | |
| return float(np.clip(raw_yield / max_possible, 0.0, 1.0)) | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # F. Spoilage | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| def apply_spoilage( | |
| stored_age: int, stored_amount: float, max_age: int | |
| ) -> Tuple[float, bool]: | |
| """ | |
| Check whether stored crop has spoiled. | |
| Returns | |
| ------- | |
| (remaining_amount, spoiled) | |
| remaining_amount: 0.0 if spoiled, else stored_amount | |
| spoiled: True if crop rotted this step | |
| """ | |
| if stored_amount > 0 and stored_age > max_age: | |
| return 0.0, True | |
| return stored_amount, False | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| # G. Text Observation Formatter | |
| # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | |
| def format_text_observation( | |
| obs_dict: dict, | |
| config: EnvConfig, | |
| has_active_loan: bool, | |
| valid_actions: list[int] | None = None, | |
| ) -> str: | |
| """ | |
| Convert observation data into a human-readable text block for LLM agents. | |
| """ | |
| month = obs_dict["current_month"] | |
| step = obs_dict["current_step"] | |
| max_steps = config.max_steps | |
| season = get_season(month) | |
| month_name = MONTH_NAMES[month] | |
| crop_type = obs_dict["active_crop_type"] | |
| crop_name = config.crop_names[crop_type] | |
| crop_cat = config.crop_categories[crop_type] | |
| crop_age = obs_dict["crop_age_months"] | |
| growth_req = config.growth_months[crop_type] if crop_type > 0 else 0 | |
| lines = [ | |
| f"============= Farm Dashboard (Step {step}/{max_steps}) =============", | |
| f"Month: {month_name} ({month}) | Season: {season.value}", | |
| f"Weather: Expected rainfall {obs_dict['expected_rainfall']:.2f}/1.0", | |
| "", | |
| "FARM STATUS:", | |
| ] | |
| if crop_type == 0: | |
| lines.append("Active Crop: None (Fallow land)") | |
| else: | |
| lines.append( | |
| f"Active Crop: {crop_name} ({crop_cat}) | " | |
| f"Age: {crop_age}/{growth_req} months" | |
| ) | |
| lines.append(f"Soil Nitrogen: {obs_dict['soil_nitrogen']:.2f}/1.0") | |
| lines.append(f"Water Level: {obs_dict['current_water_level']:.2f}/1.0") | |
| lines.append( | |
| f"Expected Yield Potential: {obs_dict['expected_yield_potential']:.2f}/1.0" | |
| ) | |
| lines.append("") | |
| lines.append("FINANCES:") | |
| loan_status = " (active loan)" if has_active_loan else "" | |
| lines.append( | |
| f"Cash: ₹{obs_dict['cash_balance']:,.0f} | " | |
| f"Debt: ₹{obs_dict['current_debt']:,.0f}{loan_status}" | |
| ) | |
| lines.append( | |
| f"Interest Rate: {obs_dict['current_interest_rate'] * 100:.1f}% annual" | |
| ) | |
| lines.append( | |
| f"Land Value: ₹{obs_dict['current_land_price']:,.0f}" | |
| ) | |
| lines.append("") | |
| lines.append("MARKET PRICES (per ton):") | |
| price_parts = [] | |
| for i in range(1, config.num_crop_types): | |
| crop_name = config.crop_names[i] | |
| price_val = obs_dict.get(f'market_price_crop_{i}', 0.0) | |
| price_parts.append(f"{crop_name}: ₹{price_val:,.0f}") | |
| lines.append(" | ".join(price_parts)) | |
| lines.append("") | |
| lines.append("STORAGE:") | |
| stored_type = obs_dict["stored_crop_type"] | |
| stored_amt = obs_dict["stored_amount"] | |
| if stored_type == 0 or stored_amt <= 0: | |
| lines.append("Empty") | |
| else: | |
| stored_name = config.crop_names[stored_type] | |
| stored_age = obs_dict["stored_age_months"] | |
| lines.append( | |
| f"{stored_amt:.1f} tons of {stored_name} " | |
| f"(age: {stored_age}/{config.max_storage_age} months)" | |
| ) | |
| lines.append("") | |
| lines.append("COSTS:") | |
| costs_parts = [] | |
| for i in range(1, config.num_crop_types): | |
| crop_name = config.crop_names[i] | |
| cost_val = obs_dict.get(f'cost_seed_{i}', 0.0) | |
| costs_parts.append(f"Plant {crop_name}: ₹{cost_val:,.0f}") | |
| lines.append(" | ".join(costs_parts)) | |
| lines.append( | |
| f"Irrigate: ₹{obs_dict['cost_irrigate']:,.0f} | " | |
| f"Fertilize: ₹{obs_dict['cost_fertilize']:,.0f} | " | |
| f"Monthly Fixed: ₹{obs_dict.get('monthly_fixed_cost', 0):,.0f}" | |
| ) | |
| if valid_actions is not None: | |
| lines.append("") | |
| lines.append("AVAILABLE ACTIONS:") | |
| action_strs = [ | |
| f"{a}: {config.action_names[a]}" for a in valid_actions | |
| ] | |
| lines.append(" | ".join(action_strs)) | |
| return "\n".join(lines) | |
Xet Storage Details
- Size:
- 18.8 kB
- Xet hash:
- 50b72a23d8e8d6e51a1ec56304b1ea730b08c56b52e082bd6b917e8c17bcdbc9
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.