PyDA Course
متوسط نصف موجّه ⏱ 90 دقيقة ⚡ +100 XP +15 نقطة لكل خطوة

لوحة مراقبة جودة الهواء

EnvironmentAPIsData Visualization

جودة الهواء مسألة تشتيل أرقام مختبئة داخل تدفّق حساسات. ينقل هذا المشروع أسبوعًا من قراءات PM2.5 ، جسيمات هوائية دقيقة، الملوّث الحضري الأكثر شيوعًا ، ويحوّل كل تركيز مرهٍ إلى قيمة مؤشر جودة الهواء (AQI) التابع لوكالة حماية البيئة الأمريكية (EPA)، ويصنّف تلك القيم إلى فئات صحية، وينتج الشيئين اللذين يريدهما فعلًا مواطن قلق: تقريرًا بلغة بسيطة («أمسية الثلاثاء كانت أطول فترة سوء») ومخططًا يعرض الأسبوع في نظرة واحدة. البيانات حقيقية الشكل وصادقة: يحاول المشروع جلب قراءات حية من واجهة برمجية عامة، وعندما يستعصي يقع على عيّنة حتمية (deterministic) يمكنك إعادة إنتاجها إلى العلامة العشرية، فأرقامك المبلّغة دائمة القابلية للفحص.

هذا يفترض عملًا أساسيًا بالبيانات المرتبة (tidy data) ولا شيء منه مُقيَّم؛ هذا المشروع اختياري وغير مُقيَّم ، راجع مشاريع من العالم الحقيقي للاطلاع على القائمة الكاملة والنامية.

🎯 ما ستفعله

  1. تحميل أسبوع مرتب من قراءات PM2.5 إلى DataFrame وفحص سلامة شكله وأنواعه.
  2. كتابة صيغة نقاط التوقف (breakpoints) التابعة لـEPA التي تحوّل التركيز إلى AQI عددًا صحيحًا.
  3. تصنيف كل ساعة بفئة، وإيجاد أسوأ ساعة وأفضلها في الأسبوع.
  4. تجميع الأسبوع في تقرير نصي مقروء مع نصائح صحية.
  5. رسم متوسط AQI حسب الساعة مقابل خطوط العتبة/الأمان وحفظ PNG.

أين تُشغّل هذا

محليًا باستخدام uv هو المسار الموصى به: هذا المشروع يعيش ويموت على pandas وmatplotlib، كلاهما على بُعد أمر uv add واحد، وملف aqi_week.png المحفوظ يحط على قرصك الخاص.

Google Colab وKaggle Notebooks وBinder جميعها فئة أولى هنا ، pandas وmatplotlib مثبتتان مسبقًا في كلٍّ منها، و!pip install requests يغطي غلاف الجلب الحي، وmatplotlib.use("Agg") في الخطوة 5 يُبقي الرسم صديقًا للبيئات بلا شاشة (headless-friendly). دفاتر الملاحظات مناسبة تمامًا إن كانت آلة دورتك بلا Python محلي؛ تذكّر فقط أن أي بيانات API حية ستتغير بين الجلسات، وهذا بالضبط ما صُنعت العيّنة الحتمية من أجله.

Open In Colab Open In Kaggle Binder

الإعداد

كل ما يلزم قبل تشغيل اللوحة: مشروع بـ pandas، وأسبوع حتمي من القراءات لتغذيته.

أعِدَّ المشروع

uv init air-quality
cd air-quality
uv add pandas numpy matplotlib requests

pandas تقوم بعمل الإطارات، وnumpy تبني العيّنة الحتمية، وmatplotlib يرسم المخطط، وrequests تشغّل الجلب الحي الاختياري. إن أزلت requests فاحذف fetch_live() من الخطوة 1 ، كل ناتج متوقع في هذا المشروع محسوب من العيّنة الحتمية، فلا شيء لاحقًا ينهار.

✅ قائمة التحقق

  • ✅ يكتمل uv add pandas numpy matplotlib requests دون أخطاء.
  • ✅ تستطيع تنفيذ import pandas as pd من داخل مجلد المشروع.

🤔 سؤال (أسئلة) سقراطي(ة)

  • لماذا تحتاج مسار الجلب الحي في هذا المشروع إلى بديل أصلًا ، ما الذي يجعل API ضمانة سيئة لبناء تقرير كامل فوقها، وكيف تُبقي العيّنة الحتمية الأرقام صادقة مهما حدث؟
  • أسبوع من القراءات المرّية هو 168 صفًا. قبل أن تكتب أي كود، ماذا تتوقع أن يكون شكل وأعمدة dtypes لتلك الإطار المرتب؟

