agroenv / server /models.py
PranovRaghavendhra's picture
Initial commit: AgroEnv Precision Agriculture Advisor
3bf3009
"""
AgroEnv Typed Models
=====================
All Pydantic models defining the agent-environment contract.
These are the strict typed interfaces enforced at every API boundary.
Design principles:
- Every field has a docstring-style description (Field(..., description=...))
- Observation is information-rich but agent must interpret it intelligently
- Action is constrained to realistic agronomic choices only
- No free-form text in actions — forces structured decision making
"""
from __future__ import annotations
from typing import Optional, Literal, List
from pydantic import BaseModel, Field, field_validator
import math
# ---------------------------------------------------------------------------
# Enums / Literal Types
# ---------------------------------------------------------------------------
CropKey = Literal["rice_kharif", "wheat_rabi", "cotton_kharif", "tomato_rabi"]
SoilKey = Literal["black_cotton_soil", "alluvial_soil", "red_laterite_soil", "loamy_soil"]
RegionKey = Literal["maharashtra_pune", "punjab_ludhiana", "andhra_guntur"]
IrrigationMethod = Literal["drip", "sprinkler", "flood", "furrow", "none"]
PesticideChoice = Literal[
"imidacloprid", "buprofezin", "thiamethoxam", "chlorantraniliprole",
"fipronil", "cartap", "dimethoate", "spiromesifen", "pyriproxyfen",
"spinosad", "indoxacarb", "mancozeb", "chlorothalonil", "azoxystrobin",
"emamectin_benzoate", "neem_oil", "none"
]
TaskName = Literal["irrigation_scheduling", "pest_management", "season_optimizer"]
QualityGrade = Literal["A", "B", "C"]
# ---------------------------------------------------------------------------
# Weather Sub-Models
# ---------------------------------------------------------------------------
class DailyWeatherObs(BaseModel):
tmax_c: float = Field(..., description="Maximum temperature today (°C)")
tmin_c: float = Field(..., description="Minimum temperature today (°C)")
tmean_c: float = Field(..., description="Mean temperature today (°C)")
humidity_pct: float = Field(..., description="Relative humidity (%)")
rainfall_mm: float = Field(..., description="Rainfall today (mm)")
solar_radiation_mj_m2: float = Field(..., description="Solar radiation (MJ/m²/day)")
et0_mm: float = Field(..., description="FAO-56 reference ET₀ (mm/day) — water demand of reference grass")
wind_speed_ms: float = Field(..., description="Wind speed at 2m (m/s)")
class WeatherForecastDay(BaseModel):
day_ahead: int = Field(..., description="Days ahead from today")
tmax_c: float = Field(..., description="Forecast max temperature (°C)")
tmin_c: float = Field(..., description="Forecast min temperature (°C)")
rain_prob_pct: float = Field(..., description="Probability of rain (%)")
expected_rain_mm: float = Field(..., description="Expected rainfall if rain occurs (mm)")
et0_forecast_mm: float = Field(..., description="Forecast reference ET₀ (mm/day)")
humidity_pct: float = Field(..., description="Forecast relative humidity (%)")
forecast_confidence: float = Field(..., description="Confidence in forecast (0–1, decreases with horizon)")
# ---------------------------------------------------------------------------
# Soil Sub-Models
# ---------------------------------------------------------------------------
class SoilObs(BaseModel):
moisture_pct: float = Field(..., description="Current soil volumetric moisture content (%)")
field_capacity_pct: float = Field(..., description="Field capacity of this soil type (%) — upper limit for irrigation")
wilting_point_pct: float = Field(..., description="Permanent wilting point (%) — plant cannot extract water below this")
depletion_mm: float = Field(..., description="Root zone depletion from field capacity (mm) — 0 means fully recharged")
ks: float = Field(..., description="Water stress coefficient (0=full stress, 1=no stress) — affects crop ET and growth")
drainage_mm_today: float = Field(..., description="Water lost to deep drainage today (mm) — indicates over-irrigation")
cumulative_stress_days: int = Field(..., description="Total days crop has experienced moisture stress this season")
waterlog_days: int = Field(..., description="Total days soil has been waterlogged this season")
raw_mm: float = Field(..., description="Readily Available Water (mm) — depletion threshold before stress begins")
# ---------------------------------------------------------------------------
# Crop Sub-Models
# ---------------------------------------------------------------------------
class CropObs(BaseModel):
crop_name: str = Field(..., description="Crop variety name")
growth_stage: str = Field(..., description="Current phenological stage (e.g. 'tillering', 'flowering')")
day_of_season: int = Field(..., description="Day number within the crop season (1 = sowing day)")
total_season_days: int = Field(..., description="Total duration of this crop's season")
gdd_accumulated: float = Field(..., description="Growing Degree Days accumulated so far")
gdd_progress_pct: float = Field(..., description="Season progress by GDD (%)")
ndvi: float = Field(..., description="Simulated satellite NDVI (0–1): derived from Leaf Area Index via Beer-Lambert law. Higher = healthier canopy")
lai: float = Field(..., description="Leaf Area Index (m²/m²) — canopy density indicator")
canopy_cover_pct: float = Field(..., description="Ground area covered by crop canopy (%)")
kc: float = Field(..., description="Current crop coefficient (Kc) — multiplied by ET₀ to get crop water demand")
estimated_yield_pct: float = Field(..., description="Estimated final yield as % of max possible, given stresses so far")
in_harvest_window: bool = Field(..., description="True if GDD is within optimal harvest window")
days_to_harvest_window: int = Field(..., description="Days estimated until harvest window opens (0 = already open)")
harvest_window_closing_days: int = Field(..., description="Days until optimal harvest window closes (0 = closed/missed)")
# ---------------------------------------------------------------------------
# Pest Sub-Models
# ---------------------------------------------------------------------------
class PestObs(BaseModel):
pest_name: str = Field(..., description="Pest/disease identifier (e.g. 'brown_planthopper')")
population: float = Field(..., description="Current population (scale: per-unit as per ICAR EIL definition)")
economic_threshold: float = Field(..., description="ICAR Economic Threshold — action required above this level")
economic_injury_level: float = Field(..., description="ICAR Economic Injury Level — yield loss begins above this")
at_threshold: bool = Field(..., description="Population has reached/exceeded Economic Threshold — spray warranted")
above_eil: bool = Field(..., description="Population above Economic Injury Level — active yield loss occurring")
resistance_index: float = Field(..., description="Pesticide resistance buildup (0=none, 1=full resistance) — affects efficacy")
days_since_spray: int = Field(..., description="Days since last pesticide application for this pest")
natural_enemy_population: float = Field(..., description="Population of natural enemies/predators (biological control indicator)")
damage_accumulated_pct: float = Field(..., description="Cumulative yield damage caused by this pest this season (%)")
# ---------------------------------------------------------------------------
# Market Sub-Models
# ---------------------------------------------------------------------------
class MarketObs(BaseModel):
current_price_inr_per_quintal: float = Field(..., description="Current mandi price (INR/quintal)")
msp_inr_per_quintal: float = Field(..., description="Government Minimum Support Price (INR/quintal)")
price_vs_msp_pct: float = Field(..., description="Current price as % above/below MSP")
market_trend: str = Field(..., description="Price trend: 'rising', 'falling', or 'stable'")
days_to_peak_price: int = Field(..., description="Estimated days until price peaks in this season")
glut_risk_pct: float = Field(..., description="Risk of harvest glut price crash if everyone harvests now (%)")
price_3d_ahead: float = Field(..., description="Projected price 3 days from now (INR/quintal)")
price_7d_ahead: float = Field(..., description="Projected price 7 days from now (INR/quintal)")
price_15d_ahead: float = Field(..., description="Projected price 15 days from now (INR/quintal)")
# ---------------------------------------------------------------------------
# Budget / Resource Tracker
# ---------------------------------------------------------------------------
class ResourceObs(BaseModel):
budget_remaining_inr: float = Field(..., description="Remaining seasonal budget (INR/ha)")
water_available_mm: float = Field(..., description="Available water in irrigation reservoir (mm equivalent)")
irrigation_events_used: int = Field(..., description="Number of irrigation events used this season")
spray_events_used: int = Field(..., description="Number of pesticide spray events used this season")
cumulative_irrigation_mm: float = Field(..., description="Total irrigation water applied this season (mm)")
cost_irrigation_today_inr: float = Field(..., description="Cost of today's irrigation action (INR/ha)")
cost_spray_today_inr: float = Field(..., description="Cost of today's spray action (INR/ha)")
# ---------------------------------------------------------------------------
# Main Observation Model
# ---------------------------------------------------------------------------
class AgroObservation(BaseModel):
"""
Complete observation returned to agent at each step.
Contains all information the agent needs to make a decision.
"""
task: TaskName = Field(..., description="Current task type")
day: int = Field(..., description="Current day of season (1-indexed)")
weather_today: DailyWeatherObs
weather_forecast: List[WeatherForecastDay] = Field(..., description="7-day weather forecast with uncertainty")
soil: SoilObs
crop: CropObs
pests: List[PestObs] = Field(default_factory=list, description="Status of all tracked pest species")
market: MarketObs
resources: ResourceObs
last_action_result: Optional[str] = Field(None, description="Feedback on the previous action taken")
episode_reward_so_far: float = Field(0.0, description="Cumulative reward earned this episode")
info_message: Optional[str] = Field(None, description="Advisory message from the environment (e.g. critical warning)")
# ---------------------------------------------------------------------------
# Action Model
# ---------------------------------------------------------------------------
class PestSprayAction(BaseModel):
"""Spray decision for a specific pest."""
pest_name: str = Field(..., description="Name of pest to treat")
pesticide: PesticideChoice = Field(..., description="Pesticide to apply. Use 'none' to skip")
class AgroAction(BaseModel):
"""
Agent's decision for the current day.
All fields are optional — agent only specifies what it wants to do.
Omitted fields default to 'do nothing'.
"""
# Irrigation decision
irrigate: bool = Field(False, description="Whether to irrigate today")
irrigation_amount_mm: float = Field(
0.0,
ge=0.0,
le=100.0,
description="Amount of irrigation to apply (mm). Only used if irrigate=True. Range: 0–100mm"
)
irrigation_method: IrrigationMethod = Field(
"none",
description="Irrigation delivery method. Affects water use efficiency"
)
# Pest management decisions
spray_decisions: List[PestSprayAction] = Field(
default_factory=list,
description="Spray decisions per pest. Omit pests to skip spraying them"
)
# Harvest decision (only valid when in_harvest_window=True)
harvest_now: bool = Field(
False,
description="Harvest the crop today. Only scores positively within the harvest window"
)
# Agent reasoning (logged but NOT scored — prevents reward hacking via verbose justification)
reasoning: str = Field(
"",
max_length=500,
description="Agent's explanation for this decision. Not scored. Used for debugging/analysis"
)
@field_validator("irrigation_amount_mm")
@classmethod
def validate_irrigation(cls, v: float) -> float:
if math.isnan(v) or math.isinf(v):
return 0.0
return round(max(0.0, min(100.0, v)), 1)
@field_validator("reasoning")
@classmethod
def clean_reasoning(cls, v: str) -> str:
return v.strip()[:500]
# ---------------------------------------------------------------------------
# Step Result
# ---------------------------------------------------------------------------
class StepResult(BaseModel):
observation: AgroObservation
reward: float = Field(..., description="Reward for this step (-1.0 to +1.0)")
done: bool = Field(..., description="Whether the episode has ended")
info: dict = Field(default_factory=dict, description="Diagnostic info (not available to agent during scoring)")
# ---------------------------------------------------------------------------
# Reset Request/Response
# ---------------------------------------------------------------------------
class ResetRequest(BaseModel):
task: TaskName = Field("irrigation_scheduling", description="Task to initialize")
crop: CropKey = Field("rice_kharif", description="Crop to simulate")
soil: SoilKey = Field("loamy_soil", description="Soil type")
region: RegionKey = Field("maharashtra_pune", description="Agro-climatic region")
seed: Optional[int] = Field(None, description="Random seed for reproducibility")
class ResetResponse(BaseModel):
observation: AgroObservation
task_description: str
success_criteria: str
max_steps: int
episode_id: str
# ---------------------------------------------------------------------------
# State Response
# ---------------------------------------------------------------------------
class StateResponse(BaseModel):
episode_id: str
task: TaskName
day: int
done: bool
total_reward: float
steps_taken: int
crop: str
region: str
soil: str
# ---------------------------------------------------------------------------
# Error Model
# ---------------------------------------------------------------------------
class ErrorResponse(BaseModel):
error: str
detail: Optional[str] = None