- KFS × Navera
- IoT & AI for Smart Agriculture
- السيشن 17
دمج الذكاء الاصطناعي مع الويب وأنظمة التحكم
Integrating AI with Web & Control Systems
من Notebook إلى نظام يعمل: حفظ النموذج وتحميله على السيرفر، بناء Prediction API يجمع ميزات اللحظة من العقدة ومن تنبؤات الطقس الحية، تجنب اختلاف التدريب عن التشغيل، وربط التوقع بلوحة التحكم بمؤشر توصية يتحدث تلقائياً — مع مراقبة جودة النموذج بعد النشر.
نظرة عامة على السيشن
بنهاية السيشن ستكون قادراً على
- تحفظ وتحمل النموذج مع بيانات تعريفه (Model bundle: الميزات، العتبة، الأفق) بـ joblib، وتعرف دور ONNX.
- تبني Prediction API يجمع الميزات من قاعدة البيانات ومن Open-Meteo Forecast API.
- تتجنب Training–serving skew (اختلاف حساب الميزات بين التدريب والتشغيل).
- تعرض التوقع في لوحة التحكم بمؤشر توصية وتحديث تلقائي.
- تراقب النموذج بعد النشر (Model monitoring) وتعيد تدريبه عند الحاجة (Hot reload).
التطبيق في مزرعة الكلية
عرض توقع الري لكل حقل على لوحة تحكم المزرعة: احتمال الحاجة للري خلال 24 ساعة، التوصية (اروِ/انتظر)، الكمية المقترحة بالمليمتر، وتأجيل تلقائي إذا كان المطر متوقعاً — ومقارنة التوقعات بما حدث فعلاً في الحقل.
المُخرج (Deliverable)
نظام ذكي يعرض تنبؤات حية على الواجهة: Endpoint /api/v1/predict يعمل بالنموذج المدرب، لوحة تعرض التوصية، وجدول يسجل كل توقع ونتيجته الفعلية لمراقبة دقة النموذج.
خطة السيشن (180 دقيقة)
- 0:00 مراجعة مراجعة: النموذج والعتبة المختارة 10 د
- 0:10 شرح تشغيل النماذج في الإنتاج: الحفظ، الـ API، اختلاف التدريب عن التشغيل 25 د
- 0:35 تطبيق Lab: predictor.py و /api/v1/predict خطوة بخطوة 35 د
- 1:10 استراحة استراحة 10 د
- 1:20 تطبيق Lab: التوصية في لوحة التحكم + إعادة التدريب الحي 35 د
- 1:55 مشروع Lab: مراقبة النموذج — تسجيل التوقعات ومقارنتها بالواقع 30 د
- 2:25 ميداني ميداني: توقع الري على اللوحة مقابل حالة الحقل 25 د
- 2:50 تقييم اختبار سريع + الواجب 10 د
الأدوات والمكونات
قاموس المصطلحات (Glossary)
تحويل النموذج المدرب إلى ملف (joblib / pickle / ONNX).
النموذج + أسماء الميزات + العتبة + الإصدار في ملف واحد.
تشغيل النموذج بلغات وأجهزة مختلفة (حتى المتحكمات القوية).
استخدام النموذج المدرب للتنبؤ ببيانات جديدة.
نفس طريقة الحساب في التدريب والتشغيل.
اختلاف الميزات وقت التشغيل عن التدريب ⇒ تنبؤات خاطئة بصمت.
Open-Meteo يعطي توقعات الطقس للساعات القادمة مجاناً.
قاعدة بسيطة تعمل إذا تعذر تحميل النموذج.
تحميل النموذج الجديد بدون إعادة تشغيل السيرفر.
تراجع دقة النموذج مع تغير الموسم أو المحصول.
لا تحمّل ملف pickle/joblib من مصدر غير موثوق — قد ينفذ كوداً.
الوقت من الطلب حتى الرد.
النماذج في الإنتاج
25 دقيقةرحلة التوقع في كل طلب
-
آخر قراءة
من جدول readings: رطوبة التربة والحرارة ورطوبة الهواء ووقت القراءة.
-
ميزات تاريخية
قراءة قبل 24 ساعة لحساب vwc_drop_24h.
-
ميزات الطقس
Open-Meteo Forecast: ET0 والمطر آخر 24 ساعة، حرارة التربة، الإشعاع، الرياح — ومطر الـ 24 ساعة القادمة.
-
المعايرة
تحويل نسبة الحساس % إلى VWC بنفس معايرة الحقل (config).
-
النموذج
predict_proba ⇒ احتمال، ومقارنته بالعتبة المختارة (0.4).
-
قواعد زراعية
مطر متوقع ≥ 5 مم ⇒ تأجيل، وحساب الكمية لملء منطقة الجذور (بحد 40 مم).
الخطر الصامت: Training–serving skew
النموذج تدرب على soil_vwc بوحدة m³/m³، لكن العقدة ترسل soil_moisture_pct بنسبة %. لو أرسلنا 35 بدل 0.255 لن يظهر أي خطأ — سيعطي النموذج توقعات خاطئة تماماً بثقة كاملة. لذلك المعايرة pct_to_vwc() وأسماء الميزات محفوظة داخل الحزمة، ونرتب الأعمدة بـ bundle["features"].
| الصيغة | المميزات | العيوب | استخدامنا |
|---|---|---|---|
| joblib / pickle | سهل جداً مع scikit-learn | نفس إصدار المكتبات، وغير آمن من مصادر مجهولة | ✅ المنصة |
| ONNX | يعمل بأي لغة وأسرع، يصلح للأجهزة | تحويل إضافي (skl2onnx) | مستقبلاً: تشغيل على Raspberry Pi بدون scikit-learn |
| TFLite / Edge Impulse | يعمل على ESP32 نفسه | نماذج صغيرة فقط | مسار Edge AI بعد الكورس |
Lab: Prediction API
35 دقيقة"""Load the irrigation model trained in session 16 and serve predictions (session 17)."""
from datetime import datetime, timedelta
import config
import db
import weather
_model = {"bundle": None, "mtime": None}
def pct_to_vwc(pct):
"""Capacitive sensor % -> volumetric water content using the 2-point calibration in config."""
return config.VWC_AT_0_PCT + (config.VWC_AT_100_PCT - config.VWC_AT_0_PCT) * (pct / 100.0)
def _load():
if not config.MODEL_PATH.exists():
return None
mtime = config.MODEL_PATH.stat().st_mtime
if _model["mtime"] != mtime: # hot-reload when you retrain the model
import joblib
try:
_model["bundle"] = joblib.load(config.MODEL_PATH)
except Exception as exc: # e.g. trained with another scikit-learn version -> retrain (s16)
print("model load failed, using the rule fallback:", exc)
_model["bundle"] = None
_model["mtime"] = mtime
return _model["bundle"]
def predict(node_id):
latest = db.rows("SELECT * FROM readings WHERE node_id=? ORDER BY ts DESC LIMIT 1", (node_id,))
if not latest or latest[0]["soil_moisture_pct"] is None:
return {"ok": False, "error": "no readings for this node yet"}
r = latest[0]
since = (datetime.fromisoformat(r["ts"]) - timedelta(hours=24)).isoformat(timespec="seconds")
old = db.rows("SELECT soil_moisture_pct FROM readings WHERE node_id=? AND ts<=? AND soil_moisture_pct IS NOT NULL "
"ORDER BY ts DESC LIMIT 1", (node_id, since))
w = weather.current()
vwc = pct_to_vwc(r["soil_moisture_pct"])
features = {
"soil_vwc": vwc,
"vwc_drop_24h": (pct_to_vwc(old[0]["soil_moisture_pct"]) - vwc) if old else 0.0,
"air_temp": r["air_temp_c"] if r["air_temp_c"] is not None else 25.0,
"air_humidity": r["air_humidity_pct"] if r["air_humidity_pct"] is not None else 60.0,
"soil_temp": w["soil_temp"], "et0_24h": w["et0_24h"], "rain_24h": w["rain_24h"],
"radiation": w["radiation"], "wind": w["wind"], "hour": datetime.fromisoformat(r["ts"]).hour,
}
bundle = _load()
if bundle is None: # graceful fallback: agronomic rule
prob = 1.0 if vwc < 0.30 else 0.0
source = "rule (model file not found - run ai/s16_irrigation_classifier.py)"
else:
import pandas as pd
X = pd.DataFrame([[features[f] for f in bundle["features"]]], columns=bundle["features"])
prob = float(bundle["model"].predict_proba(X)[0][1])
source = "ml"
rec = "irrigate" if prob >= config.IRRIGATION_THRESHOLD else "wait"
if w.get("rain_next_24h", 0) >= 5:
rec, note = "wait", "rain expected in the next 24 h"
else:
note = ""
# depth to refill the 40 cm root zone to field capacity, capped at one practical irrigation (40 mm)
litres_m2 = round(min(40.0, max(0.0, (config.VWC_AT_100_PCT * 0.8 - vwc)) * 1000 * 0.40), 1) if rec == "irrigate" else 0
return {"ok": True, "node_id": node_id, "probability": round(prob, 3), "threshold": config.IRRIGATION_THRESHOLD,
"recommendation": rec,
"note": note, "source": source, "vwc": round(vwc, 3), "water_mm": litres_m2,
"weather": w, "features": {k: round(v, 3) if isinstance(v, float) else v for k, v in features.items()}}
"""Live weather for the farm from the free Open-Meteo forecast API (no key). Cached 30 minutes."""
import json
import ssl
import time
import urllib.request
from datetime import datetime
import config
_cache = {"t": 0, "data": None}
URL = ("https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}"
"&hourly=et0_fao_evapotranspiration,precipitation,soil_temperature_0cm,shortwave_radiation,wind_speed_10m"
"&past_days=1&forecast_days=2&timezone=Africa%2FCairo")
def _ssl_context():
"""Use certifi's up-to-date CA bundle when available (old Windows Python installs ship expired CAs)."""
try:
import certifi
return ssl.create_default_context(cafile=certifi.where())
except ImportError:
return ssl.create_default_context()
def _fetch():
with urllib.request.urlopen(URL.format(lat=config.FARM_LAT, lon=config.FARM_LON), timeout=8,
context=_ssl_context()) as r:
return json.load(r)["hourly"]
def current():
"""Return the weather features the irrigation model needs (with safe fallbacks offline)."""
if time.time() - _cache["t"] > 1800 or _cache["data"] is None:
try:
_cache["data"] = _fetch()
_cache["t"] = time.time()
except Exception:
_cache["data"] = None
h = _cache["data"]
if not h:
return {"online": False, "et0_24h": 4.0, "rain_24h": 0.0, "soil_temp": 25.0,
"radiation": 400.0, "wind": 10.0, "et0_next_24h": 4.0}
now = datetime.now().strftime("%Y-%m-%dT%H:00")
i = h["time"].index(now) if now in h["time"] else len(h["time"]) // 2
past = slice(max(0, i - 23), i + 1)
nxt = slice(i + 1, i + 25)
return {
"online": True,
"et0_24h": round(sum(v or 0 for v in h["et0_fao_evapotranspiration"][past]), 2),
"rain_24h": round(sum(v or 0 for v in h["precipitation"][past]), 2),
"soil_temp": h["soil_temperature_0cm"][i],
"radiation": h["shortwave_radiation"][i],
"wind": h["wind_speed_10m"][i],
"et0_next_24h": round(sum(v or 0 for v in h["et0_fao_evapotranspiration"][nxt]), 2),
"rain_next_24h": round(sum(v or 0 for v in h["precipitation"][nxt]), 2),
}
الطقس يُخزن مؤقتاً 30 دقيقة (Cache) — لا داعي لطلب Open-Meteo مع كل تحديث للوحة. وعند انقطاع الإنترنت يعود بقيم احتياطية معقولة بدلاً من إسقاط الـ API. واستخدمنا certifi لأن نسخ Python القديمة على ويندوز تحمل شهادات SSL منتهية.
استدعاء /api/v1/predict/field_A
نتيجة فعلية من اختبار المنصة (عقدة بقراءة جافة، والطقس الحي لموقع الكلية متصل):
curl -b cookies.txt http://127.0.0.1:5000/api/v1/predict/field_A
المخرجات الفعلية عند التشغيل (Real output)
{'probability': 0.877, 'recommendation': 'irrigate', 'threshold': 0.4, 'vwc': 0.205,
'water_mm': 40.0, 'source': 'ml', 'weather': {'online': True, 'et0_24h': 3.31, 'rain_24h': 0,
'soil_temp': 18.6, 'radiation': 0.0, 'wind': 8.0, 'et0_next_24h': 2.5, 'rain_next_24h': 0}}قِس زمن الاستجابة
أضف لاستجابة الـ API حقل latency_ms يقيس زمن التوقع كاملاً. ما الجزء الأبطأ: قاعدة البيانات، Open-Meteo (أول طلب)، أم النموذج؟
الحل (Solution)
import time
@app.get("/api/v1/predict/<node_id>")
@login_required
def predict(node_id):
t0 = time.perf_counter()
result = predictor.predict(node_id)
result["latency_ms"] = round((time.perf_counter() - t0) * 1000, 1)
return jsonify(result)
Lab: التوصية في اللوحة
35 دقيقةasync function loadPredictions() {
const nodes = [...document.querySelectorAll('#control select[name=node_id] option')].map(o => o.value);
const out = await Promise.all(nodes.map(n => api(`/api/v1/predict/${n}`))); // all nodes in parallel
$('#predictions').innerHTML = out.map((p, i) => !p || !p.ok
? `<div class="pred"><b>${nodes[i]}</b><span class="muted">لا توجد بيانات كافية</span></div>`
: `<div class="pred ${p.recommendation}">
<b>${p.node_id}</b>
<span class="rec">${p.recommendation === 'irrigate' ? '💧 يحتاج ري خلال 24 ساعة' : '✅ لا يحتاج ري الآن'}</span>
<span class="prob"><i style="--v:${Math.round(p.probability * 100)}%"></i>${Math.round(p.probability * 100)}%</span>
<small>${p.water_mm ? `كمية مقترحة ≈ ${p.water_mm} مم` : ''} ${p.note || ''} · ET0 غداً ${p.weather.et0_next_24h} مم</small>
</div>`).join('');
}
setInterval(loadPredictions, 300000); // every 5 minutes is enough: soil changes slowly
شغّل المنصة بالنموذج وأعد التدريب بدون إيقافها
- التشغيل
python app.py+python simulate_nodes.py— افتح اللوحة وانظر قسم «توقع الري». - تغيير العتبة
أوقف السيرفر وشغّله بـ
set SF_IRRIGATION_THRESHOLD=0.6(ويندوز) ولاحظ تغير التوصيات. - Hot reload
والسيرفر يعمل: غيّر Kc في common.py وأعد تشغيل s16_irrigation_classifier.py. predictor.py يكتشف تغير تاريخ الملف ويحمّل النموذج الجديد في الطلب التالي.
- Fallback
أعد تسمية ملف النموذج مؤقتاً: اللوحة تستمر بقاعدة بسيطة وتكتب المصدر «rule (model file not found…)».
لا تحمّل نماذج من مصدر غير موثوق
ملف joblib/pickle يمكن أن يحتوي كوداً يُنفذ عند التحميل. حمّل فقط النماذج التي دربها فريقك، واحمِ مجلد outputs من الكتابة على السيرفر.
مراقبة النموذج بعد النشر
30 دقيقةالنموذج تدرب على حقل خضر محاكى بطقس 2023. في المزرعة الحقيقية: محصول مختلف، مرحلة نمو، نوع تربة. كيف نعرف أنه ما زال جيداً؟ نسجل كل توقع، ثم نقارنه بما حدث فعلاً بعد 24 ساعة (هل احتجنا للري؟).
# db.py - new table
CREATE TABLE IF NOT EXISTS predictions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT, node_id TEXT, probability REAL, recommendation TEXT, threshold REAL,
model_mtime REAL, -- which model version produced it
outcome INTEGER -- filled 24 h later: 1 = irrigation was needed
);
# predictor.py - at the end of predict()
db.execute("INSERT INTO predictions (ts, node_id, probability, recommendation, threshold, model_mtime) "
"VALUES (?,?,?,?,?,?)", (r["ts"], node_id, prob, rec, config.IRRIGATION_THRESHOLD, _model["mtime"]))
# monitor.py - nightly: label yesterday's predictions and compute the live F1
# outcome = 1 if an irrigation command was executed (commands.status='done') within 24 h after ts,
# or soil_moisture_pct dropped below SOIL_DRY_PCT within 24 h
monitor.py — دقة النموذج في الحقل الحقيقي
اكتب سكربت يعمل يومياً: يملأ عمود outcome لتوقعات أمس، ثم يحسب Precision و Recall و F1 لآخر 7 أيام ويرسلها في سجل القرارات. إذا هبط F1 تحت 0.6 يسجل تنبيه «Model drift — أعد التدريب ببيانات الموسم».
تلميح (Hint)
استخدم استعلام SQL يربط predictions بـ readings خلال نافذة 24 ساعة بعد كل توقع، ثم sklearn.metrics.f1_score.
ميداني: التوقع مقابل الحقل
25 دقيقةتوقع الري على اللوحة مع مؤشر التوصية
لوحة التحكم على شاشة المزرعة + الحقول
- اقرأ اللوحة
لكل حقل: الاحتمال، التوصية، الكمية المقترحة، ET0 الغد.
- تحقق ميداني
فريق لكل حقل: فحص يدوي للتربة (اختبار الكرة على عمق 15 سم) وحالة النبات.
- اتفاق أم اختلاف؟
سجل: هل يتفق النموذج والمشرف؟ إذا اختلفا، من كان على حق بعد 24 ساعة؟ (سجّلوا غداً)
- الأخطاء المنهجية
هل النموذج يبالغ في حقل معين؟ ربما معايرة حساس ذلك الحقل (VWC_AT_0/100) تحتاج تعديلاً.
| الحقل | احتمال النموذج | توصية النموذج | فحص اليد | رأي المشرف | ما حدث بعد 24 ساعة |
|---|---|---|---|---|---|
- التوصية مساعدة للقرار — قرار الري النهائي للمشرف في هذه المرحلة.
اختبر فهمك
5 أسئلة · نتيجة فوريةالواجب والتسليم
الواجب الأسبوعي (Weekly Assignment)
نفذ تحدي latency_ms وقدم جدولاً بزمن كل جزء.
أضف جدول predictions وسجّل التوقعات لمدة 3 أيام على الأقل.
تحدي الفريق: monitor.py (الأفضل يُدمج في المنصة الرسمية للكورس).
قائمة التسليم (Submission Checklist)
مراجع ومصادر
استعد للسيشن القادمة
السيشن القادمة الأتمتة الكاملة والتنبيهات: أنشئ بوت Telegram من @BotFather واحتفظ بالـ Token (لا تنشره!).