الخطوة 1: حمّل أسبوعًا مرتبًا من القراءات

تبني هذه الخطوة الـDataFrame الذي تستهلكه كل خطوة لاحقة. الجوهر أسبوع اصطناعي لكنه حتمي ، إيقاع 24 ساعة مضاف إليه ضجيج، مُبذَّر (seeded)، فتظهر الأرقام نفسها تمامًا على كل آلة. غلاف جلب حي رقيق يحاول استدعاء واجهة OpenAQ ويُتخطى حين تكون الشبكة أو الواجهة غير متاحة.

1.1 ولّد الأسبوع الحتمي

👟 تلميح البداية :

ابنِ أسبوع تلوث مقنعًا: شكل جيبي 24 ساعة (يتسوّق هواء هذه المدينة الاصطناعية في الساعات الصغيرة ، «الانقلاب الصباحي» الكلاسيكي)، وضجيج غاوسي، وقيم مقصوصة كي لا تنزل تحت الصفر أبدًا، وصفوفًا مؤرخة بالساعة.

# aqi.py
import numpy as np
import pandas as pd

def load_week() -> pd.DataFrame:
    rng = np.random.default_rng(42)
    hours = np.arange(168)
    daily = 20 + 10 * np.sin(2 * np.pi * hours / 24)   # daily rhythm
    noise = rng.normal(0, 5, size=168)
    pm25 = np.clip(daily + noise, 0, None).round(1)    # µg/m³, never negative
    dates = pd.date_range("2025-03-03", periods=168, freq="h")
    return pd.DataFrame({"date": dates, "pm25": pm25})

df = load_week()
print(df.shape)

np.random.default_rng(42) هو اصطلاح العشوائية القابلة لإعادة الإنتاج: البذرة نفسها تُنتج «الضجيج» نفسه على كل آلة، ولهذا كل ناتج متوقع في هذا المشروع دقيق. المظهر 20 + 10·sin(2πh/24) يجعل الفيزياء الكامنة مرئية، و.round(1) يُبقي التركيزات على عُشر ميكروجرام/م³ كما يُبلَّغ عنها مقياس حقيقي.

🎯 الناتج المتوقع :

(168, 2) ، سبعة أيام من القراءات المرّية، عمودان (date, pm25).

🩹 إذا لم يعمل :

إن اختلف عدد الصفوف عن 168، فتحقق من periods=168 وfreq="h" في date_range. وإن كان (168, 3) فأكثر، تسرّب عمود طائش (مثل hour من خطوة لاحقة) إلى load_week ، أبقِ المولّد يبني العمودين المرتبين بالضبط.

1.2 افحص الإطار

👟 تلميح البداية :

تحقّق من السلامة وألقِ نظرة على قصة الأنواع: يجب أن يكون date وقتًا، وpm25 عائمًا، وأن يبدو انتشار التركيز كالهواء الحضري الحقيقي.

# aqi.py (continued)
print(df.info())

🎯 الناتج المتوقع :

168 مدخلًا غير فارغ في العمودين؛ date من نوع datetime64[ns] (أو datetime64[us])، وpm25 من نوع float64. لا قيم فارغة ، إطار مرتب.

🩹 إذا لم يعمل :

إن ظهر date كـobject، فلم يُسند date_range إلى العمود (قائمة سلاسل نصية عادية بدلًا منه). وإن ظهر pm25 بعلامة 168 غير فارغ لكنه طُبع كـobject، فطُبّق .round(1) على قائمة مختلطة الأنواع ، أعد بناء العمود بمصفوفة numpy.

1.3 اختياري: على أي مسار أنت؟

👟 تلميح البداية :

اطبع سطرًا واحدًا يصرّح بوضوح: هل تحلل بيانات حية أم العيّنة الحتمية ، يجب ألّا يكذب التقرير عن مصدره أبدًا.

# aqi.py (continued)
SOURCE = "sample (deterministic)"
try:
    import requests
    r = requests.get("https://api.openaq.org/v2/measurements",
                     params={"city": "Stockholm", "parameter": "pm25", "limit": 168},
                     timeout=10)
    r.raise_for_status()
    results = r.json().get("results", [])
    if results:
        live = pd.DataFrame({
            "date": pd.to_datetime([m["date"]["utc"] for m in results]),
            "pm25": [float(m["value"]) for m in results],
        })
        df = live.sort_values("date").reset_index(drop=True)
        SOURCE = "live OpenAQ"
except Exception:
    pass   # network down, key missing, or API changed — sample it is

print("analyzing:", SOURCE)

