diff --git a/Frontend/Analysis/analysis.css b/Frontend/Analysis/analysis.css
index a628a76..ec8077d 100644
--- a/Frontend/Analysis/analysis.css
+++ b/Frontend/Analysis/analysis.css
@@ -5,57 +5,7 @@
font-family: "Poppins", sans-serif;
}
-:root {
- --bg-primary: #0f1117;
- --bg-secondary: #161b27;
- --bg-card: #1a1f2e;
- --text-primary: #ffffff;
- --text-muted: #8892a4;
- --text-heading: #e2e8f0;
- --border-color: rgba(255, 255, 255, 0.1);
- --border-strong: rgba(255, 255, 255, 0.2);
- --shadow-color: rgba(0, 0, 0, 0.4);
- --input-bg: #1a1f2e;
- --btn-bg: rgba(255, 255, 255, 0.07);
- --btn-hover: rgba(255, 255, 255, 0.12);
- --bg-0: #020617;
- --bg-1: #0f172a;
- --bg-2: #1e293b;
- --panel: rgba(255, 255, 255, 0.08);
- --panel-strong: rgba(255, 255, 255, 0.12);
- --text: #f8fafc;
- --muted: #cbd5e1;
- --muted-2: #94a3b8;
- --accent: #38bdf8;
- --accent-2: #0ea5e9;
- --danger: #ef4444;
- --warning: #f59e0b;
- --shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
-}
-
-[data-theme="light"] {
- --bg-primary: #f4f6fa;
- --bg-secondary: #eaecf2;
- --bg-card: #ffffff;
- --text-primary: #0f1117;
- --text-muted: #5a6475;
- --text-heading: #1a202c;
- --border-color: rgba(0, 0, 0, 0.1);
- --border-strong: rgba(0, 0, 0, 0.2);
- --shadow-color: rgba(0, 0, 0, 0.1);
- --input-bg: #ffffff;
- --btn-bg: rgba(0, 0, 0, 0.04);
- --btn-hover: rgba(0, 0, 0, 0.08);
- --bg-0: #f8fafc;
- --bg-1: #eef6ff;
- --bg-2: #e2e8f0;
- --panel: rgba(255, 255, 255, 0.85);
- --panel-strong: rgba(255, 255, 255, 0.95);
- --text: #1e293b;
- --muted: #475569;
- --muted-2: #64748b;
- --shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
-}
+
html {
scroll-behavior: smooth;
diff --git a/Frontend/Analysis/analysis.html b/Frontend/Analysis/analysis.html
index f1b0098..b83c576 100644
--- a/Frontend/Analysis/analysis.html
+++ b/Frontend/Analysis/analysis.html
@@ -9,8 +9,8 @@
Climate Shield | Analysis
-
+
+
diff --git a/Frontend/index.html b/Frontend/index.html
index 01f3eb9..99dd39c 100644
--- a/Frontend/index.html
+++ b/Frontend/index.html
@@ -7,6 +7,7 @@
Climate Shield | Home
+
diff --git a/Frontend/script.js b/Frontend/script.js
index 1c044ca..20c4a49 100644
--- a/Frontend/script.js
+++ b/Frontend/script.js
@@ -207,59 +207,47 @@ window.onload = function () {
const insightElement = document.getElementById("climate-insight");
const anomalyElement = document.getElementById("anomaly-result");
- if (!insightElement || !anomalyElement) {
- return;
- }
-
- const climateInsightElement =
- document.getElementById("climate-insight");
-
- insightElement.innerText = insight;
-
- if (climateInsightElement) {
+ if (insightElement) {
const insight = generateClimateInsight(
1.8,
1.2,
"Andhra Pradesh"
);
-
- climateInsightElement.innerText = insight;
+ insightElement.innerText = insight;
}
- if (anomalyResultElement) {
+ if (anomalyElement) {
const tempData = [28, 29, 30, 45, 31, 29];
-
const results = detectAnomalies(tempData);
-
const anomalies = results.filter(
r => r.isAnomaly
);
- anomalyElement.innerHTML =
- anomalies.length === 0
- ? "✅ No unusual climate spikes detected"
- : anomalies.map(a =>
- `⚠️ Anomaly: ${a.value}°C (z=${a.zScore.toFixed(2)})`
- ).join("
");
-};
-};
-
-// Scroll to Top Button
-const scrollTopBtn = document.getElementById("scrollTopBtn");
-
-if (scrollTopBtn) {
- window.addEventListener("scroll", () => {
- if (window.scrollY > 300) {
- scrollTopBtn.classList.add("show");
- } else {
- scrollTopBtn.classList.remove("show");
- }
- });
+ anomalyElement.innerHTML =
+ anomalies.length === 0
+ ? "✅ No unusual climate spikes detected"
+ : anomalies.map(a =>
+ `⚠️ Anomaly: ${a.value}°C (z=${a.zScore.toFixed(2)})`
+ ).join("
");
+ } // Closes whatever block wrapped the anomaly logic
+
+ // Scroll to Top Button
+ const scrollTopBtn = document.getElementById("scrollTopBtn");
+
+ if (scrollTopBtn) {
+ window.addEventListener("scroll", () => {
+ if (window.scrollY > 300) {
+ scrollTopBtn.classList.add("show");
+ } else {
+ scrollTopBtn.classList.remove("show");
+ }
+ });
- scrollTopBtn.addEventListener("click", () => {
- window.scrollTo({
- top: 0,
- behavior: "smooth"
- });
- });
-}
+ scrollTopBtn.addEventListener("click", () => {
+ window.scrollTo({
+ top: 0,
+ behavior: "smooth"
+ });
+ });
+ }
+};
\ No newline at end of file
diff --git a/Frontend/style.css b/Frontend/style.css
index ddf221e..bcf7758 100644
--- a/Frontend/style.css
+++ b/Frontend/style.css
@@ -5,58 +5,7 @@
font-family: "Poppins", sans-serif;
}
-:root {
- --bg-primary: #0f1117;
- --bg-secondary: #161b27;
- --bg-card: #1a1f2e;
- --text-primary: #ffffff;
- --text-muted: #8892a4;
- --text-heading: #e2e8f0;
- --border-color: rgba(255, 255, 255, 0.1);
- --border-strong: rgba(255, 255, 255, 0.2);
- --shadow-color: rgba(0, 0, 0, 0.4);
- --input-bg: #1a1f2e;
- --btn-bg: rgba(255, 255, 255, 0.07);
- --btn-hover: rgba(255, 255, 255, 0.12);
- --bg-0: #020617;
- --bg-1: #0f172a;
- --bg-2: #1e293b;
- --panel: rgba(255, 255, 255, 0.08);
- --panel-strong: rgba(255, 255, 255, 0.12);
- --text: #f8fafc;
- --muted: #cbd5e1;
- --muted-2: #94a3b8;
- --accent: #38bdf8;
- --accent-2: #0ea5e9;
- --success: #22c55e;
- --danger: #ef4444;
- --warning: #f59e0b;
- --shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
-}
-
-[data-theme="light"] {
- --bg-primary: #f4f6fa;
- --bg-secondary: #eaecf2;
- --bg-card: #ffffff;
- --text-primary: #0f1117;
- --text-muted: #5a6475;
- --text-heading: #1a202c;
- --border-color: rgba(0, 0, 0, 0.1);
- --border-strong: rgba(0, 0, 0, 0.2);
- --shadow-color: rgba(0, 0, 0, 0.1);
- --input-bg: #ffffff;
- --btn-bg: rgba(0, 0, 0, 0.04);
- --btn-hover: rgba(0, 0, 0, 0.08);
- --bg-0: #f8fafc;
- --bg-1: #edf2f7;
- --bg-2: #dbeafe;
- --panel: rgba(255, 255, 255, 0.85);
- --panel-strong: rgba(255, 255, 255, 0.95);
- --text: #0f172a;
- --muted: #334155;
- --muted-2: #64748b;
- --shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
-}
+
.theme-toggle {
width: 42px;
diff --git a/Frontend/theme.css b/Frontend/theme.css
new file mode 100644
index 0000000..533025c
--- /dev/null
+++ b/Frontend/theme.css
@@ -0,0 +1,52 @@
+:root {
+ --bg-primary: #0f1117;
+ --bg-secondary: #161b27;
+ --bg-card: #1a1f2e;
+ --text-primary: #ffffff;
+ --text-muted: #8892a4;
+ --text-heading: #e2e8f0;
+ --border-color: rgba(255, 255, 255, 0.1);
+ --border-strong: rgba(255, 255, 255, 0.2);
+ --shadow-color: rgba(0, 0, 0, 0.4);
+ --input-bg: #1a1f2e;
+ --btn-bg: rgba(255, 255, 255, 0.07);
+ --btn-hover: rgba(255, 255, 255, 0.12);
+ --bg-0: #020617;
+ --bg-1: #0f172a;
+ --bg-2: #1e293b;
+ --panel: rgba(255, 255, 255, 0.08);
+ --panel-strong: rgba(255, 255, 255, 0.12);
+ --text: #f8fafc;
+ --muted: #cbd5e1;
+ --muted-2: #94a3b8;
+ --accent: #38bdf8;
+ --accent-2: #0ea5e9;
+ --success: #22c55e;
+ --danger: #ef4444;
+ --warning: #f59e0b;
+ --shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
+}
+
+[data-theme="light"] {
+ --bg-primary: #f4f6fa;
+ --bg-secondary: #eaecf2;
+ --bg-card: #ffffff;
+ --text-primary: #0f1117;
+ --text-muted: #5a6475;
+ --text-heading: #1a202c;
+ --border-color: rgba(0, 0, 0, 0.1);
+ --border-strong: rgba(0, 0, 0, 0.2);
+ --shadow-color: rgba(0, 0, 0, 0.1);
+ --input-bg: #ffffff;
+ --btn-bg: rgba(0, 0, 0, 0.04);
+ --btn-hover: rgba(0, 0, 0, 0.08);
+ --bg-0: #f8fafc;
+ --bg-1: #eef6ff;
+ --bg-2: #e2e8f0;
+ --panel: rgba(255, 255, 255, 0.85);
+ --panel-strong: rgba(255, 255, 255, 0.95);
+ --text: #1e293b;
+ --muted: #475569;
+ --muted-2: #64748b;
+ --shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
+}
diff --git a/Frontend/theme.js b/Frontend/theme.js
index d838333..a215818 100644
--- a/Frontend/theme.js
+++ b/Frontend/theme.js
@@ -1,25 +1,44 @@
(function () {
+ function getSystemPreference() {
+ return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
+ }
+
const saved = localStorage.getItem('theme');
- const preferred = window.matchMedia('(prefers-color-scheme: light)').matches
- ? 'light' : 'dark';
- const initial = saved || preferred;
+ let initial = saved || getSystemPreference();
document.documentElement.setAttribute('data-theme', initial);
+ // Listen for system theme changes dynamically
+ window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', function(e) {
+ // Only apply system theme if the user hasn't set a manual override
+ if (!localStorage.getItem('theme')) {
+ const newTheme = e.matches ? 'light' : 'dark';
+ document.documentElement.setAttribute('data-theme', newTheme);
+
+ const icon = document.getElementById('theme-icon');
+ if (icon) {
+ icon.textContent = newTheme === 'light' ? '🌙' : '☀️';
+ }
+
+ window.dispatchEvent(new CustomEvent('themechange', { detail: { theme: newTheme } }));
+ }
+ });
+
document.addEventListener('DOMContentLoaded', function () {
const btn = document.getElementById('theme-toggle');
const icon = document.getElementById('theme-icon');
if (!btn || !icon) return;
- icon.textContent = initial === 'light' ? '🌙' : '☀️';
+ // Set initial icon state based on the calculated initial theme
+ icon.textContent = document.documentElement.getAttribute('data-theme') === 'light' ? '🌙' : '☀️';
btn.addEventListener('click', function () {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
- localStorage.setItem('theme', next);
+ localStorage.setItem('theme', next); // User manually set a theme
icon.textContent = next === 'light' ? '🌙' : '☀️';
// Notify analysis.js to swap the map tile layer if it's listening
diff --git a/backend/alertsystem.py b/backend/alertsystem.py
index bde3472..13fb067 100644
--- a/backend/alertsystem.py
+++ b/backend/alertsystem.py
@@ -45,6 +45,10 @@ def fetch_gis_alert_data():
)
from flask_cors import CORS
+try:
+ from .risk_calculator import calculate_risk_scores, get_detailed_risks, generate_alerts
+except ImportError:
+ from risk_calculator import calculate_risk_scores, get_detailed_risks, generate_alerts
# =========================================================
# APP CONFIG
@@ -308,98 +312,13 @@ def get_weather_insights():
# RISK CALCULATIONS
# ----------------------------------------------------
- flood_risk_metric = round(
- min(
- 1.0,
- (
- rain_val * 0.6 +
- humid_val * 0.3 +
- wind_val * 0.1
- ) / 100
- ),
- 3
- )
-
- heat_risk_metric = round(
- min(
- 1.0,
- (
- max(temp_val - 25, 0) * 2 +
- humid_val * 0.3
- ) / 100
- ),
- 3
- )
-
- wildfire_risk_metric = round(
- min(
- 1.0,
- (
- max(temp_val - 32, 0) * 1.5 +
- (100 - humid_val) * 0.5 +
- wind_val * 0.2
- ) / 100
- ),
- 3
- )
-
- cyclone_risk_metric = round(
- min(
- 1.0,
- (
- wind_val * 1.5 +
- rain_val * 0.5
- ) / 100
- ),
- 3
- )
-
- drought_risk_metric = round(
- min(
- 1.0,
- (
- max(temp_val - 28, 0) +
- (100 - humid_val)
- ) / 100
- ),
- 3
- )
+ current_risk_scores = calculate_risk_scores(temp_val, humid_val, wind_val, rain_val)
# ----------------------------------------------------
# ALERTS
# ----------------------------------------------------
- calculated_alerts = []
-
- if flood_risk_metric >= 0.6:
- calculated_alerts.append(
- "⚠ High Flood Risk Detected"
- )
-
- if heat_risk_metric >= 0.6:
- calculated_alerts.append(
- "🔥 Heatwave Conditions Possible"
- )
-
- if wildfire_risk_metric >= 0.6:
- calculated_alerts.append(
- "🌲 Elevated Wildfire Risk"
- )
-
- if cyclone_risk_metric >= 0.6:
- calculated_alerts.append(
- "🌀 Cyclone Risk Detected"
- )
-
- if drought_risk_metric >= 0.6:
- calculated_alerts.append(
- "☀ Drought Conditions Possible"
- )
-
- if not calculated_alerts:
- calculated_alerts.append(
- "✅ No major climate threats detected."
- )
+ calculated_alerts = generate_alerts(current_risk_scores)
# ----------------------------------------------------
# FORECAST GENERATION
@@ -430,60 +349,7 @@ def get_weather_insights():
"humidity": day_humidity,
"rainfall": round(day_rain, 1),
"wind_speed": day_wind,
- "risks": {
- "flood_risk": round(
- min(
- 1.0,
- (
- day_rain * 0.6 +
- day_humidity * 0.3 +
- day_wind * 0.1
- ) / 100
- ),
- 3
- ),
- "heat_risk": round(
- min(
- 1.0,
- (
- max(day_temp - 25, 0) * 2 +
- day_humidity * 0.3
- ) / 100
- ),
- 3
- ),
- "wildfire_risk": round(
- min(
- 1.0,
- (
- max(day_temp - 32, 0) * 1.5 +
- (100 - day_humidity) * 0.5 +
- day_wind * 0.2
- ) / 100
- ),
- 3
- ),
- "cyclone_risk": round(
- min(
- 1.0,
- (
- day_wind * 1.5 +
- day_rain * 0.5
- ) / 100
- ),
- 3
- ),
- "drought_risk": round(
- min(
- 1.0,
- (
- max(day_temp - 28, 0) +
- (100 - day_humidity)
- ) / 100
- ),
- 3
- )
- }
+ "risks": calculate_risk_scores(day_temp, day_humidity, day_wind, day_rain)
})
return jsonify({
@@ -505,23 +371,7 @@ def get_weather_insights():
"wind_speed": wind_val
},
- "risks": {
- "flood_risk": round(flood_risk_metric, 3),
- "flood_risk_confidence": round(flood_risk_metric * 100, 1),
- "flood_risk_level": "HIGH" if flood_risk_metric >= 0.6 else "MEDIUM" if flood_risk_metric >= 0.3 else "LOW",
- "heat_risk": round(heat_risk_metric, 3),
- "heat_risk_confidence": round(heat_risk_metric * 100, 1),
- "heat_risk_level": "HIGH" if heat_risk_metric >= 0.6 else "MEDIUM" if heat_risk_metric >= 0.3 else "LOW",
- "wildfire_risk": round(wildfire_risk_metric, 3),
- "wildfire_risk_confidence": round(wildfire_risk_metric * 100, 1),
- "wildfire_risk_level": "HIGH" if wildfire_risk_metric >= 0.6 else "MEDIUM" if wildfire_risk_metric >= 0.3 else "LOW",
- "cyclone_risk": round(cyclone_risk_metric, 3),
- "cyclone_risk_confidence": round(cyclone_risk_metric * 100, 1),
- "cyclone_risk_level": "HIGH" if cyclone_risk_metric >= 0.6 else "MEDIUM" if cyclone_risk_metric >= 0.3 else "LOW",
- "drought_risk": round(drought_risk_metric, 3),
- "drought_risk_confidence": round(drought_risk_metric * 100, 1),
-"drought_risk_level": "HIGH" if drought_risk_metric >= 0.6 else "MEDIUM" if drought_risk_metric >= 0.3 else "LOW",
- },
+ "risks": get_detailed_risks(current_risk_scores),
"forecast": forecast,
@@ -637,28 +487,51 @@ def city_suggestions():
suggestions = []
+ seen = set()
for item in data:
address = item.get("address", {})
- if not (
- address.get("city")
- or address.get("town")
- or address.get("village")
- or address.get("municipality")
- ):
- continue
-
city_name = (
address.get("city")
or address.get("town")
or address.get("village")
or address.get("municipality")
+ or address.get("city_district")
+ or address.get("county")
+ or address.get("suburb")
+ or address.get("neighbourhood")
+ or address.get("state")
)
+ if not city_name:
+ continue
+
+ state_name = address.get("state", "")
+ if not state_name:
+ display_name = item.get("display_name", "")
+ parts = [p.strip() for p in display_name.split(",")]
+ if len(parts) >= 2:
+ state_name = parts[-2]
+
+ country_code = address.get("country_code", "").upper()
+ if not country_code:
+ country_name = address.get("country", "")
+ if not country_name:
+ display_name = item.get("display_name", "")
+ parts = [p.strip() for p in display_name.split(",")]
+ if len(parts) >= 1:
+ country_name = parts[-1]
+ country_code = country_name
+
+ key = (city_name.lower(), state_name.lower(), country_code.lower())
+ if key in seen:
+ continue
+ seen.add(key)
+
suggestions.append({
"city": city_name,
- "state": address.get("state", ""),
- "country": address.get("country", "")
+ "state": state_name,
+ "country": country_code
})
suggestions.sort(
@@ -716,3 +589,5 @@ def chatbot():
debug=True
)
+
+# Auto-reloaded to apply new API key env variables
diff --git a/backend/conftest.py b/backend/conftest.py
new file mode 100644
index 0000000..d39e3c6
--- /dev/null
+++ b/backend/conftest.py
@@ -0,0 +1,55 @@
+import pytest
+from backend.alertsystem import app
+
+@pytest.fixture
+def client():
+ app.config["TESTING"] = True
+ with app.test_client() as client:
+ yield client
+
+@pytest.fixture
+def mock_openweather_geo():
+ return [
+ {
+ "name": "Delhi",
+ "lat": 28.6139,
+ "lon": 77.2090,
+ "country": "IN",
+ "state": "Delhi"
+ }
+ ]
+
+@pytest.fixture
+def mock_openweather_weather():
+ return {
+ "main": {
+ "temp": 35.0,
+ "humidity": 40
+ },
+ "wind": {
+ "speed": 5.0
+ },
+ "rain": {
+ "1h": 0
+ }
+ }
+
+@pytest.fixture
+def mock_openweather_forecast():
+ return {
+ "list": [
+ {
+ "dt_txt": "2023-10-01 12:00:00",
+ "main": {
+ "temp": 36.0,
+ "humidity": 45
+ },
+ "wind": {
+ "speed": 4.5
+ },
+ "rain": {
+ "3h": 0
+ }
+ }
+ ] * 40 # Just enough items to simulate API response
+ }
diff --git a/backend/risk_calculator.py b/backend/risk_calculator.py
new file mode 100644
index 0000000..c26bca2
--- /dev/null
+++ b/backend/risk_calculator.py
@@ -0,0 +1,46 @@
+def calculate_risk_scores(temp_val, humid_val, wind_val, rain_val):
+ flood_risk_metric = round(min(1.0, (rain_val * 0.6 + humid_val * 0.3 + wind_val * 0.1) / 100), 3)
+ heat_risk_metric = round(min(1.0, (max(temp_val - 25, 0) * 2 + humid_val * 0.3) / 100), 3)
+ wildfire_risk_metric = round(min(1.0, (max(temp_val - 32, 0) * 1.5 + (100 - humid_val) * 0.5 + wind_val * 0.2) / 100), 3)
+ cyclone_risk_metric = round(min(1.0, (wind_val * 1.5 + rain_val * 0.5) / 100), 3)
+ drought_risk_metric = round(min(1.0, (max(temp_val - 28, 0) + (100 - humid_val)) / 100), 3)
+
+ return {
+ "flood_risk": flood_risk_metric,
+ "heat_risk": heat_risk_metric,
+ "wildfire_risk": wildfire_risk_metric,
+ "cyclone_risk": cyclone_risk_metric,
+ "drought_risk": drought_risk_metric,
+ }
+
+def get_risk_level(metric):
+ if metric >= 0.6:
+ return "HIGH"
+ elif metric >= 0.3:
+ return "MEDIUM"
+ return "LOW"
+
+def get_detailed_risks(scores):
+ details = {}
+ for risk_type, metric in scores.items():
+ details[risk_type] = metric
+ details[f"{risk_type}_confidence"] = round(metric * 100, 1)
+ details[f"{risk_type}_level"] = get_risk_level(metric)
+ return details
+
+def generate_alerts(scores):
+ alerts = []
+ if scores["flood_risk"] >= 0.6:
+ alerts.append("⚠ High Flood Risk Detected")
+ if scores["heat_risk"] >= 0.6:
+ alerts.append("🔥 Heatwave Conditions Possible")
+ if scores["wildfire_risk"] >= 0.6:
+ alerts.append("🌲 Elevated Wildfire Risk")
+ if scores["cyclone_risk"] >= 0.6:
+ alerts.append("🌀 Cyclone Risk Detected")
+ if scores["drought_risk"] >= 0.6:
+ alerts.append("☀ Drought Conditions Possible")
+
+ if not alerts:
+ alerts.append("✅ No major climate threats detected.")
+ return alerts
diff --git a/backend/test_chatbot_api.py b/backend/test_chatbot_api.py
new file mode 100644
index 0000000..c6099ab
--- /dev/null
+++ b/backend/test_chatbot_api.py
@@ -0,0 +1,61 @@
+import pytest
+import json
+
+def test_chatbot_api_missing_message(client):
+ response = client.post("/chatbot", json={})
+ assert response.status_code == 400
+ data = response.get_json()
+ assert data["success"] is False
+ assert "Please provide a message" in data["message"]
+
+def test_chatbot_api_greeting(client):
+ response = client.post("/chatbot", json={"message": "hello"})
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data["success"] is True
+ assert "response" in data
+ assert any(phrase in data["response"] for phrase in ["Hello", "Hi", "Greetings"])
+
+def test_chatbot_api_knowledge_base(client):
+ response = client.post("/chatbot", json={"message": "what is a flood?"})
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data["success"] is True
+ assert "flood" in data["response"].lower() or "submerge" in data["response"].lower()
+
+def test_chatbot_api_with_context(client):
+ payload = {
+ "message": "is it safe?",
+ "context": {
+ "location": {"city": "Mumbai", "state": "MH", "country": "IN"},
+ "weather": {"temperature": 32, "humidity": 85, "rainfall": 120, "wind_speed": 15},
+ "risks": {
+ "flood_risk": 0.85,
+ "heat_risk": 0.2,
+ "wildfire_risk": 0.05,
+ "cyclone_risk": 0.3,
+ "drought_risk": 0.1
+ }
+ }
+ }
+ response = client.post("/chatbot", json=payload)
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data["success"] is True
+ # The chatbot should mention the highest risk (Flood) based on context
+ assert "Flood" in data["response"]
+ assert "0.850" in data["response"]
+
+def test_chatbot_api_unknown_topic(client):
+ response = client.post("/chatbot", json={"message": "how do I cook pasta?"})
+ assert response.status_code == 200
+ data = response.get_json()
+ assert data["success"] is True
+ # The chatbot should gracefully handle unknown topic
+ assert any(word in data["response"].lower() for word in ["outside my current scope", "can't help you", "select from one of the above", "could you explain", "more specific", "can you elaborate", "try asking me"])
+
+def test_chatbot_api_empty_payload(client):
+ response = client.post("/chatbot", data="")
+ assert response.status_code == 400
+ data = response.get_json()
+ assert data["success"] is False
diff --git a/backend/test_risk_calculator.py b/backend/test_risk_calculator.py
new file mode 100644
index 0000000..845b080
--- /dev/null
+++ b/backend/test_risk_calculator.py
@@ -0,0 +1,96 @@
+import pytest
+from backend.risk_calculator import calculate_risk_scores, get_risk_level, get_detailed_risks, generate_alerts
+
+def test_calculate_risk_scores_normal():
+ # Moderate temperature, normal humidity, low wind, no rain
+ scores = calculate_risk_scores(temp_val=25, humid_val=50, wind_val=10, rain_val=0)
+
+ assert 0 <= scores["flood_risk"] <= 1.0
+ assert 0 <= scores["heat_risk"] <= 1.0
+ assert 0 <= scores["wildfire_risk"] <= 1.0
+ assert 0 <= scores["cyclone_risk"] <= 1.0
+ assert 0 <= scores["drought_risk"] <= 1.0
+
+ # Check specific values based on formula
+ # heat_risk = max(25-25,0)*2 + 50*0.3 / 100 = 15/100 = 0.15
+ assert scores["heat_risk"] == 0.15
+
+def test_calculate_risk_scores_extreme_heat():
+ # High temp, low humidity
+ scores = calculate_risk_scores(temp_val=45, humid_val=10, wind_val=5, rain_val=0)
+
+ # heat_risk = (max(45-25,0)*2 + 10*0.3)/100 = (40 + 3)/100 = 0.43
+ assert scores["heat_risk"] == 0.43
+
+ # wildfire_risk = (max(45-32)*1.5 + 90*0.5 + 5*0.2)/100 = (19.5 + 45 + 1)/100 = 0.655
+ assert scores["wildfire_risk"] == 0.655
+
+def test_calculate_risk_scores_extreme_flood():
+ # Heavy rain, high wind
+ scores = calculate_risk_scores(temp_val=20, humid_val=95, wind_val=50, rain_val=150)
+
+ # flood = (150*0.6 + 95*0.3 + 50*0.1)/100 = (90 + 28.5 + 5)/100 = 1.235 -> capped at 1.0
+ assert scores["flood_risk"] == 1.0
+
+ # cyclone = (50*1.5 + 150*0.5)/100 = (75 + 75)/100 = 1.5 -> capped at 1.0
+ assert scores["cyclone_risk"] == 1.0
+
+def test_calculate_risk_scores_extreme_drought():
+ # Very high temp, extremely low humidity, zero rain
+ scores = calculate_risk_scores(temp_val=40, humid_val=5, wind_val=20, rain_val=0)
+
+ # drought = (max(40-28, 0) + (100 - 5)) / 100 = (12 + 95)/100 = 1.07 -> capped at 1.0
+ assert scores["drought_risk"] == 1.0
+
+def test_calculate_risk_scores_extreme_cold():
+ # Cold temperatures should yield 0 for heat and wildfire risk contribution from temp
+ scores = calculate_risk_scores(temp_val=-10, humid_val=80, wind_val=15, rain_val=0)
+
+ assert scores["heat_risk"] == 0.24 # (max(-10-25, 0)*2 + 80*0.3)/100 = (0 + 24)/100 = 0.24
+ assert scores["wildfire_risk"] == 0.13 # (max(-10-32, 0)*1.5 + 20*0.5 + 15*0.2)/100 = (0 + 10 + 3)/100 = 0.13
+ assert scores["drought_risk"] == 0.2 # (max(-10-28, 0) + 20)/100 = 0.2
+
+def test_get_risk_level():
+ assert get_risk_level(0.1) == "LOW"
+ assert get_risk_level(0.3) == "MEDIUM"
+ assert get_risk_level(0.59) == "MEDIUM"
+ assert get_risk_level(0.6) == "HIGH"
+ assert get_risk_level(1.0) == "HIGH"
+
+def test_get_detailed_risks():
+ scores = {"flood_risk": 0.65, "heat_risk": 0.2}
+ detailed = get_detailed_risks(scores)
+
+ assert detailed["flood_risk"] == 0.65
+ assert detailed["flood_risk_confidence"] == 65.0
+ assert detailed["flood_risk_level"] == "HIGH"
+
+ assert detailed["heat_risk"] == 0.2
+ assert detailed["heat_risk_confidence"] == 20.0
+ assert detailed["heat_risk_level"] == "LOW"
+
+def test_generate_alerts():
+ # High risk
+ high_risk_scores = {
+ "flood_risk": 0.8,
+ "heat_risk": 0.2,
+ "wildfire_risk": 0.1,
+ "cyclone_risk": 0.9,
+ "drought_risk": 0.0
+ }
+ alerts = generate_alerts(high_risk_scores)
+ assert len(alerts) == 2
+ assert "⚠ High Flood Risk Detected" in alerts
+ assert "🌀 Cyclone Risk Detected" in alerts
+
+ # Low risk
+ low_risk_scores = {
+ "flood_risk": 0.1,
+ "heat_risk": 0.1,
+ "wildfire_risk": 0.1,
+ "cyclone_risk": 0.1,
+ "drought_risk": 0.1
+ }
+ alerts_low = generate_alerts(low_risk_scores)
+ assert len(alerts_low) == 1
+ assert "✅ No major climate threats detected." in alerts_low
diff --git a/backend/test_weather_api.py b/backend/test_weather_api.py
new file mode 100644
index 0000000..c796fbb
--- /dev/null
+++ b/backend/test_weather_api.py
@@ -0,0 +1,101 @@
+import pytest
+from unittest.mock import patch
+import json
+import os
+import requests
+
+def test_weather_api_missing_fields(client):
+ response = client.post("/weather", json={"city": "Delhi"})
+ assert response.status_code == 400
+ data = response.get_json()
+ assert data["success"] is False
+ assert "Please fill all fields" in data["message"]
+
+@patch.dict(os.environ, {}, clear=True)
+def test_weather_api_missing_api_key(client):
+ response = client.post("/weather", json={"city": "Delhi", "state": "Delhi", "country": "IN"})
+ assert response.status_code == 500
+ data = response.get_json()
+ assert data["success"] is False
+ assert "Weather service configuration error" in data["message"]
+
+@patch("requests.get")
+@patch.dict(os.environ, {"OPENWEATHER_API_KEY": "fake_key"})
+def test_weather_api_success(mock_get, client, mock_openweather_geo, mock_openweather_weather, mock_openweather_forecast):
+ class MockResponse:
+ def __init__(self, json_data, status_code=200):
+ self.json_data = json_data
+ self.status_code = status_code
+
+ def json(self):
+ return self.json_data
+
+ def raise_for_status(self):
+ pass
+
+ def side_effect(*args, **kwargs):
+ url = args[0]
+ if "geo/1.0/direct" in url:
+ return MockResponse(mock_openweather_geo)
+ elif "data/2.5/weather" in url:
+ return MockResponse(mock_openweather_weather)
+ elif "data/2.5/forecast" in url:
+ return MockResponse(mock_openweather_forecast)
+ return MockResponse({}, 404)
+
+ mock_get.side_effect = side_effect
+
+ response = client.post("/weather", json={"city": "Delhi", "state": "Delhi", "country": "IN"})
+
+ assert response.status_code == 200
+ data = response.get_json()
+
+ assert data["success"] is True
+ assert data["location"]["city"] == "Delhi"
+ assert data["weather"]["temperature"] == 35.0
+ assert "flood_risk" in data["risks"]
+ assert len(data["forecast"]) == 5
+ assert len(data["alerts"]) > 0
+
+@patch("requests.get")
+@patch.dict(os.environ, {"OPENWEATHER_API_KEY": "fake_key"})
+def test_weather_api_location_not_found(mock_get, client):
+ class MockResponse:
+ def __init__(self, json_data, status_code=200):
+ self.json_data = json_data
+ self.status_code = status_code
+ def json(self):
+ return self.json_data
+ def raise_for_status(self):
+ pass
+
+ mock_get.return_value = MockResponse([]) # Empty list means not found
+
+ response = client.post("/weather", json={"city": "FakeCity", "state": "FakeState", "country": "XX"})
+
+ assert response.status_code == 404
+ data = response.get_json()
+ assert data["success"] is False
+ assert "Location not found" in data["message"]
+
+@patch("requests.get")
+@patch.dict(os.environ, {"OPENWEATHER_API_KEY": "fake_key"})
+def test_weather_api_timeout(mock_get, client):
+ mock_get.side_effect = requests.exceptions.Timeout("Connection timed out")
+
+ response = client.post("/weather", json={"city": "Delhi", "state": "Delhi", "country": "IN"})
+
+ assert response.status_code == 500
+ data = response.get_json()
+ assert data["success"] is False
+
+@patch("requests.get")
+@patch.dict(os.environ, {"OPENWEATHER_API_KEY": "fake_key"})
+def test_weather_api_connection_error(mock_get, client):
+ mock_get.side_effect = requests.exceptions.ConnectionError("Connection error")
+
+ response = client.post("/weather", json={"city": "Delhi", "state": "Delhi", "country": "IN"})
+
+ assert response.status_code == 500
+ data = response.get_json()
+ assert data["success"] is False