2026-03-22 03:08:27 +03:30
from __future__ import annotations
from collections import defaultdict
from datetime import datetime
from typing import Any
from django . utils import timezone
2026-04-25 17:22:41 +03:30
def safe_number ( value , default = 0 ) :
return default if value is None else value
2026-03-22 03:08:27 +03:30
SEVERITY_ORDER = { " low " : 1 , " medium " : 2 , " high " : 3 , " critical " : 4 }
SEVERITY_UI = {
" low " : { " avatarColor " : " info " , " chipColor " : " info " } ,
" medium " : { " avatarColor " : " warning " , " chipColor " : " warning " } ,
" high " : { " avatarColor " : " error " , " chipColor " : " error " } ,
" critical " : { " avatarColor " : " error " , " chipColor " : " error " } ,
}
METRIC_META = {
" moisture " : {
" title " : " تنش رطوبتی " ,
" icon " : " tabler-droplet-half-2 " ,
" unit " : " % " ,
" domain " : " water_balance " ,
" threshold " : 45.0 ,
" danger_span " : 20.0 ,
" direction " : " below " ,
} ,
" temperature " : {
" title " : " تنش دمایی " ,
" icon " : " tabler-snowflake " ,
" unit " : " °C " ,
" domain " : " temperature_stress " ,
" threshold " : 0.0 ,
" danger_span " : 8.0 ,
" direction " : " below " ,
} ,
" ph " : {
" title " : " عدم تعادل pH " ,
" icon " : " tabler-flask " ,
" unit " : " pH " ,
" domain " : " root_chemistry " ,
" threshold_low " : 6.0 ,
" threshold_high " : 7.5 ,
" danger_span " : 1.5 ,
} ,
" ec " : {
" title " : " شوری / EC بالا " ,
" icon " : " tabler-bolt " ,
" unit " : " dS/m " ,
" domain " : " root_chemistry " ,
" threshold " : 3.0 ,
" danger_span " : 2.0 ,
" direction " : " above " ,
} ,
" fungal_risk " : {
" title " : " ریسک قارچی " ,
" icon " : " tabler-mushroom " ,
" unit " : " % " ,
" domain " : " disease_pressure " ,
" threshold " : 70.0 ,
" danger_span " : 20.0 ,
" direction " : " above " ,
} ,
}
SUMMARY_TEMPLATES = {
" moisture " : {
" low " : " افت رطوبت خاک ثبت شده و نیاز به پایش نزدیکتر دارد. " ,
" medium " : " رطوبت خاک پایینتر از محدوده مطلوب است و برنامه آبیاری باید بازبینی شود. " ,
" high " : " تنش آبی قابلتوجه شناسایی شده و مزرعه به اقدام آبیاری سریع نیاز دارد. " ,
" critical " : " کمبود شدید رطوبت فعال است و خطر افت رشد یا آسیب ریشه بالا رفته است. " ,
} ,
" temperature " : {
" low " : " دمای پایین ثبت شده و باید روند شبانه پایش شود. " ,
" medium " : " ریسک سرمازدگی ایجاد شده و اقدامات محافظتی باید آماده شود. " ,
" high " : " سرما به محدوده پرخطر رسیده و حفاظت دمایی باید در اولویت باشد. " ,
" critical " : " یخبندان بحرانی پیشبینی یا مشاهده شده و اقدام فوری حفاظتی لازم است. " ,
} ,
" ph " : {
" low " : " pH از محدوده مطلوب فاصله گرفته و نیاز به بررسی اصلاحی دارد. " ,
" medium " : " عدم تعادل pH میتواند جذب عناصر را مختل کند و باید اصلاح شود. " ,
" high " : " انحراف pH شدید است و ریسک اختلال تغذیه گیاه بالا رفته است. " ,
" critical " : " pH در وضعیت بحرانی قرار دارد و مداخله سریع برای جلوگیری از تنش تغذیهای لازم است. " ,
} ,
" ec " : {
" low " : " EC بالاتر از حد مرجع است و باید روند شوری پیگیری شود. " ,
" medium " : " شوری خاک میتواند رشد را محدود کند و نیاز به تعدیل دارد. " ,
" high " : " EC بالا به سطح پرخطر رسیده و مدیریت شوری باید انجام شود. " ,
" critical " : " شوری بحرانی فعال است و احتمال آسیب ریشه و افت جذب آب بسیار بالاست. " ,
} ,
" fungal_risk " : {
" low " : " شرایط اولیه برای فشار بیماری قارچی مشاهده شده است. " ,
" medium " : " رطوبت و خیسماندگی بستر، ریسک بیماری قارچی را افزایش داده است. " ,
" high " : " فشار بیماری قارچی بالا است و عملیات پیشگیرانه باید در اولویت قرار گیرد. " ,
" critical " : " الگوی بسیار پرخطر بیماری قارچی فعال است و اقدام فوری محافظتی لازم است. " ,
} ,
}
ACTION_TEMPLATES = {
" moisture " : {
" low " : " روند رطوبت را در نوبت بعدی کنترل و یکنواختی آبیاری را بررسی کنید. " ,
" medium " : " یک نوبت آبیاری اصلاحی برنامهریزی و افت رطوبت در عمقهای مختلف پایش شود. " ,
" high " : " آبیاری جبرانی کوتاهمدت اجرا و راندمان روش آبیاری بازبینی شود. " ,
" critical " : " آبیاری اضطراری، بررسی انسداد سامانه و پایش مجدد سنسور فوراً انجام شود. " ,
} ,
" temperature " : {
" low " : " پوشش یا برنامه محافظتی شبانه آماده نگه داشته شود. " ,
" medium " : " زمانبندی آبیاری و پوشش حفاظتی برای ساعات سرد تنظیم شود. " ,
" high " : " اقدامات ضدیخبندان مانند آبیاری حفاظتی یا پوشش فوری اجرا شود. " ,
" critical " : " پروتکل کامل حفاظت سرما فوراً فعال و وضعیت مزرعه در چند ساعت بعدی بازبینی شود. " ,
} ,
" ph " : {
" low " : " نمونهبرداری تکمیلی انجام و روند pH برای چند قرائت بعدی کنترل شود. " ,
" medium " : " برنامه اصلاح pH با توجه به نوع خاک و کود مصرفی بازتنظیم شود. " ,
" high " : " اصلاحکننده مناسب خاک در اولویت قرار گیرد و تغذیه گیاه بازبینی شود. " ,
" critical " : " مداخله اصلاحی فوری برای pH انجام و مصرف نهادههای تشدیدکننده متوقف شود. " ,
} ,
" ec " : {
" low " : " منبع آب و روند EC در روزهای آینده کنترل شود. " ,
" medium " : " شستوشوی محدود خاک یا اصلاح برنامه کوددهی بررسی شود. " ,
" high " : " کاهش بار نمکی، بازبینی کوددهی و ارزیابی زهکشی در اولویت قرار گیرد. " ,
" critical " : " اقدام فوری برای کاهش شوری و توقف نهادههای شورکننده انجام شود. " ,
} ,
" fungal_risk " : {
" low " : " تهویه و رطوبت بستر پایش شود و نشانههای اولیه بیماری بررسی گردد. " ,
" medium " : " فاصله آبیاری و تهویه مزرعه تنظیم و بازدید بیماری انجام شود. " ,
" high " : " اقدامات پیشگیرانه بیماری و کاهش رطوبت ماندگار فوراً اجرا شود. " ,
" critical " : " پروتکل فوری مدیریت بیماری فعال و مزرعه از نظر آلودگی کانونی بررسی شود. " ,
} ,
}
EXPLANATION_TEMPLATES = {
" moisture " : {
" low " : " رطوبت فعلی {current_value} {unit} به زیر آستانه {threshold_value} {unit} رسیده است و این وضعیت {duration_text} ادامه داشته است. " ,
" medium " : " رطوبت خاک {current_value} {unit} است؛ فاصله از آستانه {threshold_value} {unit} و تداوم {duration_text} نشاندهنده تنش آبی است. " ,
" high " : " رطوبت خاک در {current_value} {unit} ثبت شده که بهطور معنیدار پایینتر از آستانه {threshold_value} {unit} است و {duration_text} پایدار مانده است. " ,
" critical " : " رطوبت خاک به {current_value} {unit} سقوط کرده و با عبور شدید از آستانه {threshold_value} {unit} ، {duration_text} در وضعیت بحرانی باقی مانده است. " ,
} ,
" temperature " : {
" low " : " دما به {current_value} {unit} رسیده که از حد هشدار {threshold_value} {unit} پایینتر است و {duration_text} تداوم داشته است. " ,
" medium " : " دمای ثبتشده {current_value} {unit} کمتر از آستانه {threshold_value} {unit} است و تداوم {duration_text} ریسک تنش سرما را بالا برده است. " ,
" high " : " افت دما تا {current_value} {unit} همراه با ماندگاری {duration_text} شرایط پرخطر سرما را ایجاد کرده است. " ,
" critical " : " دمای {current_value} {unit} با ماندگاری {duration_text} نشان میدهد مزرعه در معرض یخبندان بحرانی قرار دارد. " ,
} ,
" ph " : {
" low " : " pH فعلی {current_value} {unit} از محدوده مرجع {threshold_value} خارج شده و این انحراف {duration_text} ادامه داشته است. " ,
" medium " : " انحراف pH تا {current_value} {unit} نسبت به حد مجاز {threshold_value} همراه با تداوم {duration_text} میتواند جذب عناصر را مختل کند. " ,
" high " : " pH {current_value} {unit} با فاصله زیاد از محدوده مرجع و پایداری {duration_text} یک تنش شیمیایی مهم ایجاد کرده است. " ,
" critical " : " وضعیت بحرانی pH در سطح {current_value} {unit} و با تداوم {duration_text} نیاز به اصلاح فوری دارد. " ,
} ,
" ec " : {
" low " : " EC فعلی {current_value} {unit} از آستانه {threshold_value} {unit} عبور کرده و {duration_text} پایدار مانده است. " ,
" medium " : " EC برابر {current_value} {unit} است؛ عبور از حد {threshold_value} {unit} با ماندگاری {duration_text} فشار شوری را افزایش داده است. " ,
" high " : " شوری ثبتشده در {current_value} {unit} با تداوم {duration_text} به سطح پرخطر رسیده است. " ,
" critical " : " EC در {current_value} {unit} و با پایداری {duration_text} نشاندهنده شوری بحرانی خاک است. " ,
} ,
" fungal_risk " : {
" low " : " رطوبت هوا و خاک شرایط اولیه فشار قارچی را ایجاد کرده و این الگو {duration_text} ادامه داشته است. " ,
" medium " : " ترکیب رطوبت {current_value} {unit} و ماندگاری {duration_text} از آستانه {threshold_value} {unit} عبور کرده و ریسک قارچی را بالا برده است. " ,
" high " : " شرایط مرطوب پایدار در {current_value} {unit} و تداوم {duration_text} فشار قارچی جدی ایجاد کرده است. " ,
" critical " : " ماندگاری طولانی شرایط بسیار مرطوب ( {current_value} {unit} ) در برابر حد {threshold_value} {unit} نشاندهنده ریسک بحرانی بیماری قارچی است. " ,
} ,
}
CLUSTER_TITLES = {
" water_balance " : " تعادل آب " ,
" temperature_stress " : " تنش دمایی " ,
" root_chemistry " : " شیمی ناحیه ریشه " ,
" disease_pressure " : " فشار بیماری " ,
}
def _now ( ) - > datetime :
return timezone . now ( )
def _timestamp_for ( obj : Any , fallback : datetime ) - > datetime :
for attr in ( " recorded_at " , " updated_at " , " created_at " , " forecast_date " ) :
value = getattr ( obj , attr , None )
if value is not None :
if isinstance ( value , datetime ) :
return value
return datetime . combine ( value , datetime . min . time ( ) , tzinfo = fallback . tzinfo )
return fallback
def _format_timestamp ( value : datetime ) - > str :
if timezone . is_naive ( value ) :
value = timezone . make_aware ( value , timezone . get_current_timezone ( ) )
return value . isoformat ( )
def _format_duration ( hours : float ) - > str :
rounded = max ( 1 , round ( hours ) )
if rounded > = 24 :
days = rounded / / 24
rem_hours = rounded % 24
if rem_hours == 0 :
return f " { days } روز "
return f " { days } روز و { rem_hours } ساعت "
return f " { rounded } ساعت "
def _severity_from_score ( score : float ) - > str :
if score > = 0.85 :
return " critical "
if score > = 0.55 :
return " high "
if score > = 0.3 :
return " medium "
return " low "
def _build_severity ( distance_ratio : float , duration_hours : float ) - > str :
duration_ratio = min ( duration_hours / 72.0 , 1.0 )
score = min ( ( distance_ratio * 0.7 ) + ( duration_ratio * 0.3 ) , 1.0 )
return _severity_from_score ( score )
def _collect_active_history_duration (
current_value : float ,
history : list [ Any ] ,
field_name : str ,
threshold : float ,
direction : str ,
fallback_timestamp : datetime ,
) - > tuple [ float , datetime ] :
if direction == " below " :
is_violating = lambda value : value < threshold
else :
is_violating = lambda value : value > threshold
if not is_violating ( current_value ) :
return 0.0 , fallback_timestamp
violating_times = [ fallback_timestamp ]
for item in history :
value = getattr ( item , field_name , None )
if value is None :
break
if not is_violating ( value ) :
break
violating_times . append ( _timestamp_for ( item , fallback_timestamp ) )
oldest_violation = min ( violating_times )
duration_hours = max ( ( _now ( ) - oldest_violation ) . total_seconds ( ) / 3600 , 1.0 )
return duration_hours , oldest_violation
def _make_alert (
metric_type : str ,
current_value : float ,
threshold_value : float | str ,
severity : str ,
duration_hours : float ,
timestamp : datetime ,
sensor_id : str ,
zone_id : str | None = None ,
direction : str | None = None ,
metadata : dict [ str , Any ] | None = None ,
) - > dict [ str , Any ] :
meta = METRIC_META [ metric_type ]
unit = meta [ " unit " ]
threshold_display = threshold_value
if isinstance ( threshold_value , float ) :
threshold_display = round ( threshold_value , 2 )
explanation = EXPLANATION_TEMPLATES [ metric_type ] [ severity ] . format (
current_value = round ( current_value , 2 ) ,
threshold_value = threshold_display ,
unit = unit ,
duration_text = _format_duration ( duration_hours ) ,
)
return {
" metric_type " : metric_type ,
" title " : meta [ " title " ] ,
" current_value " : round ( current_value , 2 ) ,
" threshold_value " : threshold_display ,
" severity " : severity ,
" duration_hours " : round ( duration_hours , 1 ) ,
" duration " : _format_duration ( duration_hours ) ,
" timestamp " : _format_timestamp ( timestamp ) ,
" sensor_id " : sensor_id ,
" zone_id " : zone_id ,
" domain " : meta [ " domain " ] ,
" direction " : direction ,
" unit " : unit ,
" icon " : meta [ " icon " ] ,
" summary " : SUMMARY_TEMPLATES [ metric_type ] [ severity ] ,
" recommended_action " : ACTION_TEMPLATES [ metric_type ] [ severity ] ,
" explanation " : explanation ,
" metadata " : metadata or { } ,
}
def _detect_moisture_alert ( sensor : Any , history : list [ Any ] , sensor_id : str ) - > list [ dict [ str , Any ] ] :
current_value = safe_number ( getattr ( sensor , " soil_moisture " , None ) , 0 )
meta = METRIC_META [ " moisture " ]
threshold = meta [ " threshold " ]
if current_value > = threshold :
return [ ]
timestamp = _timestamp_for ( sensor , _now ( ) )
duration_hours , started_at = _collect_active_history_duration (
current_value = current_value ,
history = history ,
field_name = " soil_moisture " ,
threshold = threshold ,
direction = meta [ " direction " ] ,
fallback_timestamp = timestamp ,
)
distance_ratio = min ( ( threshold - current_value ) / meta [ " danger_span " ] , 1.0 )
severity = _build_severity ( distance_ratio , duration_hours )
return [
_make_alert (
metric_type = " moisture " ,
current_value = current_value ,
threshold_value = threshold ,
severity = severity ,
duration_hours = duration_hours ,
timestamp = started_at ,
sensor_id = sensor_id ,
direction = meta [ " direction " ] ,
)
]
def _detect_ph_alert ( sensor : Any , history : list [ Any ] , sensor_id : str ) - > list [ dict [ str , Any ] ] :
current_value = safe_number ( getattr ( sensor , " soil_ph " , None ) , 7 )
meta = METRIC_META [ " ph " ]
low = meta [ " threshold_low " ]
high = meta [ " threshold_high " ]
if low < = current_value < = high :
return [ ]
direction = " below " if current_value < low else " above "
threshold = low if direction == " below " else high
timestamp = _timestamp_for ( sensor , _now ( ) )
duration_hours , started_at = _collect_active_history_duration (
current_value = current_value ,
history = history ,
field_name = " soil_ph " ,
threshold = threshold ,
direction = direction ,
fallback_timestamp = timestamp ,
)
distance_ratio = min ( abs ( current_value - threshold ) / meta [ " danger_span " ] , 1.0 )
severity = _build_severity ( distance_ratio , duration_hours )
threshold_display = f " { low } - { high } "
return [
_make_alert (
metric_type = " ph " ,
current_value = current_value ,
threshold_value = threshold_display ,
severity = severity ,
duration_hours = duration_hours ,
timestamp = started_at ,
sensor_id = sensor_id ,
direction = direction ,
metadata = { " boundary_threshold " : threshold } ,
)
]
def _detect_ec_alert ( sensor : Any , history : list [ Any ] , sensor_id : str ) - > list [ dict [ str , Any ] ] :
current_value = safe_number ( getattr ( sensor , " electrical_conductivity " , None ) , 0 )
meta = METRIC_META [ " ec " ]
threshold = meta [ " threshold " ]
if current_value < = threshold :
return [ ]
timestamp = _timestamp_for ( sensor , _now ( ) )
duration_hours , started_at = _collect_active_history_duration (
current_value = current_value ,
history = history ,
field_name = " electrical_conductivity " ,
threshold = threshold ,
direction = meta [ " direction " ] ,
fallback_timestamp = timestamp ,
)
distance_ratio = min ( ( current_value - threshold ) / meta [ " danger_span " ] , 1.0 )
severity = _build_severity ( distance_ratio , duration_hours )
return [
_make_alert (
metric_type = " ec " ,
current_value = current_value ,
threshold_value = threshold ,
severity = severity ,
duration_hours = duration_hours ,
timestamp = started_at ,
sensor_id = sensor_id ,
direction = meta [ " direction " ] ,
)
]
def _detect_frost_alert ( forecasts : list [ Any ] , sensor_id : str ) - > list [ dict [ str , Any ] ] :
violating = [ forecast for forecast in forecasts [ : 3 ] if safe_number ( getattr ( forecast , " temperature_min " , None ) , 10 ) < 0 ]
if not violating :
return [ ]
first = violating [ 0 ]
coldest = min ( safe_number ( getattr ( item , " temperature_min " , None ) , 0 ) for item in violating )
duration_hours = max ( len ( violating ) * 24.0 , 24.0 )
meta = METRIC_META [ " temperature " ]
distance_ratio = min ( ( meta [ " threshold " ] - coldest ) / meta [ " danger_span " ] , 1.0 )
severity = _build_severity ( distance_ratio , duration_hours )
timestamp = _timestamp_for ( first , _now ( ) )
return [
_make_alert (
metric_type = " temperature " ,
current_value = coldest ,
threshold_value = meta [ " threshold " ] ,
severity = severity ,
duration_hours = duration_hours ,
timestamp = timestamp ,
sensor_id = sensor_id ,
direction = meta [ " direction " ] ,
metadata = { " forecast_days_impacted " : len ( violating ) } ,
)
]
def _detect_fungal_risk ( sensor : Any , forecasts : list [ Any ] , history : list [ Any ] , sensor_id : str ) - > list [ dict [ str , Any ] ] :
humidity_values = [ safe_number ( getattr ( forecast , " humidity_mean " , None ) , None ) for forecast in forecasts [ : 3 ] ]
humidity_values = [ value for value in humidity_values if value is not None ]
if not humidity_values :
return [ ]
humidity = sum ( humidity_values ) / len ( humidity_values )
moisture = safe_number ( getattr ( sensor , " soil_moisture " , None ) , 0 )
meta = METRIC_META [ " fungal_risk " ]
threshold = meta [ " threshold " ]
if humidity < = threshold or moisture < = 60 :
return [ ]
timestamp = _timestamp_for ( sensor , _now ( ) )
duration_hours , started_at = _collect_active_history_duration (
current_value = moisture ,
history = history ,
field_name = " soil_moisture " ,
threshold = 60.0 ,
direction = " above " ,
fallback_timestamp = timestamp ,
)
duration_hours = max ( duration_hours , len ( forecasts [ : 3 ] ) * 12.0 )
humidity_ratio = min ( ( humidity - threshold ) / meta [ " danger_span " ] , 1.0 )
moisture_ratio = min ( ( moisture - 60.0 ) / 20.0 , 1.0 )
severity = _build_severity ( ( humidity_ratio * 0.6 ) + ( moisture_ratio * 0.4 ) , duration_hours )
return [
_make_alert (
metric_type = " fungal_risk " ,
current_value = humidity ,
threshold_value = threshold ,
severity = severity ,
duration_hours = duration_hours ,
timestamp = started_at ,
sensor_id = sensor_id ,
direction = meta [ " direction " ] ,
metadata = { " soil_moisture " : round ( moisture , 2 ) } ,
)
]
def _sort_alerts ( alerts : list [ dict [ str , Any ] ] ) - > list [ dict [ str , Any ] ] :
return sorted (
alerts ,
key = lambda alert : (
SEVERITY_ORDER [ alert [ " severity " ] ] ,
alert [ " duration_hours " ] ,
abs ( float ( alert [ " current_value " ] ) ) if isinstance ( alert [ " current_value " ] , ( int , float ) ) else 0 ,
) ,
reverse = True ,
)
def _build_clusters ( alerts : list [ dict [ str , Any ] ] ) - > list [ dict [ str , Any ] ] :
grouped : dict [ str , list [ dict [ str , Any ] ] ] = defaultdict ( list )
for alert in alerts :
grouped [ alert [ " domain " ] ] . append ( alert )
clusters : list [ dict [ str , Any ] ] = [ ]
for domain , items in grouped . items ( ) :
ordered = _sort_alerts ( items )
top = ordered [ 0 ]
clusters . append (
{
" domain " : domain ,
" title " : CLUSTER_TITLES . get ( domain , domain ) ,
" alert_count " : len ( items ) ,
" highest_severity " : top [ " severity " ] ,
" primary_metric " : top [ " metric_type " ] ,
" summary " : top [ " summary " ] ,
" alert_ids " : [ f " { item [ ' metric_type ' ] } : { item [ ' timestamp ' ] } " for item in ordered ] ,
}
)
return sorted ( clusters , key = lambda cluster : SEVERITY_ORDER [ cluster [ " highest_severity " ] ] , reverse = True )
def _build_alert_stats ( alerts : list [ dict [ str , Any ] ] ) - > list [ dict [ str , Any ] ] :
stats : list [ dict [ str , Any ] ] = [ ]
for metric_type , meta in METRIC_META . items ( ) :
matches = [ alert for alert in alerts if alert [ " metric_type " ] == metric_type ]
if not matches :
continue
top = _sort_alerts ( matches ) [ 0 ]
ui = SEVERITY_UI [ top [ " severity " ] ]
stats . append (
{
" title " : meta [ " title " ] ,
" count " : str ( len ( matches ) ) ,
" avatarColor " : ui [ " avatarColor " ] ,
" avatarIcon " : meta [ " icon " ] ,
" severity " : top [ " severity " ] ,
" topSummary " : top [ " summary " ] ,
}
)
return stats
2026-03-22 01:09:09 +03:30
2026-05-13 16:45:54 +03:30
def _snapshot_metric ( ai_snapshot : dict [ str , Any ] | None , metric_name : str ) - > float | None :
if not isinstance ( ai_snapshot , dict ) :
return None
farm_metrics = ai_snapshot . get ( " farm_metrics " ) or { }
resolved_metrics = farm_metrics . get ( " resolved_metrics " ) if isinstance ( farm_metrics , dict ) else { }
if not isinstance ( resolved_metrics , dict ) :
return None
return safe_number ( resolved_metrics . get ( metric_name ) , None )
def _snapshot_weather ( ai_snapshot : dict [ str , Any ] | None ) - > dict [ str , Any ] :
if not isinstance ( ai_snapshot , dict ) :
return { }
weather = ai_snapshot . get ( " weather " ) or { }
forecast = weather . get ( " forecast " ) if isinstance ( weather , dict ) else None
return forecast if isinstance ( forecast , dict ) else { }
def _block_metric_alerts ( ai_snapshot : dict [ str , Any ] | None , sensor_id : str ) - > list [ dict [ str , Any ] ] :
alerts : list [ dict [ str , Any ] ] = [ ]
if not isinstance ( ai_snapshot , dict ) :
return alerts
for block in ai_snapshot . get ( " block_metrics " ) or [ ] :
if not isinstance ( block , dict ) :
continue
block_code = str ( block . get ( " block_code " ) or " default-block " )
metrics = block . get ( " resolved_metrics " ) or { }
moisture = safe_number ( metrics . get ( " soil_moisture " ) , None )
if moisture is not None and moisture < 25 :
alerts . append (
_make_alert (
metric_type = " moisture " ,
current_value = moisture ,
threshold_value = 25.0 ,
severity = " warning " if moisture > = 18 else " danger " ,
duration_hours = 1.0 ,
timestamp = _now ( ) ,
sensor_id = sensor_id ,
zone_id = block_code ,
direction = " below " ,
metadata = {
" evaluation_level " : " block " ,
" affected_blocks " : [ block_code ] ,
" source " : " block_metrics " ,
} ,
)
)
return alerts
def _sub_block_support ( ai_snapshot : dict [ str , Any ] | None , metric_type : str ) - > list [ dict [ str , Any ] ] :
evidence : list [ dict [ str , Any ] ] = [ ]
if not isinstance ( ai_snapshot , dict ) :
return evidence
metric_key = {
" moisture " : " soil_moisture " ,
" ph " : " soil_ph " ,
" ec " : " electrical_conductivity " ,
} . get ( metric_type )
if not metric_key :
return evidence
for sub_block in ai_snapshot . get ( " sub_block_metrics " ) or [ ] :
if not isinstance ( sub_block , dict ) :
continue
metrics = sub_block . get ( " resolved_metrics " ) or { }
value = metrics . get ( metric_key )
if value is None :
continue
evidence . append (
{
" block_code " : sub_block . get ( " block_code " ) or " default-block " ,
" sub_block_code " : sub_block . get ( " sub_block_code " ) or " default-sub-block " ,
" metric " : metric_key ,
" value " : round ( float ( value ) , 2 ) ,
}
)
return evidence
2026-03-22 01:09:09 +03:30
def build_farm_alerts_tracker ( sensor_id : str , context : dict | None = None , ai_bundle : dict | None = None ) - > dict :
context = context or { }
2026-05-13 16:45:54 +03:30
ai_snapshot = ( ai_bundle or { } ) . get ( " ai_snapshot " ) if isinstance ( ai_bundle , dict ) else None
2026-03-22 01:09:09 +03:30
forecasts = context . get ( " forecasts " , [ ] )
2026-03-22 03:08:27 +03:30
2026-05-13 16:45:54 +03:30
if not isinstance ( ai_snapshot , dict ) :
2026-03-22 03:08:27 +03:30
return {
" totalAlerts " : 0 ,
" alerts " : [ ] ,
" alertStats " : [ ] ,
" alertClusters " : [ ] ,
" mostCriticalIssue " : None ,
" prioritizedAlertSummaries " : [ ] ,
" recommendedOperationalActions " : [ ] ,
" humanReadableExplanations " : [ ] ,
2026-05-13 16:45:54 +03:30
" source_metadata " : { " status " : " missing " , " fallback " : " no_ai_snapshot " } ,
2026-03-22 03:08:27 +03:30
}
alerts = [ ]
2026-05-13 16:45:54 +03:30
moisture = _snapshot_metric ( ai_snapshot , " soil_moisture " )
if moisture is not None and moisture < 25 :
alerts . append (
_make_alert (
metric_type = " moisture " ,
current_value = moisture ,
threshold_value = 25.0 ,
severity = " warning " if moisture > = 18 else " danger " ,
duration_hours = 1.0 ,
timestamp = _now ( ) ,
sensor_id = sensor_id ,
direction = " below " ,
metadata = {
" evaluation_level " : " farm " ,
" affected_blocks " : [ item . get ( " block_code " ) for item in ( ai_snapshot . get ( " block_metrics " ) or [ ] ) ] ,
" supporting_sub_blocks " : _sub_block_support ( ai_snapshot , " moisture " ) ,
" source " : " farm_metrics " ,
} ,
)
)
soil_ph = _snapshot_metric ( ai_snapshot , " soil_ph " )
if soil_ph is not None and not ( 6.0 < = soil_ph < = 7.8 ) :
alerts . append (
_make_alert (
metric_type = " ph " ,
current_value = soil_ph ,
threshold_value = " 6.0-7.8 " ,
severity = " warning " if 5.5 < = soil_ph < = 8.2 else " danger " ,
duration_hours = 1.0 ,
timestamp = _now ( ) ,
sensor_id = sensor_id ,
direction = " below " if soil_ph < 6.0 else " above " ,
metadata = {
" evaluation_level " : " farm " ,
" supporting_sub_blocks " : _sub_block_support ( ai_snapshot , " ph " ) ,
" source " : " farm_metrics " ,
} ,
)
)
ec = _snapshot_metric ( ai_snapshot , " electrical_conductivity " )
if ec is not None and ec > 2.5 :
alerts . append (
_make_alert (
metric_type = " ec " ,
current_value = ec ,
threshold_value = 2.5 ,
severity = " warning " if ec < = 3.2 else " danger " ,
duration_hours = 1.0 ,
timestamp = _now ( ) ,
sensor_id = sensor_id ,
direction = " above " ,
metadata = {
" evaluation_level " : " farm " ,
" supporting_sub_blocks " : _sub_block_support ( ai_snapshot , " ec " ) ,
" source " : " farm_metrics " ,
} ,
)
)
weather = _snapshot_weather ( ai_snapshot )
if weather :
temp_min = safe_number ( weather . get ( " temperature_min " ) , None )
if temp_min is not None and temp_min < 0 :
alerts . append (
_make_alert (
metric_type = " temperature " ,
current_value = temp_min ,
threshold_value = 0.0 ,
severity = " warning " if temp_min > = - 2 else " danger " ,
duration_hours = 24.0 ,
timestamp = _now ( ) ,
sensor_id = sensor_id ,
direction = " below " ,
metadata = {
" evaluation_level " : " farm " ,
" source " : " weather_forecast " ,
" weather_policy " : " center_location_latest_forecast " ,
} ,
)
)
humidity = safe_number ( weather . get ( " humidity_mean " ) , None )
if humidity is not None and moisture is not None and humidity > 75 and moisture > 60 :
alerts . append (
_make_alert (
metric_type = " fungal_risk " ,
current_value = humidity ,
threshold_value = 75.0 ,
severity = " warning " if humidity < = 85 else " danger " ,
duration_hours = 24.0 ,
timestamp = _now ( ) ,
sensor_id = sensor_id ,
direction = " above " ,
metadata = {
" evaluation_level " : " farm " ,
" supporting_sub_blocks " : _sub_block_support ( ai_snapshot , " moisture " ) ,
" source " : " farm_metrics+weather_forecast " ,
" soil_moisture " : round ( moisture , 2 ) ,
} ,
)
)
alerts . extend ( _block_metric_alerts ( ai_snapshot , sensor_id ) )
2026-03-22 01:09:09 +03:30
2026-03-22 03:08:27 +03:30
ordered_alerts = _sort_alerts ( alerts )
clusters = _build_clusters ( ordered_alerts )
top_alert = ordered_alerts [ 0 ] if ordered_alerts else None
2026-03-22 01:09:09 +03:30
return {
2026-03-22 03:08:27 +03:30
" totalAlerts " : len ( ordered_alerts ) ,
" alerts " : ordered_alerts ,
" alertStats " : _build_alert_stats ( ordered_alerts ) ,
" alertClusters " : clusters ,
" mostCriticalIssue " : top_alert ,
" prioritizedAlertSummaries " : [ alert [ " summary " ] for alert in ordered_alerts ] ,
" recommendedOperationalActions " : [ alert [ " recommended_action " ] for alert in ordered_alerts ] ,
" humanReadableExplanations " : [ alert [ " explanation " ] for alert in ordered_alerts ] ,
2026-05-13 16:45:54 +03:30
" source_metadata " : {
" farm_metrics " : ( ai_snapshot . get ( " source_metadata " ) or { } ) . get ( " farm_metrics " , { } ) ,
" weather " : ( ( ai_snapshot . get ( " weather " ) or { } ) . get ( " source_metadata " ) or { } ) ,
" default_block_policy " : ( ai_snapshot . get ( " aggregation_policy " ) or { } ) . get ( " default_block_policy " ) ,
} ,
2026-03-22 01:09:09 +03:30
}