إن try/except الشامل على الجلب مقصود: واجهة معطلة أو منقولة أو محتاجة لمفتاح يجب ألّا تقتل تقريرًا أبدًا. حين ينجح المسار الحي، يصبح df هواء ستوكهولم الحقيقي وستختلف أرقام باقي المشروع ، كل ناتج متوقع أدناه يفترض العيّنة الحتمية، فسطر المصدر يُبقيك مرسّيًا.

🎯 الناتج المتوقع :

analyzing: sample (deterministic) على آلة بلا وصول موثوق إلى OpenAQ ، وanalyzing: live OpenAQ على آلة ينجح عليها الجلب.

🩹 إذا لم يعمل :

إن رأيت KeyError على m["date"] من استجابة ناجحة، فشكل استجابة OpenAQ تغير ، وطباعة results[0].keys() أسرع طريقة لرؤية الحقول الجديدة، وما زال المسار العيِّني ينقذ المشروع.

1.4 تحقّق من التحميل

✅ قائمة التحقق

  • ✅ تعيد load_week() (168, 2) بلا قيم فارغة وبلا تركيزات سالبة.
  • ✅ تقريبًا df["pm25"].min() هي 3.1 وdf["pm25"].max() هي 40.7 (µg/m³) ، مدى حضري مقنع.
  • ✅ سطر مصدر واحد يصرّح هل تشغّل العيّنة أم البيانات الحية.

🤔 سؤال (أسئلة) سقراطي(ة)

  • مظهر تركيز العيّنة يبلغ ذروته عند 6 صباحًا، وهو «الانقلاب الصباحي» الكلاسيكي حين تكون طبقة التنفس التي نتنفس فيها في أضيق نقطة. أي رياضيات خطوة مستقبلية ستتغير لو قلبت المظهر ليبلغ الذروة 3 بعد الظهر بدلًا من ذلك ، ولماذا ستكون تسميات الفئات العائق الحقيقي لا المتوسطات؟
  • لماذا تقيّد الجلب عند 168 صفًا (limit=168) بدلًا من سحب «كل شيء»؟ ماذا ينكسر في تقرير أسبوعي إن صمت حساس يوم كامل وسط الأرشيف؟

الخطوة 2: حوّل التركيزات إلى AQI

µg/m³ خام لا يعني شيئًا لغير عالم. تحوّله EPA إلى مقياس 0–500 بـجدول نقاط توقف: نطاقات التركيز تقابل نطاقات AQI، موصولة بخطوط مستقيمة. تكتب هذه الخطوة تلك الصيغة التجزئة الجزئية (piecewise) كدالة Python صادقة واحدة.

2.1 اكتب صيغة نقاط التوقف

👟 تلميح البداية :

نفّذ pm25_to_aqi(pm25) ، امشِ عبر جدول نقاط توقف PM2.5 التابع لـEPA بالترتيب، وللسكّة المطابقة قيس التركيز خطيًا إلى نطاق AQI.

# aqi.py (continued)
def pm25_to_aqi(pm25: float) -> int:
    breakpoints = [
        (0.0, 12.0,  0,  50),   # Good
        (12.1, 35.4, 51, 100),  # Moderate
        (35.5, 55.4, 101, 150), # USG
        (55.5, 150.4, 151, 200),# Unhealthy
        (150.5, 250.4, 201, 300),
    ]
    for low, high, aqi_low, aqi_high in breakpoints:
        if low <= pm25 <= high:
            return round((aqi_high - aqi_low) / (high - low) * (pm25 - low) + aqi_low)
    return round((300 - 201) / (250.4 - 150.5) * (pm25 - 150.5) + 201) if pm25 > 250.4 else 0

print(pm25_to_aqi(12.0), pm25_to_aqi(30.0), pm25_to_aqi(35.4), pm25_to_aqi(50.0))

(aqi_high - aqi_low) / (high - low) هو ميل مقطع سطر واحد من AQI مقابل التركيز؛ تكبير (pm25 - low) وإضافة aqi_low يزلقك صعودًا ذلك السطر ، استيفاء خطي عادي فوق السكّة. هذا هو «المعيار» التابع لـEPA كله مشفّرًا في حلقة for, وهذا بالضبط لماذا تنشره EPA نفسها كجدول: أربعة أرقام للسكّة، بلا سحر.

🎯 الناتج المتوقع :

50 89 100 137 ، أطراف السكّة النظيفة تقابل أعدادًا صحيحة (12.0 ← 50, 35.4 ← 100) ونقاط منتصف السكّة تُستوفى (30.0 ← 89, 50.0 ← 137).

