From 66df61878b80388bab96ce1f9a65e18b9be51394 Mon Sep 17 00:00:00 2001 From: Satyam Pandey Date: Thu, 26 Feb 2026 00:09:38 +0530 Subject: [PATCH 1/2] feat: Precision Irrigation System #1640 --- backend/models/precision_irrigation.py | 62 +++++++++++++++++ backend/services/irrigation_orchestrator.py | 74 +++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 backend/models/precision_irrigation.py create mode 100644 backend/services/irrigation_orchestrator.py diff --git a/backend/models/precision_irrigation.py b/backend/models/precision_irrigation.py new file mode 100644 index 00000000..e9aaf7ac --- /dev/null +++ b/backend/models/precision_irrigation.py @@ -0,0 +1,62 @@ +""" +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 + } diff --git a/backend/services/irrigation_orchestrator.py b/backend/services/irrigation_orchestrator.py new file mode 100644 index 00000000..d1a40102 --- /dev/null +++ b/backend/services/irrigation_orchestrator.py @@ -0,0 +1,74 @@ +""" +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.") From 23a1d49068ec007ff365a6eb099d28f194beea05 Mon Sep 17 00:00:00 2001 From: Satyam Pandey Date: Thu, 26 Feb 2026 00:34:39 +0530 Subject: [PATCH 2/2] feat: Expand Precision Irrigation & Aquifer Management System (#1640) --- backend/api/v1/__init__.py | 2 + backend/api/v1/irrigation_v2.py | 48 +++++++++++++++++++++ backend/models/__init__.py | 3 ++ backend/models/audit_log.py | 3 ++ backend/models/ledger.py | 1 + backend/models/precision_irrigation.py | 14 ++++++ backend/services/irrigation_orchestrator.py | 15 +++++++ backend/tasks/irrigation_sync.py | 29 +++++++++++++ examples/test_irrigation_logic.py | 33 ++++++++++++++ 9 files changed, 148 insertions(+) create mode 100644 backend/api/v1/irrigation_v2.py create mode 100644 backend/tasks/irrigation_sync.py create mode 100644 examples/test_irrigation_logic.py diff --git a/backend/api/v1/__init__.py b/backend/api/v1/__init__.py index 5c5a6a8a..1cc3d937 100644 --- a/backend/api/v1/__init__.py +++ b/backend/api/v1/__init__.py @@ -47,6 +47,7 @@ from .vaults import vaults_bp from .carbon import carbon_bp from .logistics import smart_freight_bp +from .irrigation_v2 import irrigation_v2_bp # Create v1 API blueprint api_v1 = Blueprint('api_v1', __name__, url_prefix='/api/v1') @@ -101,3 +102,4 @@ api_v1.register_blueprint(biosecurity_bp, url_prefix='/biosecurity') api_v1.register_blueprint(vaults_bp, url_prefix='/vaults') api_v1.register_blueprint(carbon_bp, url_prefix='/carbon') +api_v1.register_blueprint(irrigation_v2_bp, url_prefix='/irrigation-v2') diff --git a/backend/api/v1/irrigation_v2.py b/backend/api/v1/irrigation_v2.py new file mode 100644 index 00000000..52da0ec7 --- /dev/null +++ b/backend/api/v1/irrigation_v2.py @@ -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 diff --git a/backend/models/__init__.py b/backend/models/__init__.py index a83ac5ac..9b5093a9 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -43,6 +43,7 @@ from .reliability_log import ReliabilityLog from .market import ForwardContract, PriceHedgingLog from .circular import WasteInventory, BioEnergyOutput, CircularCredit +from .precision_irrigation import WaterStressIndex, IrrigationValveAutomation, AquiferMonitoring from .disease import MigrationVector, ContainmentZone from .ledger import ( LedgerAccount, LedgerTransaction, LedgerEntry, @@ -115,6 +116,8 @@ 'ReliabilityLog', 'ForwardContract', 'PriceHedgingLog', # Circular Economy 'WasteInventory', 'BioEnergyOutput', 'CircularCredit', + # Precision Irrigation (L3-1640) + 'WaterStressIndex', 'IrrigationValveAutomation', 'AquiferMonitoring', # Double-Entry Ledger 'LedgerAccount', 'LedgerTransaction', 'LedgerEntry', 'FXValuationSnapshot', 'Vault', 'VaultCurrencyPosition', 'FXRate', diff --git a/backend/models/audit_log.py b/backend/models/audit_log.py index b858d628..fd767e03 100644 --- a/backend/models/audit_log.py +++ b/backend/models/audit_log.py @@ -35,6 +35,9 @@ class AuditLog(db.Model): # Smart Freight (L3-1631) ai_logistics_flag = db.Column(db.Boolean, default=False) # For geo-fence & phyto auto-decisions + # Precision Irrigation (L3-1640) + irrigation_auto_flag = db.Column(db.Boolean, default=False) + timestamp = db.Column(db.DateTime, default=datetime.utcnow) # Meta data for extra context diff --git a/backend/models/ledger.py b/backend/models/ledger.py index 362b95b6..ca891ca5 100644 --- a/backend/models/ledger.py +++ b/backend/models/ledger.py @@ -44,6 +44,7 @@ class TransactionType(enum.Enum): DIVIDEND = 'DIVIDEND' FEE = 'FEE' INTEREST = 'INTEREST' + 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 diff --git a/backend/models/precision_irrigation.py b/backend/models/precision_irrigation.py index e9aaf7ac..81db099b 100644 --- a/backend/models/precision_irrigation.py +++ b/backend/models/precision_irrigation.py @@ -60,3 +60,17 @@ def to_dict(self): '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) + diff --git a/backend/services/irrigation_orchestrator.py b/backend/services/irrigation_orchestrator.py index d1a40102..7b3cb38b 100644 --- a/backend/services/irrigation_orchestrator.py +++ b/backend/services/irrigation_orchestrator.py @@ -72,3 +72,18 @@ def _trigger_valves(farm_id: int, zone_id: str, log_id: int): 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 + diff --git a/backend/tasks/irrigation_sync.py b/backend/tasks/irrigation_sync.py new file mode 100644 index 00000000..370cad0c --- /dev/null +++ b/backend/tasks/irrigation_sync.py @@ -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 diff --git a/examples/test_irrigation_logic.py b/examples/test_irrigation_logic.py new file mode 100644 index 00000000..a85a91b2 --- /dev/null +++ b/examples/test_irrigation_logic.py @@ -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()