# ---------------------------------------------------------------------------
# air_quality_alert.py
#
# AI project on Statistical Data: warning a day ahead when the air is going
# to be "Poor" or worse, from today's readings at a city monitoring station.
#
# CBSE Class X, Artificial Intelligence (417), Part C project work.
# SDG 11 (Sustainable Cities) and SDG 3 (Good Health and Well-being).
#
# The model is a PERCEPTRON - the single artificial neuron of Unit 2. It
# multiplies each input by a weight, adds a bias, and says "Poor tomorrow"
# if the total is above 0. In Unit 2 the weights were given. Here the
# perceptron finds them itself: every time it gets a training day wrong, it
# nudges its weights a little towards the right answer, and it goes through
# the training days again and again.
#
# Libraries: pandas (the data), numpy (the arithmetic), matplotlib (charts).
# ---------------------------------------------------------------------------

# %% Stage 1 - Problem scoping
print("=" * 60)
print("WILL TOMORROW'S AIR BE POOR?")
print("=" * 60)
print("Who   : schools, parents, and people with asthma")
print("What  : bad-air days are announced on the day, too late to plan")
print("Where : a city with one air-quality monitoring station")
print("Why   : a day's warning lets people protect themselves")
print()

# %% Stage 2 - Data acquisition
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

pd.set_option("display.width", 120)
pd.set_option("display.max_columns", 20)

data = pd.read_csv("air_quality.csv", parse_dates=["date"])

print("--- Stage 2: Data acquisition ---")
print("Rows read    :", len(data))
print("First day    :", data["date"].min().date())
print("Last day     :", data["date"].max().date())
print()
print("The first 10 rows:")
print(data.head(10))
print()

# %% Stage 3 - Data exploration
print("--- Stage 3: Data exploration ---")

# PM2.5 above 90 is "Poor" or worse on India's air quality scale.
# The label is about TOMORROW, so each day is given the next day's PM2.5.
# This must be done BEFORE any rows are removed, or a gap in the data would
# pair a day with the wrong "tomorrow".
data["pm25_tomorrow"] = data["pm25"].shift(-1)
data["poor_tomorrow"] = np.where(data["pm25_tomorrow"] > 90, 1, 0)

# A day is unusable if today's or tomorrow's reading is missing (the
# sensor was offline). The last day has no tomorrow at all.
unusable = data["pm25"].isnull() | data["pm25_tomorrow"].isnull()
print("Days with a missing reading today or tomorrow :", unusable.sum(), "(removed)")
data = data[~unusable]
print("Days left                                     :", len(data))
print()

print("PM2.5 - mean   :", round(np.mean(data["pm25"]), 1))
print("PM2.5 - median :", np.median(data["pm25"]))
print("Days followed by a Poor day :", data["poor_tomorrow"].sum(), "of", len(data))
print()

monthly = data.groupby(data["date"].dt.month)["pm25"].mean().round(1)
print("Average PM2.5 in each month (both years together):")
print(monthly.to_string())
print()

# Chart 1: PM2.5 every day for two years.
plt.figure(figsize=(9, 4))
plt.plot(data["date"], data["pm25"], color="#8c6d31", linewidth=0.9)
plt.axhline(90, color="#e15759", linestyle="--", label="90: the start of Poor")
plt.title("PM2.5 every day for two years")
plt.xlabel("Date")
plt.ylabel("PM2.5 (micrograms per cubic metre)")
plt.legend()
plt.tight_layout()
plt.savefig("chart1_pm25_daily.png")
plt.close()

# Chart 2: today's PM2.5 and tomorrow's wind forecast.
poor = data[data["poor_tomorrow"] == 1]
fine = data[data["poor_tomorrow"] == 0]
plt.figure(figsize=(7, 5))
plt.scatter(fine["pm25"], fine["wind_forecast_kmph"], color="#59a14f", alpha=0.5, s=14, label="Tomorrow not Poor")
plt.scatter(poor["pm25"], poor["wind_forecast_kmph"], color="#e15759", alpha=0.6, s=14, label="Tomorrow Poor")
plt.title("Today's PM2.5, tomorrow's wind forecast, and what tomorrow brought")
plt.xlabel("PM2.5 today")
plt.ylabel("Wind forecast for tomorrow (km/h)")
plt.legend()
plt.grid(True, linestyle="--", alpha=0.4)
plt.tight_layout()
plt.savefig("chart2_today_tomorrow.png")
plt.close()

# %% Stage 4 - Modelling
print("--- Stage 4: Modelling ---")
# Today's PM2.5, humidity and temperature, plus the weather office's
# FORECAST of tomorrow's wind - real air-quality forecasts use weather
# forecasts too, because wind is what clears the air.
features = ["pm25", "wind_forecast_kmph", "humidity_pct", "temp_c"]

# For a forecast the rows must NOT be shuffled. The model learns from the
# first year and is tested on the second - exactly how it would be used:
# trained on the past, judged on a future it has not seen.
train = data[data["date"].dt.year == 2024]
test = data[data["date"].dt.year == 2025]
print("Training days (2024) :", len(train))
print("Testing days (2025)  :", len(test))
print()

low = train[features].min()
high = train[features].max()
X_train = ((train[features] - low) / (high - low)).values
X_test = ((test[features] - low) / (high - low)).values
y_train = train["poor_tomorrow"].values
y_test = test["poor_tomorrow"].values