🩹 إذا لم يعمل :

إن طبع طرف سكّة مثل pm25_to_aqi(12.0) قيمة 51 بدلًا من 50, فحد سكّتك (0.0, 12.0) حصري على اليسار ، يجب أن تكون كل سكّة low <= pm25 <= high. إن كان الناتج عائمًا بكسور عشرية, فـround(...) مفقود؛ AQI عدد صحيح بحكم التعريف.

2.2 طبّقها على الأسبوع كله

👟 تلميح البداية :

.apply(pm25_to_aqi) على عمود pm25 ، دالة واحدة، 168 صفًا، عمود أعداد صحيحة جديد واحد.

# aqi.py (continued)
df["aqi"] = df["pm25"].apply(pm25_to_aqi)
print(df["aqi"].min(), df["aqi"].max())
print(df[df["date"] == "2025-03-04 06:00"])   # the worst hour, we suspect

يبثّ .apply الدالة النقية نفسها عبر كل صف ، لا حلقات, ولا سبيل لإهمال الصفوف بشكل مختلف. لأن الدالة بلا حالة, فهي قابلة للاختبار بشكل تافه: تحقق من ثلاث قيم محسوبة يدويًا مرة واحدة, والعمود كله يرث تلك الثقة.

🎯 الناتج المتوقع :

13 114, والصف الخاص بـ2025-03-04 06:00 يعرض aqi = 114 ، أسوأ قراءة منفردة في الأسبوع, ساعة «غير صحية للمجموعات الحساسة».

🩹 إذا لم يعمل :

إن كانت min/max سالبة أو سخيفة, فأعادت pm25_to_aqi فرع else 0/الاحتياطي لمعظم الصفوف ، اطبع df["pm25"].describe() وتأكد من استدعاء واحد مقابل طرف سكّة معروف. إن طبع صف 06:00 قيمة aqi مختلفة, فعيّنتك من مسار البيانات الحية (أرقام مختلفة كليًا ، العيّنة (168, 2) وبحد أقصى pm25 40.7).

2.3 تحقّق من التحويل

✅ قائمة التحقق

  • ✅ أطراف السكّة المدققة يدويًا تصمد: 12.0 ← 50, 35.4 ← 100.
  • ✅ مدى العمود كله 13..114, أعداد صحيحة, بلا NaNs.
  • ✅ أضاف .apply عمودًا جديدًا واحدًا بالضبط (aqi) بلا إزعاج لـdate أو pm25.

🤔 سؤال (أسئلة) سقراطي(ة)

  • الصيغة التجزئة الجزئية تُستوفي داخل السكّة لكنها تقفز حيث تلتقي السكّات (12.0 ← AQI 50, لكن 12.1 ← AQI 51). ابتكر تركيزًا, حلّ الصيغة, وأخبرني بماذا كان سيعني AQI 50.6 لو لم تُقرَّر EPA إلى أعداد صحيحة ، لماذا يساعد التقريب إلى عدد صحيح البث العام فعلًا؟
  • تعيد pm25_to_aqi قيمة 0 لأي شيء تحت 0.0, ومع ذلك يضمن .clip(0, None) مدخلات غير سالبة. متى يبقى فرع return 0 قابلًا للوصول فعلًا, وكيف كان مراجع وظيفي متشدد ليتكلم عن إبقاء كود ميت حوله؟

الخطوة 3: صنّف الفئات واصطد أسوأ ساعة

الآن ينال الإطار عموديه الثالث والرابع: فئة بشرية لكل AQI, ثم سؤالا التجميع ، أي ساعة في الأسبوع كانت الأسوأ, أيهما الأفضل, وكيف بدا الأسبوع حسب الفئة؟

3.1 صنّف قيم AQI في فئات

👟 تلميح البداية :

اكتب category(aqi) تمشي عبر حسمات فئات EPA, ثم .apply إليها وعدَّ بـvalue_counts().

# aqi.py (continued)
def category(aqi: int) -> str:
    if aqi <= 50:   return "Good"
    if aqi <= 100:  return "Moderate"
    if aqi <= 150:  return "Unhealthy for Sensitive Groups"
    if aqi <= 200:  return "Unhealthy"
    return "Very Unhealthy"

df["category"] = df["aqi"].apply(category)
print(df["category"].value_counts())

ترتيب شرطات if من الأنظف إلى الأقذر واستخدام <= عند كل حسم يعني أن أول سكّة مطابقة تنتصر ، قاعدة «السطل المتعارض-الحصري» الكلاسيكية. ينزل value_counts() حسب العدد, فأول سطر في الناتج هو في الوقت نفسه جواب «أي نوع من الأسابيع هذا؟».

