Skip to content
4 changes: 2 additions & 2 deletions backend/api/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
from .spatial_yield import spatial_yield_bp
from .carbon import carbon_bp
from .logistics import smart_freight_bp
from .carbon_v2 import carbon_v2_bp
from .irrigation_v2 import irrigation_v2_bp

# Create v1 API blueprint
api_v1 = Blueprint('api_v1', __name__, url_prefix='/api/v1')
Expand Down Expand Up @@ -106,4 +106,4 @@
api_v1.register_blueprint(arbitrage_bp, url_prefix='/arbitrage')
api_v1.register_blueprint(spatial_yield_bp, url_prefix='/spatial-yield')
api_v1.register_blueprint(carbon_bp, url_prefix='/carbon')
api_v1.register_blueprint(carbon_v2_bp, url_prefix='/carbon-v2')
api_v1.register_blueprint(irrigation_v2_bp, url_prefix='/irrigation-v2')
48 changes: 48 additions & 0 deletions backend/api/v1/irrigation_v2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from flask import Blueprint, jsonify, request
from backend.auth_utils import token_required
from backend.models.precision_irrigation import WaterStressIndex, IrrigationValveAutomation, AquiferMonitoring
from backend.services.irrigation_orchestrator import IrrigationOrchestrator
from backend.extensions import db

irrigation_v2_bp = Blueprint('irrigation_v2', __name__)

@irrigation_v2_bp.route('/stress/logs', methods=['GET'])
@token_required
def get_stress_logs(current_user):
"""Retrieve historical water stress data."""
farm_id = request.args.get('farm_id')
logs = WaterStressIndex.query.filter_by(farm_id=farm_id).order_by(WaterStressIndex.recorded_at.desc()).limit(50).all()
return jsonify({
'status': 'success',
'data': [l.to_dict() for l in logs]
}), 200

@irrigation_v2_bp.route('/valves/status', methods=['GET'])
@token_required
def get_valve_states(current_user):
"""View the current status of all smart valves on a farm."""
farm_id = request.args.get('farm_id')
valves = IrrigationValveAutomation.query.filter_by(farm_id=farm_id).all()
return jsonify({
'status': 'success',
'data': [v.to_dict() for v in valves]
}), 200

@irrigation_v2_bp.route('/aquifer/health', methods=['GET'])
@token_required
def get_aquifer_health(current_user):
"""Monitor regional aquifer water levels and depletion risks."""
aquifer_id = request.args.get('aquifer_id')
latest = AquiferMonitoring.query.filter_by(aquifer_id=aquifer_id).order_by(AquiferMonitoring.recorded_at.desc()).first()

if not latest:
return jsonify({'error': 'No data found for this aquifer'}), 404