# The perceptron starts knowing nothing: every weight and the bias are 0.
weights = np.zeros(len(features))
bias = 0.0
rate = 0.1          # how big each nudge is
passes = 30         # how many times it goes through the training days

# The days are visited in a shuffled order on every pass. In date order,
# every pass would end on December's long run of Poor days, and that last
# streak would drag all the weights towards "always Poor".
shuffle = np.random.default_rng(1)

mistakes_each_pass = []
for p in range(passes):
    mistakes = 0
    for i in shuffle.permutation(len(X_train)):
        total = np.dot(X_train[i], weights) + bias
        if total > 0:
            output = 1
        else:
            output = 0
        error = y_train[i] - output          # +1, 0 or -1
        if error != 0:
            # Wrong: move every weight a little towards the right answer.
            weights = weights + rate * error * X_train[i]
            bias = bias + rate * error
            mistakes = mistakes + 1
    mistakes_each_pass.append(mistakes)

print("Mistakes in the first pass :", mistakes_each_pass[0])
print("Mistakes in the last pass  :", mistakes_each_pass[-1])
print()
print("Weights the perceptron learned:")
for i in range(len(features)):
    print("  " + features[i].ljust(20), round(weights[i], 3))
print("  " + "bias".ljust(20), round(bias, 3))
print()

# Chart 3: the perceptron learning.
plt.figure(figsize=(7, 4))
plt.plot(range(1, passes + 1), mistakes_each_pass, marker="o", color="#4e79a7", markersize=4)
plt.title("The perceptron learning: mistakes in each pass")
plt.xlabel("Pass through the training days")
plt.ylabel("Mistakes")
plt.grid(True, linestyle="--", alpha=0.4)
plt.tight_layout()
plt.savefig("chart3_learning.png")
plt.close()

# Chart 4: the weights it learned.
plt.figure(figsize=(7, 4))
colours = []
for w in weights:
    if w > 0:
        colours.append("#e15759")
    else:
        colours.append("#59a14f")
plt.barh(features, weights, color=colours)
plt.axvline(0, color="#999999")
plt.title("What the perceptron learned (red pushes towards Poor)")
plt.xlabel("Weight")
plt.tight_layout()
plt.savefig("chart4_weights.png")
plt.close()

# %% Stage 5 - Evaluation
print("--- Stage 5: Evaluation ---")
perceptron = []
for i in range(len(X_test)):
    if np.dot(X_test[i], weights) + bias > 0:
        perceptron.append(1)
    else:
        perceptron.append(0)

# A simple baseline to beat: "tomorrow will be like today".
same_as_today = []
for v in test["pm25"]:
    if v > 90:
        same_as_today.append(1)
    else:
        same_as_today.append(0)

results = []
for name, predicted in [["Same as today", same_as_today], ["Perceptron", perceptron]]:
    tp = fn = fp = tn = 0
    for i in range(len(y_test)):
        if y_test[i] == 1 and predicted[i] == 1:
            tp = tp + 1
        elif y_test[i] == 1:
            fn = fn + 1
        elif predicted[i] == 1:
            fp = fp + 1
        else:
            tn = tn + 1
    accuracy = (tp + tn) / len(y_test) * 100
    precision = 0
    if tp + fp > 0:
        precision = tp / (tp + fp) * 100
    recall = 0
    if tp + fn > 0:
        recall = tp / (tp + fn) * 100
    f1 = 0
    if precision + recall > 0:
        f1 = 2 * precision * recall / (precision + recall)
    results.append([name, accuracy, precision, recall, f1])
    print(name)
    print("  " + name + " TP, FN, FP, TN :", tp, fn, fp, tn)
    print("  " + name + " accuracy  :", round(accuracy, 1), "%")
    print("  " + name + " precision :", round(precision, 1), "%")
    print("  " + name + " recall    :", round(recall, 1), "%")
    print("  " + name + " F1 score  :", round(f1, 1), "%")
    print()

# Chart 5: perceptron against the baseline.
x = np.arange(4)
plt.figure(figsize=(8, 4.5))
plt.bar(x - 0.2, results[0][1:5], width=0.4, label="Same as today", color="#9c9c9c")
plt.bar(x + 0.2, results[1][1:5], width=0.4, label="Perceptron", color="#4e79a7")
plt.xticks(x, ["Accuracy", "Precision", "Recall", "F1 score"])
plt.ylim(0, 100)
plt.ylabel("Percent (2025, unseen)")
plt.title("Forecasting Poor air one day ahead")
plt.legend()
plt.tight_layout()
plt.savefig("chart5_compared.png")
plt.close()

# %% Stage 6 - Deployment
print("--- Stage 6: Deployment ---")
today = pd.DataFrame({
    "pm25":               [78, 120, 85],
    "wind_forecast_kmph": [3.0, 18.0, 9.0],
    "humidity_pct": [72, 45, 60],
    "temp_c":       [15.0, 24.0, 19.0],
})
scaled = ((today - low) / (high - low)).values
for i in range(len(today)):
    total = np.dot(scaled[i], weights) + bias
    if total > 0:
        alert = "ALERT: Poor air likely tomorrow - plan indoor activities"
    else:
        alert = "No alert"
    t = today.iloc[i]
    print("PM2.5", int(t["pm25"]), " wind forecast", t["wind_forecast_kmph"], "km/h  humidity", int(t["humidity_pct"]),
          "%  ->", alert)