🎯 الناتج المتوقع :

Moderate 132, Good 33, Unhealthy for Sensitive Groups 3 ، أسبوع ملوث باعتدال وحفنة ساعات للمجموعات الحساسة ولا هواء غير صحي فعلًا.

🩹 إذا لم يعمل :

إن لم تبلغ المجاميع 168, فالفئات تتداخل أو تترك ثغرات ، تحقق من حدود <= عن تداخل بفارق واحد, أو أعد التشغيل بـdf["category"].isna().sum() لالتقاط صفوف غير مصنفة. إن كان كل شيء سطلًا واحدًا, فطُبّق category على aqi لكن ترتيب حسم (كـ<= 100 قبل <= 50) جعل العوائد المبكرة تبتلع كل شيء.

3.2 اعثر على أسوأ وأفضل الساعات

👟 تلميح البداية :

idxmax/idxmin على عمود aqi, ثم بحث صف عن تلك المؤشرات ، قصة الأسبوع في طبعتين.

# aqi.py (continued)
worst = df.loc[df["aqi"].idxmax()]
best = df.loc[df["aqi"].idxmin()]
print("worst:", worst["date"], worst["pm25"], worst["aqi"])
print("best: ", best["date"], best["pm25"], best["aqi"])

تعيد df["aqi"].idxmax() وسم فهرس صف القيمة العظمى ، قرنه بـ.loc هو اصطلاح الخطوتين «اعثر وأظهر السجل» الذي يعمّم على أي بحث بمفتاح. مع فهرس وقت يصبح هذا وقت-سلسلي-محلي, وهو بالضبط كيف يسحب لوح مراقبة «التنبيه هنا, في هذه الثانية».

🎯 الناتج المتوقع :

worst: 2025-03-04 06:00:00 40.7 114 وbest: 2025-03-07 17:00:00 3.1 13 ، فجر الثلاثاء مقابل بعد ظهر الجمعة.

🩹 إذا لم يعمل :

إن ظهر الصف الخطأ, فأعاد idxmax القيمة العظمى لعمود عائم بينما طابق .loc[...] إطارًا مختلفًا ، تأكد أن worst صف من df, لا من نسخة مُعاد تجميعها. إن طبع كلاهما التاريخ نفسه, فعمود date ليس الفهرس و.loc[df["aqi"].idxmax()] أعاد استخدام الوسم الصحيح بصمت.

3.3 تحقّق من الاصطياد

✅ قائمة التحقق

  • ✅ مجاميع عدّ الفئات تبلغ 168 بثلاث فئات متميزة.
  • ✅ أسوأ ساعة aqi (114) فئتها USG, وأفضلها (13) فئتها Good.
  • worst وbest صفوف حقيقية من df, لا إطارات مُعاد تجميعها أو منسوخة.

🤔 سؤال (أسئلة) سقراطي(ة)

  • كانت متوسطات أيام الأسبوع كلها على بُعد ميكروغرامين من بعضها, ومع ذلك يبرز اليوم الذروة في تقرير. أين يبدأ «اجمع باليوم ثم رتّب الأيام» بالتضليل, وأي حقيقة صف واحد (أسوأ ساعة) تخفيها المتوسطات اليومية بنشاط؟
  • يرتّب value_counts() تنازليًا افتراضيًا. لماذا الترتيب التنازلي الافتراضي الصواب لهذا التقرير ، وأي سؤال كان سيُجاب عنه الترتيب التصاعدي بدلًا من ذلك؟

الخطوة 4: اطبع تقرير المواطن

المخططات لعينيّ الناظر; التقرير للعمل. تحوّل هذه الخطوة تجميعات الخطوة 3 إلى بضعة أسطر بسيطة يستطيع قارئ التصرف بناءً عليها الليلة ، نسب مئوية, ساعة ذروة, وقاموس النصيحة الملموسة المرافق لكل فئة.

4.1 اكتب قاموس النصيحة

👟 تلميح البداية :

اطبَع كل فئة إلى جملة تصرفية واحدة ، «ماذا في ذلك» للصحة العامة لكل AQI.

# aqi.py (continued)
ADVICE = {
    "Good": "Open the windows — air is clean today.",
    "Moderate": "Fine for most people; sensitive folks, take it easy outside.",
    "Unhealthy for Sensitive Groups": "Sensitive groups: reduce prolonged outdoor exertion.",
    "Unhealthy": "Everyone: cut back prolonged or heavy outdoor effort.",
    "Very Unhealthy": "Stay indoors; keep windows shut.",
}