return jsonify({
'status': 'success',
'data': {
'level': latest.current_water_level_m,
'recharge_rate': latest.recharge_rate_lps,
'is_critical': latest.is_critical_depletion
}
}), 200
6 changes: 3 additions & 3 deletions backend/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
from .arbitrage import ArbitrageOpportunity, AlgorithmicTradeRecord
from .spatial_yield import SpatialYieldGrid, TemporalYieldForex
from .circular import WasteInventory, BioEnergyOutput, CircularCredit
from .carbon_escrow import CarbonTradeEscrow, EscrowAuditLog, CarbonCreditWallet
from .precision_irrigation import WaterStressIndex, IrrigationValveAutomation, AquiferMonitoring
from .disease import MigrationVector, ContainmentZone
from .ledger import (
LedgerAccount, LedgerTransaction, LedgerEntry,
Expand Down Expand Up @@ -127,8 +127,8 @@
'ReliabilityLog', 'ForwardContract', 'PriceHedgingLog',
# Circular Economy & Biomass Energy
'WasteInventory', 'BioEnergyOutput', 'CircularCredit',
# Carbon Trading Escrow (L3-1642)
'CarbonTradeEscrow', 'EscrowAuditLog', 'CarbonCreditWallet',
# Precision Irrigation (L3-1640)
'WaterStressIndex', 'IrrigationValveAutomation', 'AquiferMonitoring',
# Double-Entry Ledger
'LedgerAccount', 'LedgerTransaction', 'LedgerEntry',
'FXValuationSnapshot', 'Vault', 'VaultCurrencyPosition', 'FXRate',
Expand Down
4 changes: 2 additions & 2 deletions backend/models/audit_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ class AuditLog(db.Model):
# Smart Freight (L3-1631)
ai_logistics_flag = db.Column(db.Boolean, default=False) # For geo-fence & phyto auto-decisions

# Carbon Trading Escrow (L3-1642)
carbon_escrow_flag = db.Column(db.Boolean, default=False)
# Precision Irrigation (L3-1640)
irrigation_auto_flag = db.Column(db.Boolean, default=False)

timestamp = db.Column(db.DateTime, default=datetime.utcnow)

Expand Down
2 changes: 1 addition & 1 deletion backend/models/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ class TransactionType(enum.Enum):
DIVIDEND = 'DIVIDEND'
FEE = 'FEE'
INTEREST = 'INTEREST'
CARBON_ESCROW_FEE = 'CARBON_ESCROW_FEE' # Platform fee for managing carbon settlements
WATER_TX_SETTLEMENT = 'WATER_TX_SETTLEMENT' # Automated payment for precision water consumption
CARBON_CREDIT_MINT = 'CARBON_CREDIT_MINT' # New credit minted from sequestration
CARBON_CREDIT_SALE = 'CARBON_CREDIT_SALE' # Credit sold to ESG buyer

Expand Down
76 changes: 76 additions & 0 deletions backend/models/precision_irrigation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""
Precision Irrigation & Aquifer Management Models — L3-1640
==========================================================
Implements high-resolution water stress indexing and automated
irrigation valve orchestration.
"""

from datetime import datetime
from backend.extensions import db

class WaterStressIndex(db.Model):
__tablename__ = 'water_stress_index_logs'

id = db.Column(db.Integer, primary_key=True)
farm_id = db.Column(db.Integer, db.ForeignKey('farms.id'), nullable=False)
zone_id = db.Column(db.String(50), nullable=False)

# Soil metrics
moisture_level_pct = db.Column(db.Float, nullable=False)
evapotranspiration_rate = db.Column(db.Float)
root_zone_salinity = db.Column(db.Float)

# Engine calculated
stress_score = db.Column(db.Float) # 0.0 to 1.0
irrigation_required_liters = db.Column(db.Float)

recorded_at = db.Column(db.DateTime, default=datetime.utcnow)

def to_dict(self):
return {
'id': self.id,
'zone': self.zone_id,
'moisture': self.moisture_level_pct,
'stress': self.stress_score,
'timestamp': self.recorded_at.isoformat()
}

class IrrigationValveAutomation(db.Model):
__tablename__ = 'irrigation_valve_automation'

id = db.Column(db.Integer, primary_key=True)
farm_id = db.Column(db.Integer, db.ForeignKey('farms.id'), nullable=False)
valve_id = db.Column(db.String(50), nullable=False)

status = db.Column(db.String(20), default='IDLE') # OPEN, CLOSED, FAULT
flow_rate_lpm = db.Column(db.Float, default=0.0)
total_volume_injected_l = db.Column(db.Float, default=0.0)

last_action = db.Column(db.String(50))
last_action_at = db.Column(db.DateTime)

# Link to stress event
triggered_by_event_id = db.Column(db.Integer, db.ForeignKey('water_stress_index_logs.id'))

def to_dict(self):
return {
'id': self.id,
'valve': self.valve_id,
'status': self.status,
'flow_rate': self.flow_rate_lpm,
'total_vol': self.total_volume_injected_l
}

class AquiferMonitoring(db.Model):
__tablename__ = 'aquifer_monitoring_logs'

id = db.Column(db.Integer, primary_key=True)
aquifer_id = db.Column(db.String(50), nullable=False)

current_water_level_m = db.Column(db.Float)
recharge_rate_lps = db.Column(db.Float)

# Sustainability indicator
is_critical_depletion = db.Column(db.Boolean, default=False)
recorded_at = db.Column(db.DateTime, default=datetime.utcnow)

89 changes: 89 additions & 0 deletions backend/services/irrigation_orchestrator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""
Precision Irrigation Orchestration Engine — L3-1640
===================================================
Autonomous irrigation management using soil moisture sensors and
evapotranspiration projections.
"""

from datetime import datetime, timedelta
from backend.extensions import db
from backend.models.precision_irrigation import WaterStressIndex, IrrigationValveAutomation
from backend.models.weather import WeatherData
import logging

logger = logging.getLogger(__name__)

class IrrigationOrchestrator:

@staticmethod
def calculate_water_deficit(farm_id: int, moisture: float, temp: float, humidity: float) -> float:
"""
Penman-Monteith simplified deficit calculation.
"""
# Baseline moisture target is 45%
target = 45.0
deficit = max(0.0, target - moisture)

# ET correction: higher temp/lower humidity increases requirement
et_modifier = (temp * 0.05) + ( (100 - humidity) * 0.02)
return deficit * (1 + (et_modifier / 100))

@staticmethod
def process_zone_telemetry(farm_id: int, zone_id: str, moisture: float):
"""
Evaluates moisture data and triggers valves if threshold breached.
"""
# Fetch recent weather
weather = WeatherData.query.filter_by(location_id=farm_id).order_by(WeatherData.timestamp.desc()).first()
temp = weather.temperature if weather else 25.0
humidity = weather.humidity if weather else 50.0

stress_score = max(0.0, min(1.0, (45.0 - moisture) / 45.0))
required_liters = IrrigationOrchestrator.calculate_water_deficit(farm_id, moisture, temp, humidity) * 100 # per hectare base

log = WaterStressIndex(
farm_id=farm_id,
zone_id=zone_id,
moisture_level_pct=moisture,
evapotranspiration_rate=0.5, # Placeholder
stress_score=stress_score,
irrigation_required_liters=required_liters
)
db.session.add(log)
db.session.flush()

if stress_score > 0.4:
IrrigationOrchestrator._trigger_valves(farm_id, zone_id, log.id)

db.session.commit()
return log

@staticmethod
def _trigger_valves(farm_id: int, zone_id: str, log_id: int):
"""
Opens valves for specific zone.
"""
valves = IrrigationValveAutomation.query.filter_by(farm_id=farm_id).all()
for v in valves:
v.status = 'OPEN'
v.flow_rate_lpm = 15.0
v.last_action = 'AUTO_OPEN_LOW_MOISTURE'
v.last_action_at = datetime.utcnow()
v.triggered_by_event_id = log_id

logger.info(f"Hydrating Zone {zone_id} on Farm {farm_id} due to stress shift.")

@staticmethod
def check_aquifer_sustainability(aquifer_id: str):
"""
Calculates if aquifer withdrawals are exceeding natural recharge rates.
"""
from backend.models.precision_irrigation import AquiferMonitoring
latest = AquiferMonitoring.query.filter_by(aquifer_id=aquifer_id).order_by(AquiferMonitoring.recorded_at.desc()).first()

if latest and latest.current_water_level_m < 15.0: # Below 15m is critical
latest.is_critical_depletion = True
db.session.commit()
return True
return False

29 changes: 29 additions & 0 deletions backend/tasks/irrigation_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from backend.celery_app import celery_app
from backend.models.precision_irrigation import WaterStressIndex, IrrigationValveAutomation
from backend.services.irrigation_orchestrator import IrrigationOrchestrator
import logging
import random

logger = logging.getLogger(__name__)

@celery_app.task(name='tasks.irrigation_autonomous_sweep')
def trigger_irrigation_sweeps():
"""
Periodic task to evaluate soil moisture across all zones and trigger hydration.
"""
logger.info("💧 [L3-1640] Initializing Autonomous Irrigation Sweep...")

# Simulate scanning zones
mock_zones = [
{'farm_id': 1, 'zone_id': 'ALPHA_1'},
{'farm_id': 1, 'zone_id': 'ALPHA_2'},
{'farm_id': 2, 'zone_id': 'BETA_9'}
]

for zone in mock_zones:
# Simulate sensor reading
moisture = random.uniform(15.0, 55.0)
IrrigationOrchestrator.process_zone_telemetry(zone['farm_id'], zone['zone_id'], moisture)

logger.info("✅ Irrigation sweep complete.")
return True
33 changes: 33 additions & 0 deletions examples/test_irrigation_logic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import unittest
from backend.services.irrigation_orchestrator import IrrigationOrchestrator

class TestIrrigationLogic(unittest.TestCase):
"""
Validates water deficit calculations and aquifer sustainability logic.
"""

def test_water_deficit_math(self):
# Case 1: Low moisture, low ET
deficit_low = IrrigationOrchestrator.calculate_water_deficit(1, 20.0, 20.0, 80.0)
# 45 - 20 = 25. ET mod = (20*0.05) + (20*0.02) = 1.0 + 0.4 = 1.4%
self.assertAlmostEqual(deficit_low, 25.35, places=2)

# Case 2: Low moisture, high ET (Heatwave)
deficit_high = IrrigationOrchestrator.calculate_water_deficit(1, 20.0, 42.0, 10.0)
# 45 - 20 = 25. ET mod = (42*0.05) + (90*0.02) = 2.1 + 1.8 = 3.9%
self.assertAlmostEqual(deficit_high, 25.975, places=3)

self.assertTrue(deficit_high > deficit_low, "Heatwave must result in higher water requirement.")

def test_valve_trigger_thresholds(self):
"""Verify that stress scores above 0.4 trigger autonomous actions."""
# 45 - 15 / 45 = 30 / 45 = 0.66 (Trigger)
stress_trigger = (45.0 - 15.0) / 45.0
self.assertTrue(stress_trigger > 0.4)

# 45 - 40 / 45 = 5 / 45 = 0.11 (No Trigger)
stress_safe = (45.0 - 40.0) / 45.0
self.assertFalse(stress_safe > 0.4)

if __name__ == '__main__':
unittest.main()
Loading