قاموس يطابق سلاسل الفئات الدقيقة إلى نصائح, فلا يقرر التقرير أبدًا ماذا يقول ، يبحثه. إبقاء النصيحة بيانات لا نثر if/elif يعني أن القاموس نفسه يستطيع تشغيل تنبيه SMS أو حبة لوح أو ملصق, دون تغيير.

🎯 الناتج المتوقع :

لا مخرجات من التعريف وحده ، لكن يجب أن يحتوي القاموس على مفتاح لكل سلسلة تمامًا يستطيع category() إنتاجها.

🩹 إذا لم يعمل :

إن ألقى التقرير لاحقًا KeyError, فسلسلة فئة في df["category"] ليست في ADVICE ، شغّل set(df["category"]) - set(ADVICE) لتطبع الأيتام في سطر واحد.

4.2 اجمع واطبع

👟 تلميح البداية :

احسب نسب الفئات, وساعة أعلى متوسط AQI, وأسوأ قراءة منفردة, ثم print تقريرًا مرتبًا من 6 أسطر.

# aqi.py (continued)
df["hour"] = df["date"].dt.hour  # pull the clock value for hour-of-day aggregation

def print_report(df: pd.DataFrame, advice: dict[str, str]) -> None:
    counts = df["category"].value_counts()
    n = len(df)
    hourly_mean = df.groupby("hour")["aqi"].mean()
    peak_hour = int(hourly_mean.idxmax())
    peak_value = round(float(hourly_mean.max()))
    worst = df.loc[df["aqi"].idxmax()]

    print(f"Week: {n} hourly readings")
    print(f"Most common category: {counts.index[0]} ({counts.iloc[0]}h, {counts.iloc[0] / n * 100:.0f}%)")
    print(f"Peak pollution hour (avg AQI): {peak_hour:02d}:00 (~{peak_value})")
    print(f"Worst single hour: {worst['date']}  AQI {worst['aqi']}")
    print("Advice:", advice[counts.index[0]])

print_report(df, ADVICE)

df["hour"] = df["date"].dt.hour يُسقط الطابع الزمني إلى قيمة الساعة ، استدعاء مُنفِّذ .dt واحد يحول عمود وقت إلى تقسيم الـ24 الذي يحتاجه التقرير. ثم groupby("hour")["aqi"].mean() تهرّب الأسبوع إلى 24 متوسطًا ساعيًا, وidxmax() على تلك السلسلة تجد ساعة الذروة لكل ساعة من اليوم ، حقيقة بلغة طبيعية («الفجر هو أطول فترة سوء») لا كسرور جدول. counts.index[0] هو الفئة الشائعة, يقرنها التقرير بسطر النصيحة الخاص بها فيحصل القارئ على جملة تصرفية واحدة.

🎯 الناتج المتوقع :

Week: 168 hourly readings
Most common category: Moderate (132h, 79%)
Peak pollution hour (avg AQI): 06:00 (~94)
Worst single hour: 2025-03-04 06:00:00  AQI 114
Advice: Fine for most people; sensitive folks, take it easy outside.

🩹 إذا لم يعمل :

إن طبع counts.iloc[0] / n * 100 قيمة 79.0% بدلًا من 79%, فصيغة :.0f أُسقطت. إن أخطأ peak_hour:02d, فأعاد idxmax() قيمة numpy.float64 ، لفه بـint(...). إن كانت النصيحة للفئة الخطأ, فبحث advice[counts.index[0]] عن صف المنوال, لكن منوال خاطئ يعني أن value_counts لم يُشغَّل على الأسبوع الكامل.

4.3 تحقّق من التقرير

✅ قائمة التحقق

  • ✅ الأسطر الخمسة تُعيد إنتاج الناتج المتوقع مع العيّنة.
  • ✅ النسب المئوية تبلغ ~100 عبر الفئات.
  • ✅ سطر النصيحة يطابق الفئة الشائعة, لا أسوأ ساعة.

🤔 سؤال (أسئلة) سقراطي(ة)

  • يطبع التقرير نصيحة المنوال بينما يرفع أسوأ ساعة. متى تضلل النصيحة المستمدة من المنوال بنشاط ، وكيف تغير سطرًا واحدًا ليجعل الرسالة التصرفية الوحيدة في التقرير صادقة مع أسبوع Moderate بنسبة 79% ويوم Very-Unhealthy بنسبة 2% معًا؟
  • يتجاهل hourly_mean.idxmax() أي يوم تقع فيه الذروة, فيظهر «06:00» حتى وإن كانت أسوأ ساعة الثلاثاء. أعد التعبير عن تلك الثغرة السقراطية كعملية pandas بسطر واحد تُبلّغ «الثلاثاء 06:00» بدلًا من ذلك ، ما الذي يتغير مفهوميًا؟

الخطوة 5: ارسم الأسبوع واحفظه

تقرير يقول; مخطط يُظهر. ترسم هذه الخطوة 24 نقطة متوسط AQI ساعي بخطّي عتبة 50 و100 معلَّمين, وتحفظ الشكل كـPNG ، الأثر الذي يمكنك فعليًا وضعه في شريحة عرض أو إرساله لصديق.

5.1 ارسم متوسط AQI حسب الساعة

👟 تلميح البداية :

groupby("hour")["aqi"].mean() مرة أخرى, plt.plot بعلامات, خطّا عتبة axhline, وAgg كي يُصيَّر المخطط بلا شاشة في أي بيئة.

# aqi.py (continued)
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

def plot_week(df: pd.DataFrame, out: str = "aqi_week.png") -> None:
    hourly = df.groupby("hour")["aqi"].mean()
    fig, ax = plt.subplots(figsize=(8, 4))
    ax.plot(hourly.index, hourly.values, marker="o", label="mean AQI")
    ax.axhline(50, color="green", ls="--", lw=1, label="Good / Moderate")
    ax.axhline(100, color="orange", ls="--", lw=1, label="Moderate / USG")
    ax.set_xlabel("Hour of day")
    ax.set_ylabel("Mean AQI")
    ax.set_title("Average weekly AQI by hour of day")
    ax.legend()
    fig.tight_layout()
    fig.savefig(out)
    plt.close(fig)

plot_week(df)

matplotlib.use("Agg") يختار المُصيِّر المعلّم بلا رأس ، يرسم إلى مخزن مؤقت ويكتب savefig الـPNG, بصفر اعتماد على نظام نوافذ, وهذا ما يجعل هذه الخلية صامدة المنصة عبر الدفاتر والخوادم. يحمل خطّا axhline الحدود نفسها التي رمّزتها عدديًا في الخطوة 2, لكن هنا كخطوط قطع بصرية: أي نقطة فوق 100 تنتهك الخط البرتقالي في لمحة.

🎯 الناتج المتوقع :

يُظهر الشكل حدبة فجر تعبر حدود (USG) البرتقالية قرب 06:00 وقراءات بعد الظهر تغوص إلى منطقة Good ، مطابقًا hourly_mean.max() ≈ 94 في الخطوة 4.

🩹 إذا لم يعمل :

إن لم يظهر ملف, فـplt.close(fig) ركض قبل savefig أو المسار خطأ ، ضع savefig قبل close. إن كان المخطط فارغًا, فـhourly فارغ لأن عمود "hour" غير موجود ، حدث استخراج hour في نسخة, لا على df. إن تبادل المحوران (ساعات على محور y), فذهب hourly.index وhourly.values إلى وسيطتين خاطئتين.

5.2 أكِّد الأثر

👟 تلميح البداية :

تحقق أن الـPNG موجود وغير فارغ قبل أن توقّع ، الملف على القرص هو المُسلَّم.

# aqi.py (continued)
import os
print("exists:", os.path.exists("aqi_week.png"), "size:", os.path.getsize("aqi_week.png"), "bytes")

🎯 الناتج المتوقع :

exists: True size: <بضعة عشرات من kB> bytes ، PNG حقيقي قابل للفتح.

🩹 إذا لم يعمل :

إن كانت exists: False, فدالة الرسم لم تعمل قط (تحقق من اسم الملف الممرر إلى savefig مقابل الاسم المفحوص). إن كان الحجم حفنة بايتات, فكتب المُصيِّر ملفًا فارغًا أو مكانيًا ، أعد تشغيل الخلية وراقب استثناءً بين plot_week(df) والفحص.

5.3 تحقّق من المخطط

✅ قائمة التحقق

  • ✅ الـPNG موجود, غير فارغ, ويُظهر حدبة AQI الفجرية تعبر خط 100.
  • ✅ خطّا العتبة 50 و100 لهما تسميات, ووسيلة الإيضاح (legend) تُصيَّر.
  • ✅ المخطط محفوظ كـaqi_week.png في مجلد المشروع.

🤔 سؤال (أسئلة) سقراطي(ة)

  • يرمّز خطّا axhline الحسمات لكن لا السكّات ، لا يستطيع المخطط إظهار «ساعات USG» مظللة أينما حطت النقاط. ما استدعاء matplotlib واحد يظلل السكّة بين 51 و100, ولماذا عادة ما يكون التظليل أكثر صدقًا من خطوط القطع لقارئ عام؟
  • يكتب savefig بكسلًا, فيتجمد المخطط لحظة صنعه. لو أردت نفس الدفتر يسلّم مخططًا تحدثت أرقامه ببيانات الأسبوع القادم, أي أجزاء من plot_week ستضطر للبقاء نقية ، وأي جزء أثر جانبي بطبيعته؟

⚠️ مآزق شائعة

  • حدود اسكّة تتداخل. إن استخدمت أي سكّة < low والتالية <= high, ازدواج حساب حسم EPA وبلغ مجمل value_counts فوق 168. يجب أن تكون كل سكّة low <= pm25 <= high وأن تلامس السكّات بعضها بدقة.
  • نسيان أن round(1) → AQI عدد صحيح. AQI أعداد صحيحة بحكم التعريف; إعادة عوامات من pm25_to_aqi تمرر الاختبارات لكنها تجعل متوسطات groupby سخيفة (كـ94.3333).
  • idxmax على العمود الخطأ. .idxmax يُعيد وسم فهرس; استخدم .loc[label] على نفس الإطار, أو تعرض صفًا مختلفًا بصمت.
  • أنواع مختلطة في التقرير. f"{peak_hour:02d}" يحتاج int; عوامات numpy تطلق TypeError على :02d. لفّ بـint(...).
  • رسم على مشغِّل بلا شاشة دون Agg. لا شاشة للدفاتر والخوادم; matplotlib.use("Agg") قبل استيراد pyplot هو الفرق بين PNG محفوظ وTclError.
  • انحراف قاموس النصيحة. يجب أن يحتوي ADVICE على مفتاح لكل سلسلة يستطيع category() إصدارها; فئة جديدة دون مدخل قاموس تحطّم التقرير وقت التشغيل.

ما بنيته للتو

خط معالجة هواء حقيقي: بيانات مرتبة ساعية تدخل, تقرير بشري ومخطط يخرجان. على الطريق رمّزت معيارًا تنظيميًا كاملًا (جدول نقاط توقف EPA) كبيانات, وحوّلت عمود حساسات عدديًا إلى بصيرة فئوية, وأنتجت الأثرين اللذين يستهلكهما الناس فعلًا ، بيان نصي عادي للأسبوع وPNG يعرضه. الشكل القابل للنقل ، تحميل ← تحويل بدوال نقية ← تجميع ← تقرير + تصوير ، هو نفس الهيكل العظمي خلف لوحات الحساسات ولوحات المنتجات ورسائل التحليلات الأسبوعية.

شغّل نسخة أكمل دون أي إعداد محلي

examples/air-quality/ في مستودع الدورة هو اللوحة كاملة كدفتر ملاحظات ، مسارا العيّنة والحياة, التقرير, والمخطط, كلها في مكان واحد. استنسخ المستودع أو افتحه في Codespace.

إلى أين تذهب من هنا

  • أضف ملوِّثًا ثانيًا (PM10 أو أوزون) وAQI مدموجًا ، الملوِّث الذي يسجل أسوأ نتيجة في ساعة معينة يقود قيمة التقرير, وهكذا يعمل AQI الحقيقي التابع لـEPA.
  • أعد مفاتحة الإطار على df["date"] وأضف resample("D").mean() كي يستطيع التقرير الأسبوعي رفع أيام كاملة فوق عتبة.
  • ابنِ نصف التنبيه: دالة تُعيد «أرسل SMS» حين تظهر فئة كـUnhealthy أكثر من N ساعة في نافذة منزلقة من 24 ، ثم صِلْها بوظيفة cron/Playwright.
  • بدّل البذرة الحتمية ببيانات OpenAQ الحقيقية لمدينتك وقارن التقريرين ، درس لا يُنسى في كم تستطيع المتوسطات الإخفاء.

شارك مشروعك مع الصف

بنيت شيئًا تفخر به؟ examples/student-projects/ معرض لمشاريع قدّمها طلاب آخرون ، وREADME الخاص به يحوي شرحًا كاملًا وودودًا للمبتدئين لإضافة مشروعك عبر pull request, حتى لو لم تستخدم git من قبل قط: عمل fork للمستودع, وإنشاء فرع, وتثبيت ملفاتك, وفتح الـ PR, خطوة بخطوة. لا يُفترض أي خبرة سابقة بـ git.

مرحبًا بك في كتابة Python خارج المتصفح. 🎓

أكمل كل خطوة ثم حدد المشروع كمكتمل لجمع نقاطه.